fix(daemon): keep generated core Codex plugin files out of Git (#919)

Generated-file hygiene recognises the namespaced .codex/plugins/shared:openrig-core folder, so OpenRig's generated Codex plugin folder no longer shows as untracked.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-06 23:18:35 -07:00
1 parent 4472577a11
commit cd79a6bd05
3 files changed
+33 -16

No files matched your search

+1 -1
View File
@@ -105,7 +105,7 @@ using a published package, since repository guidance can be ahead of npm.
| **npm installation** | Installs the CLI (`rig` and `openrig-tui`), bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`; cmux defaults `automation.socketControlMode` to `cmuxOnly`, which only accepts processes started inside cmux and so blocks OpenRig's control, so setup switches it to `automation`; while doing so it may start the cmux app and reload its configuration. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. With `runtime.codex.hooks_enabled` enabled (the default), writes Codex hook configuration and trust records as described below—even before a rig launches. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. In a Git repository, newly created files under `.codex/plugins/openrig-core/` are added to the repository's Git `info/exclude` inside an `# BEGIN OpenRig generated files` block; new `AGENTS.md`, `CLAUDE.md` and `CLAUDE.local.md` files stay visible with a warning. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. In a Git repository, newly created files under `.codex/plugins/shared:openrig-core/` (or the unqualified `.codex/plugins/openrig-core/`) are added to the repository's Git `info/exclude` inside an `# BEGIN OpenRig generated files` block; new `AGENTS.md`, `CLAUDE.md` and `CLAUDE.local.md` files stay visible with a warning. |
| **Bundle install** (`rig bundle install`, or `rig up` with a `.rigbundle` or GitHub link) | Writes the bundle's files into the install target: `--target`, or the current directory for `rig up` and for a GitHub link. `rig bundle install` with a local archive needs `--target`. Routes its declared skills, plugins and context packs into your libraries. A GitHub link's archive is kept under `OPENRIG_HOME/bundle-imports/`, and installs are recorded in `OPENRIG_HOME/bundle-audit.jsonl`. A bundle that carries a project creates it under `workspace.projects_root` and records it, with the rig's association, in the workspace catalog. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules to your settings files; a team seat with no permission policy, per-seat choice or (for Codex) named profile gets the per-launch [team default](docs/reference/rig-spec.md#team-launch-defaults) instead. Agent-guided setup recommends keeping it and requires your actual answer before the agent [adds remembered rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer keeps the team default and preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
@@ -9,6 +9,8 @@ const within = (root: string, file: string): boolean => {
const patternFor = (relative: string) => `/${relative.split(path.sep).join("/").replace(/([\\*?\[\] !#])/g, "\\$1")}`;
// Git's ignore-case comparison folds ASCII bytes, not Unicode filenames.
const foldCase = (value: string) => value.replace(/[A-Z]/g, c => c.toLowerCase());
// The bundled plugin is imported as shared:openrig-core; keep the unqualified projection too.
const corePluginDirectories = [".codex/plugins/openrig-core", ".codex/plugins/shared:openrig-core"];
/** Only pass files this projection created. Existing names/bytes are not ownership. */
export function excludeNewGeneratedFiles(cwd: string, createdFiles: string[]): string[] {
@@ -48,15 +50,15 @@ export function excludeNewGeneratedFiles(cwd: string, createdFiles: string[]): s
if (!exclude || !common) throw new Error("Git did not return exclusion and common metadata paths");
const metadataIdentity = (file: string) => fs.existsSync(file) ? fs.realpathSync(file)
: path.join(fs.realpathSync(path.dirname(file)), path.basename(file));
const coreDirectory = path.join(path.relative(root, fs.realpathSync(cwd)), ".codex/plugins/openrig-core");
const coreDirectories = corePluginDirectories.map(directory => path.join(path.relative(root, fs.realpathSync(cwd)), directory));
const ignoresCase = (dir: string) => git(dir, ["config", "--type=bool", "--get", "core.ignorecase"], undefined, true).trim() === "true";
// One bounded listing per worktree, including ignored files only at relevant paths.
const inventory = (dir: string, ignoreCase: boolean, paths = [coreDirectory]) => git(dir, ["ls-files", "--cached", "--others", "-t", "-z", "--",
const inventory = (dir: string, ignoreCase: boolean, paths = coreDirectories) => git(dir, ["ls-files", "--cached", "--others", "-t", "-z", "--",
...[...new Set(paths)].map(file => `:(literal${ignoreCase ? ",icase" : ""})${file.split(path.sep).join("/")}`)])
.split("\0").filter(Boolean).map(record => ({ tracked: record[0] !== "?", name: record.slice(2) }));
const rootIgnoreCase = ignoresCase(root);
// Guidance also needs an exact tracked-file check, but never a directory-wide scan.
const own = inventory(root, rootIgnoreCase, [coreDirectory, ...files.map(file => path.relative(root, fs.realpathSync(file)))]);
const own = inventory(root, rootIgnoreCase, [...coreDirectories, ...files.map(file => path.relative(root, fs.realpathSync(file)))]);
const ignored = new Set(git(root, ["check-ignore", "--stdin", "-z"], files.join("\0") + "\0", true).split("\0"));
const candidates: Array<{ file: string; relative: string }> = [];
for (const file of files) {
@@ -66,7 +68,8 @@ export function excludeNewGeneratedFiles(cwd: string, createdFiles: string[]): s
const relative = path.relative(root, canonical);
if (/[\r\n]/.test(relative)) { warn(file, "Git exclude cannot represent this filename on one line"); continue; }
if (own.some(item => item.tracked && item.name === relative) || ignored.has(file)) continue;
if (!path.relative(path.resolve(cwd), canonical).split(path.sep).join("/").startsWith(".codex/plugins/openrig-core/")) {
const fromCwd = path.relative(path.resolve(cwd), canonical).split(path.sep).join("/");
if (!corePluginDirectories.some(directory => fromCwd.startsWith(`${directory}/`))) {
const message = `${file} was created by OpenRig and is untracked. If you do not want to commit it, add this line to ${exclude}: ${patternFor(relative)} (shared by linked worktrees).`;
warnings.push(message);
console.warn(`[openrig] ${message}`);
@@ -63,21 +63,32 @@ describe("generated file Git hygiene", () => {
for (const file of ["AGENTS.md", "CLAUDE.md", "CLAUDE.local.md"]) expect(fs.readFileSync(path.join(repo, file), "utf8")).toContain("Managed guidance");
});
it("keeps only newly generated plugin files out of git add", async () => {
it.each(["openrig-core", "shared:openrig-core"])("keeps only newly generated %s plugin files out of git add", async effectiveId => {
write("source/.codex-plugin/plugin.json", '{"name":"core"}', root);
write("source/scripts/a [literal]*?.sh", "echo core", root);
const userPlugin = write(".codex/plugins/openrig-core/user.txt");
const pluginDir = `.codex/plugins/${effectiveId}`;
const userPlugin = write(`${pluginDir}/user.txt`);
const ignore = write(".gitignore", "# User rules\n/local-only/\n");
const ignoreBefore = fs.readFileSync(ignore);
const excludeBefore = Buffer.from("# User excludes\r\n/private-notes\r\n# no final newline");
fs.writeFileSync(excludePath(repo), excludeBefore);
git(repo, "add", ".gitignore");
git(repo, "-c", "user.name=Test", "-c", "[email protected]", "commit", "-qm", "user ignore rules");
write(".codex/user-file"); write(".codex/plugins/unselected/plugin.json");
const selected = entry("plugin", path.join(root, "source"));
await project([selected]);
const selected = { ...entry("plugin", path.join(root, "source")), effectiveId };
expect((await project([selected])).warnings ?? []).toEqual([]);
const first = fs.readFileSync(excludePath(repo));
expect(first.subarray(0, excludeBefore.length)).toEqual(excludeBefore);
expect(fs.readFileSync(ignore)).toEqual(ignoreBefore);
expect(git(repo, "check-ignore", "--", `${pluginDir}/.codex-plugin/plugin.json`).trim()).toBe(`${pluginDir}/.codex-plugin/plugin.json`);
expect(git(repo, "status", "--porcelain", "-uall")).not.toContain(`${pluginDir}/.codex-plugin/plugin.json`);
await project([selected]);
expect(fs.readFileSync(excludePath(repo))).toEqual(first);
expect(fs.readFileSync(userPlugin, "utf8")).toBe("User file\n");
git(repo, "add", ".");
expect(git(repo, "diff", "--cached", "--name-only").trim().split("\n").sort()).toEqual([
".codex/plugins/openrig-core/user.txt", ".codex/plugins/unselected/plugin.json", ".codex/user-file",
]);
`${pluginDir}/user.txt`, ".codex/plugins/unselected/plugin.json", ".codex/user-file",
].sort());
});
it.each([false, true])("preserves pre-existing guidance and plugin visibility (tracked=%s)", async tracked => {
@@ -126,10 +137,13 @@ describe("generated file Git hygiene", () => {
expect(git(linked, "ls-files", "-z")).toBe("agents.md\0");
});
it.each([false, true])("preserves current sibling plugin conflicts with Git case matching (ignorecase=%s)", ignoreCase => {
it.each([
["openrig-core", false], ["openrig-core", true],
["shared:openrig-core", false], ["shared:openrig-core", true],
] as const)("preserves current sibling %s plugin conflicts with Git case matching (ignorecase=%s)", (effectiveId, ignoreCase) => {
git(repo, "config", "core.ignorecase", String(ignoreCase));
const linked = path.join(root, "linked"); git(repo, "worktree", "add", "-q", "-b", "linked", linked);
const name = ".codex/plugins/openrig-core/payload.txt";
const name = `.codex/plugins/${effectiveId}/payload.txt`;
write(name, "User", linked);
const generated = write(ignoreCase ? name.replace("payload", "PAYLOAD") : name);
const warnings = excludeNewGeneratedFiles(repo, [generated]);
@@ -146,13 +160,13 @@ describe("generated file Git hygiene", () => {
expect(git(linked, "check-ignore", "--", file).trim()).toBe(file);
});
it("does not automatically exclude another plugin namespace", async () => {
it.each(["other", "other:openrig-core", "shared:openrig-core-extra"])("does not automatically exclude plugin namespace %s", async effectiveId => {
write("source/payload.txt", "Managed", root);
const other = { ...entry("plugin", path.join(root, "source")), effectiveId: "other" };
const other = { ...entry("plugin", path.join(root, "source")), effectiveId };
const result = await project([other]);
expect(result.warnings?.join(" ")).toContain("untracked");
git(repo, "add", "-A");
expect(git(repo, "ls-files", "-z")).toBe(".codex/plugins/other/payload.txt\0");
expect(git(repo, "ls-files", "-z")).toBe(`.codex/plugins/${effectiveId}/payload.txt\0`);
});
it("leaves a recreated tracked path visible and supports non-Git workspaces", () => {