Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 296 additions & 22 deletions .opencode/package-lock.json

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions .opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,14 @@
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.0.0"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.4.3",
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@opencode-ai/plugin": "1.18.30"
}
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to EGC are documented here.
- **The memory protocol says the state store is owned by the server and what to do without `get_state`** (#1398, found through @Akisolu's follow-up checks in #1380): the protocol text still called the state files plain Markdown and pointed agents at a per-project path, so an agent whose tool had no MCP servers registered read and wrote memory on the filesystem itself, unencrypted. Every variant now says the store is owned by `egc-memory`, encrypted, one file per project and branch, never read or written directly, and that a missing `get_state` means the server is not registered: say so and point at `egc init`. The protocol version is unchanged, so the wording reaches fresh installs; the in-place upgrade of existing blocks waits for the next protocol bump (#1395).
- **Existing installs receive the corrected memory protocol, and the doctor points at state files left in plain text** (#1401, closes #1395, found through @Akisolu's follow-up checks in #1380): the protocol text fixed in #1398 only reached new installs, because the in-place upgrade is keyed on the protocol version and that stayed at 5; the cognitive protocol is now v6, so the next `egc init` or `egc auto-update` rewrites the block in every context file of an install already in place (Cursor `cursor.rules` and the Codex TOML string included). `egc doctor` also gained a `State files` section: it reads the first bytes of every state file under `~/.egc/state` (the per-project directories too, never following links, never decrypting) and lists the ones without the encrypted header, with the last write of each, the first five shown and the rest counted, plus the guidance that a plain file either predates 1.1.6 or was written straight to disk by a tool with no `egc-memory` server, that the server encrypts it on its next save, and that `egc init` in that project gives the tool the server. The JSON report carries the same finding as `plaintextStateFiles`.
- **The DCO check skips the merge commits GitHub itself creates** (#1413): a merge made through "Update branch" or the update-branch API is authored as the maintainer with committer GitHub, joins histories that were already checked, carries no authored content and never carries a sign-off anyone can add, so it turned the check red on a contributor's PR for a commit they did not write. Such a commit (two or more parents, committer `noreply@github.com`) is skipped; a merge a person made locally is still checked like any other commit.
- **Every install target retires the files a renamed or dropped command, prompt, rule or skill leaves behind, not just OpenCode** (#1412, generalizes #1411): when a file left the install plan, the copy an earlier install wrote used to stay in the target forever -- `egc doctor` reported it as orphaned and `egc repair` pruned the state entry, but nothing removed the file, because the installer's rule is that it never deletes what the current plan does not cover. `planRetirements` now defaults to a plan-diff instead of an empty list: it compares the previous install-state's recorded `copy-file` operations against what today's plan would actually write (a directory-shaped scaffold entry still shields every file copied under it), and offers up whatever is no longer covered. The same identity check #1411 introduced decides whether a candidate is actually safe to delete (a regular file, reached through no link, byte-identical to the source EGC copied), listed by `--dry-run` under `Files to retire` and reported as `retired file` -- so renaming a command or prompt now cleans up after itself on the next install or auto-update, on every target. A source no longer in the repository cannot be identity-checked and is left in place, same as a file the person edited. OpenCode keeps its own narrower `planRetirements` (the egc-universal package cleanup, plus the permanent `opencode.json` exception), unaffected. Merge-json and hook operations have no retirement counterpart yet.
- **The OpenCode install no longer dumps the egc-universal package into the config directory, which made OpenCode stop responding on every request** (#1396, reported and bisected by @Akisolu across two log sets): the `opencode` target copied the whole `.opencode/` folder of the repository, the source of the npm package, into `~/.config/opencode/`. OpenCode imports every `{tool,tools}/*.{js,ts}` and `{plugin,plugins}/*.{ts,js}` file it finds there at start, so the copied TypeScript sources failed to load and every prompt died with them; the copied `package.json` made OpenCode's own dependency install fail on every start; and the copied `opencode.json` replaced the person's global config (model, permission, plugin list) on every install and auto-update. The target now plans only the markdown OpenCode reads or that stays inert from that folder (`commands`, `instructions`, `prompts`) next to the real plugin, skills and hooks. Installs already affected are repaired by the next `egc install` or `egc auto-update`: the files an earlier install wrote from the package are read from the install-state and retired (only a regular file still byte-identical to what EGC copied, reached through no link, never a link or a directory; a file the person replaced stays; emptied directories dropped), listed by `--dry-run` under `Files to retire` and reported as `retired file`. The person's `opencode.json` is never overwritten or removed again.
- **The installer migrates its own June 2026 linked skills layout instead of refusing to update it** (#1400): machines set up before 10 June 2026 have the Antigravity CLI skills as one link per skill into the Gemini home copy (`~/.gemini/skills/egc/`), and the hardened installer refused to write through them, so `egc auto-update` failed on that target on every run while `egc doctor` still reported it as OK. A link below the target root whose resolved target sits inside that managed copy is now recognised as EGC's own layout: the link is removed (never what it pointed at) and the real files are written in its place, reported as `migrated legacy link` in the result and listed by `egc install --dry-run` under `Legacy links to migrate`. A link that resolves anywhere else, or a dangling one, keeps the refusal. Tests cover the migration, the dry run, the foreign link, the dangling link, and a full apply of the `egc` target over the legacy layout; the troubleshooting page explains both cases.
- **OpenCode receives the EGC servers where it reads them** (#1405, found through @Akisolu's bisect in #1396): the registration wrote a `mcpServers` block, the shape Claude-style configs use, into `~/.config/opencode/config.json`, and on Windows only when that file already existed, with a second target under `%APPDATA%` that OpenCode never reads. OpenCode resolves its config directory through xdg-basedir (`~/.config/opencode` on every platform), loads `opencode.json` and the legacy `config.json`, and reads MCP servers from the `mcp` key as `{ type: "local", command: [...] }`. `egc init` and the installers now register into `opencode.json` (or `config.json` when only that exists) under `mcp` in that shape, gate on the config directory or the `opencode` binary on every platform, retire the `mcpServers` block an older EGC left behind (entries the person added stay), and the guardian's config fallback reads the new shape. Seven registration tests and one fallback test cover the fresh file, an existing file with other servers, the legacy file, a foreign block, an invalid container, the gate per platform and `XDG_CONFIG_HOME`.
Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This document describes the planned development direction for EGC (Extended Glob
- The Token Crusher keeps the lines that say why a test failed (Jest, Vitest and Mocha markers and headers, `Received` and `Expected` diffs, Go locations and runtime frames), and the Guardian git checks went through a hardening round with a test for every form now refused: `git push` refspecs, `git config` writes in any flag order or letter case, inline `-c` and `--config-env` overrides, alias values (#1403 by @prateeekbuilds, mission #1382, nine review rounds with each case probed against `main`).
- The first steps of `egc init` are check lines like the rest of the run: `cognitive protocol` with the number of tools at the current protocol and the ones installed or upgraded, `MCP registration` with the tools already registered and the ones written, `memory` with the migration count of the store just opened, and `commit-privacy filter` listing what it changed once and reporting the filter as configured afterwards; the filter planner reads the local values and lists only what is missing, so the installers stop announcing three changes on a configured repository (#1416).
- A parked `session_wait` no longer depends on the file watcher alone: while a waiter is parked the mesh transport also polls the size and mtime of the store and its `-wal` every 500 ms, so a silent FSEvents stream on macOS costs half a second instead of the repoll ceiling, and the parked-waiter test stops flaking on the macOS runners (#1423, closes #1420).
- Every install target, not just OpenCode, retires the files a renamed or dropped command, prompt, rule or skill leaves behind: `planRetirements` defaults to diffing the previous install-state against what the current plan would actually write instead of an empty list, so a rename now cleans up its old destination on the next install or auto-update everywhere, through the same identity check #1411 introduced (#1412, generalizes #1411).

## v1.1.21: The Hardening Round (Released 2026-09-05)
- A security hardening round lands as eighteen steps in seventeen pull requests (#1356 to #1363, #1365 to #1373): the memory server, the installer, the hooks and the dashboard get tighter defaults and stricter checks on what they accept from disk, from the network and from the tools around them, each step with its own tests and its own review. Nothing changes in the documented commands; all of it ships with the next release.
Expand Down
10 changes: 9 additions & 1 deletion scripts/lib/install-lifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -1257,7 +1257,15 @@ function describeUnrepairable(unrepairable) {
}

// Drops the orphaned entries from the state that is about to be rewritten,
// so doctor converges to OK; the installed files stay on disk.
// so doctor converges to OK; the installed files stay on disk. This is the
// "source is gone" half of cleaning up after a renamed or dropped file: a
// recorded operation whose source the reference repo no longer has cannot
// be identity-checked, so repair only forgets it here. The "source is
// still there, just not part of the plan anymore" half -- the common case
// for a rename -- is handled separately by planGenericRetirements
// (install-targets/helpers.js), which the next install or auto-update
// runs and which does remove the file, after the same identity check
// (#1412).
function pruneOrphanedOperations(desiredPlan, orphanedInspections) {
if (orphanedInspections.length === 0) return;
const orphanKeys = new Set(orphanedInspections.map(entry => operationIdentityKey(entry.operation)));
Expand Down
120 changes: 117 additions & 3 deletions scripts/lib/install-targets/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,116 @@ function createDefaultScaffoldOperations(input, adapter) {
});
}

// What today's scaffold operations would actually write, at the
// granularity the install-state records: single files in `files`,
// whole source directories (still copied recursively, one child file
// at a time) in `dirs`. Mirrors the file/directory split
// materializeScaffoldOperation (install-executor.js) applies when it
// turns these same scaffold operations into the copy-file entries
// the state records, so a directory scaffold entry here shields every
// file under it even though no single copy-file operation names the
// directory itself.
function collectCurrentlyCoveredDestinations(operations, repoRoot) {
const files = new Set();
const dirs = new Set();
for (const operation of Array.isArray(operations) ? operations : []) {
const destination = typeof operation.destinationPath === 'string' ? operation.destinationPath : null;
if (!destination) continue;
const resolvedDestination = path.resolve(destination);
const source = typeof operation.sourceRelativePath === 'string' ? operation.sourceRelativePath : null;
if (!source) {
// Nothing repo-relative to check the shape of (a merge or hook
// operation may carry its payload some other way): treat the
// destination as covered rather than guess, so it is never offered
// up for retirement by mistake.
files.add(resolvedDestination);
continue;
}
let stat;
try {
stat = fs.statSync(path.join(repoRoot, ...normalizeRelativePath(source).split('/')));
} catch {
// Source unreadable from here: same reasoning, stay conservative.
files.add(resolvedDestination);
continue;
}
(stat.isDirectory() ? dirs : files).add(resolvedDestination);
}
return { files, dirs };
}

function isDestinationCovered(resolved, { files, dirs }) {
if (files.has(resolved)) return true;
for (const dir of dirs) {
if (resolved === dir || resolved.startsWith(dir + path.sep)) return true;
}
return false;
}

// The default planRetirements body: compares the previous install-state's
// managed copy-file operations against what this plan would write today: a
// destination the state remembers EGC copied, that no scaffold operation
// in the current plan still covers, is offered up for retirement. Renaming
// or dropping a command, prompt, rule or skill from the package is exactly
// this -- the old destination stops being covered and is cleaned up on the
// next install or auto-update.
//
// Only recorded `copy-file` operations are diffed; merge-json and hook
// operations (settings.json entries, MCP config merges) have no
// counterpart here yet -- left as an open question by #1412.
//
// This only decides which destinations are *candidates*. The identity
// check that decides whether one is actually safe to delete -- a regular
// file, reached through no link, byte-identical to the source EGC copied
// -- happens later in install/apply.js's isRetirableFile, the same test
// #1411 introduced for OpenCode. A source no longer in the repository
// (renamed or removed, so identity cannot be verified) fails that check
// like any other unreadable source: the candidate is reported nowhere and
// the file is left in place, not deleted on the strength of the state
// entry alone.
function planGenericRetirements(input, adapter) {
const { readInstallState } = require('../install-state');
const repoRoot = input.repoRoot || process.cwd();
const targetRoot = path.resolve(adapter.resolveRoot(input));

let previous;
try {
previous = readInstallState(adapter.getInstallStatePath(input));
} catch {
// No previous install, or a state file that cannot be trusted: nothing
// to diff against, so nothing to retire.
return [];
}

const covered = collectCurrentlyCoveredDestinations(
Array.isArray(input.operations) ? input.operations : adapter.planOperations(input),
repoRoot
);

const retirements = [];
const seen = new Set();
for (const operation of Array.isArray(previous.operations) ? previous.operations : []) {
if (operation.ownership !== 'managed' || operation.kind !== 'copy-file') continue;
const destinationPath = typeof operation.destinationPath === 'string' ? operation.destinationPath : '';
if (!destinationPath) continue;
const resolved = path.resolve(destinationPath);
if (!resolved.startsWith(targetRoot + path.sep) || seen.has(resolved)) continue;
if (isDestinationCovered(resolved, covered)) continue;
const source = normalizeRelativePath(String(operation.sourceRelativePath || ''));
if (!source) continue;
seen.add(resolved);
retirements.push({
destinationPath: resolved,
sourceRelativePath: source,
// The file EGC copied there, for the apply to compare against: a
// file the person replaced since is theirs and stays.
sourcePath: path.join(repoRoot, ...source.split('/')),
reason: 'file left the install plan',
});
}
return retirements;
}

function createInstallTargetAdapter(config) {
const adapter = {
id: config.id,
Expand Down Expand Up @@ -431,13 +541,16 @@ function createInstallTargetAdapter(config) {
return createDefaultScaffoldOperations(input, adapter);
},
// Files a previous install wrote that this plan no longer covers and
// that the target wants removed on the next apply; most targets have
// none (the installer never deletes what it did not write).
// that the target wants removed on the next apply. An adapter
// with its own rules (e.g. OpenCode's egc-universal package
// cleanup, narrower and with its own kept-files exception)
// defines config.planRetirements and is used as-is; every other
// target falls back to the generic plan-diff below (#1412).
planRetirements(input = {}) {
if (typeof config.planRetirements === 'function') {
return config.planRetirements(input, adapter);
}
return [];
return planGenericRetirements(input, adapter);
},
supportsModule(module, input = {}) {
if (typeof config.supportsModule === 'function') {
Expand Down Expand Up @@ -479,5 +592,6 @@ module.exports = {
normalizeModulesInput,
normalizeRelativePath,
planFlatSkillOperation,
planGenericRetirements,
resolveModulesPlan,
};
4 changes: 4 additions & 0 deletions scripts/lib/install-targets/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ function planInstallTargetScaffold(options = {}) {
...planningInput,
modules,
});
// The generic default reuses these instead of planning a second time;
// an adapter with its own planRetirements is free to ignore the extra
// field.
const retirements = adapter.planRetirements({
...planningInput,
modules,
operations,
});

return {
Expand Down
Loading
Loading