-
Notifications
You must be signed in to change notification settings - Fork 43
fix(skills): symlink node_modules in Codex mirror instead of deep-copying #2733
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,6 +15,58 @@ export function getCodexSkillsDir(): string { | |||||||||||||||||||||||||||
| return path.join(os.homedir(), ".agents", "skills"); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Opt-out for the Codex skills mirror. Set `POSTHOG_DISABLE_CODEX_MIRROR=1` | ||||||||||||||||||||||||||||
| * when you don't use Codex (or another tool that reads ~/.agents/skills) and | ||||||||||||||||||||||||||||
| * don't want PostHog Code writing there on startup. Read at call time so it | ||||||||||||||||||||||||||||
| * can be toggled without a rebuild. | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| export function isCodexMirrorDisabled(): boolean { | ||||||||||||||||||||||||||||
| return process.env.POSTHOG_DISABLE_CODEX_MIRROR === "1"; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Copies a skill directory into Codex's skills dir, but instead of deep-copying | ||||||||||||||||||||||||||||
| * its top-level `node_modules` (which can be hundreds of MB of deps, e.g. ML | ||||||||||||||||||||||||||||
| * runtimes) it symlinks `node_modules` to the source. Node resolves | ||||||||||||||||||||||||||||
| * dependencies through the symlink, so the mirrored skill stays runnable, while | ||||||||||||||||||||||||||||
| * we avoid duplicating the heavy tree on every mirror (no transient disk peak, | ||||||||||||||||||||||||||||
| * no per-startup I/O churn). The caller is responsible for removing `dest` | ||||||||||||||||||||||||||||
| * first when overwriting. | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| export async function copySkillDirLinkingNodeModules( | ||||||||||||||||||||||||||||
| src: string, | ||||||||||||||||||||||||||||
| dest: string, | ||||||||||||||||||||||||||||
| options: { dereference?: boolean } = {}, | ||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||
| const srcNodeModules = path.join(src, "node_modules"); | ||||||||||||||||||||||||||||
| await fs.promises.cp(src, dest, { | ||||||||||||||||||||||||||||
| recursive: true, | ||||||||||||||||||||||||||||
| dereference: options.dereference ?? false, | ||||||||||||||||||||||||||||
| // Skip only the skill's top-level node_modules; everything else copies. | ||||||||||||||||||||||||||||
| filter: (source) => source !== srcNodeModules, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| if (fs.existsSync(srcNodeModules)) { | ||||||||||||||||||||||||||||
| const destNodeModules = path.join(dest, "node_modules"); | ||||||||||||||||||||||||||||
| // The cp filter never writes node_modules and callers remove `dest` | ||||||||||||||||||||||||||||
| // first, so the target is absent here. | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| // "junction" so this also works on Windows without elevated permissions; | ||||||||||||||||||||||||||||
| // the type arg is ignored on POSIX. Target is absolute, as junctions require. | ||||||||||||||||||||||||||||
| await fs.promises.symlink(srcNodeModules, destNodeModules, "junction"); | ||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||
| // Some filesystems / sandbox policies reject symlinks. Fall back to a | ||||||||||||||||||||||||||||
| // real copy so the mirrored skill keeps its dependencies (the pre-change | ||||||||||||||||||||||||||||
| // behavior in those environments) rather than ending up dep-less. | ||||||||||||||||||||||||||||
| await fs.promises.rm(destNodeModules, { recursive: true, force: true }); | ||||||||||||||||||||||||||||
| await fs.promises.cp(srcNodeModules, destNodeModules, { | ||||||||||||||||||||||||||||
| recursive: true, | ||||||||||||||||||||||||||||
| dereference: true, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/workspace-server/src/services/posthog-plugin/codex-mirror.ts
Line: 49-55
Comment:
Redundant `rm` before symlink creation — the `filter` passed to `fs.cp` already ensures `destNodeModules` is never written, and all callers remove `dest` before invoking this function (the stated contract). The extra `rm` is always a no-op and adds noise without adding safety.
```suggestion
if (fs.existsSync(srcNodeModules)) {
const destNodeModules = path.join(dest, "node_modules");
// "junction" so this also works on Windows without elevated permissions;
// the type arg is ignored on POSIX. Target is absolute, as junctions require.
await fs.promises.symlink(srcNodeModules, destNodeModules, "junction");
}
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export async function readCodexMirrorState( | ||||||||||||||||||||||||||||
| codexDir: string, | ||||||||||||||||||||||||||||
| ): Promise<CodexMirrorState> { | ||||||||||||||||||||||||||||
|
|
@@ -74,6 +126,9 @@ export async function mirrorUserSkillsToCodex( | |||||||||||||||||||||||||||
| userSkillsDir: string, | ||||||||||||||||||||||||||||
| codexDir: string, | ||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||
| if (isCodexMirrorDisabled()) { | ||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const state = await readCodexMirrorState(codexDir); | ||||||||||||||||||||||||||||
| const previouslyMirrored = new Set(state.mirrored); | ||||||||||||||||||||||||||||
| const userNames = await findSkillDirs(userSkillsDir); | ||||||||||||||||||||||||||||
|
|
@@ -87,11 +142,13 @@ export async function mirrorUserSkillsToCodex( | |||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| await fs.promises.rm(target, { recursive: true, force: true }); | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| // dereference: mirrored skills must be self-contained. | ||||||||||||||||||||||||||||
| await fs.promises.cp(path.join(userSkillsDir, name), target, { | ||||||||||||||||||||||||||||
| recursive: true, | ||||||||||||||||||||||||||||
| dereference: true, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| // dereference: mirrored skills must be self-contained — except | ||||||||||||||||||||||||||||
| // node_modules, which is symlinked rather than duplicated. | ||||||||||||||||||||||||||||
| await copySkillDirLinkingNodeModules( | ||||||||||||||||||||||||||||
| path.join(userSkillsDir, name), | ||||||||||||||||||||||||||||
| target, | ||||||||||||||||||||||||||||
| { dereference: true }, | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| return name; | ||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||
| // Skip unreadable skills (e.g. broken symlinks); drop the partial copy. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| /** | ||
| * Runs `fn` with `process.env[key]` set to `value`, restoring the previous | ||
| * value (or unsetting it) afterwards. Keeps env-dependent tests free of | ||
| * repeated save/restore boilerplate. | ||
| */ | ||
| export async function withEnvVar( | ||
| key: string, | ||
| value: string, | ||
| fn: () => Promise<void>, | ||
| ): Promise<void> { | ||
| const prev = process.env[key]; | ||
| process.env[key] = value; | ||
| try { | ||
| await fn(); | ||
| } finally { | ||
| if (prev === undefined) { | ||
| delete process.env[key]; | ||
| } else { | ||
| process.env[key] = prev; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { existsSync, lstatSync } from "node:fs"; | ||
| import { | ||
| mkdir, | ||
| mkdtemp, | ||
| readFile, | ||
| realpath, | ||
| rm, | ||
| writeFile, | ||
| } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import path from "node:path"; | ||
| import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
| import { withEnvVar } from "./test-helpers"; | ||
| import { syncCodexSkills } from "./update-skills-saga"; | ||
|
|
||
| let root: string; | ||
| let pluginPath: string; | ||
| let codexDir: string; | ||
|
|
||
| async function createBundledSkill(name: string, body = `# ${name}`) { | ||
| const dir = path.join(pluginPath, "skills", name); | ||
| await mkdir(dir, { recursive: true }); | ||
| await writeFile(path.join(dir, "SKILL.md"), body); | ||
| return dir; | ||
| } | ||
|
|
||
| beforeEach(async () => { | ||
| root = await mkdtemp(path.join(tmpdir(), "sync-codex-test-")); | ||
| pluginPath = path.join(root, "plugin"); | ||
| codexDir = path.join(root, "codex-skills"); | ||
| await mkdir(pluginPath, { recursive: true }); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await rm(root, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe("syncCodexSkills", () => { | ||
| it("copies bundled skills and symlinks node_modules", async () => { | ||
| const skillDir = await createBundledSkill("alpha"); | ||
| await mkdir(path.join(skillDir, "node_modules", "dep"), { | ||
| recursive: true, | ||
| }); | ||
| await writeFile( | ||
| path.join(skillDir, "node_modules", "dep", "index.js"), | ||
| "module.exports = 7;", | ||
| ); | ||
|
|
||
| await syncCodexSkills(pluginPath, codexDir); | ||
|
|
||
| expect( | ||
| await readFile(path.join(codexDir, "alpha", "SKILL.md"), "utf-8"), | ||
| ).toBe("# alpha"); | ||
| const mirroredNodeModules = path.join(codexDir, "alpha", "node_modules"); | ||
| expect(lstatSync(mirroredNodeModules).isSymbolicLink()).toBe(true); | ||
| expect(await realpath(mirroredNodeModules)).toBe( | ||
| await realpath(path.join(skillDir, "node_modules")), | ||
| ); | ||
| }); | ||
|
|
||
| it("does nothing when POSTHOG_DISABLE_CODEX_MIRROR=1", async () => { | ||
| await createBundledSkill("alpha"); | ||
| await withEnvVar("POSTHOG_DISABLE_CODEX_MIRROR", "1", async () => { | ||
| await syncCodexSkills(pluginPath, codexDir); | ||
| }); | ||
|
|
||
| expect(existsSync(path.join(codexDir, "alpha"))).toBe(false); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The identical
prev = process.env[key]/try { set } finally { restore }block appears here and again inupdate-skills-saga.test.ts(lines 55–71). Per the "OnceAndOnlyOnce" simplicity rule, a small shared helper — e.g.withEnvVar(key, value, fn)— would keep the restoration logic in one place and make both opt-out tests shorter.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!