Skip to content

fix(skills): symlink node_modules in Codex mirror instead of deep-copying#2733

Open
VXNCXNX wants to merge 2 commits into
PostHog:mainfrom
VXNCXNX:posthog-code/codex-mirror-skip-node-modules
Open

fix(skills): symlink node_modules in Codex mirror instead of deep-copying#2733
VXNCXNX wants to merge 2 commits into
PostHog:mainfrom
VXNCXNX:posthog-code/codex-mirror-skip-node-modules

Conversation

@VXNCXNX

@VXNCXNX VXNCXNX commented Jun 17, 2026

Copy link
Copy Markdown

Summary

On every app launch (and again every 30 minutes, and after any skill create/edit/install), PosthogPluginService re-mirrors all user skills into ~/.agents/skills. The copy is fs.cp(..., { recursive: true, dereference: true }) with no filter, so each skill's node_modules is re-duplicated from scratch every single time the app starts, not copied once.

For a dependency-heavy skill (~1.1 GB with ~700 MB node_modules) that means a ~1.4 GB copy under ~/.agents/skills (most of it duplicated deps), rewritten on every startup. The recurring disk churn and startup I/O is the real cost here, and there's currently no way to opt out of the mirror.

Changes

  • New copySkillDirLinkingNodeModules(): skips the skill's top-level node_modules during the copy (via an fs.cp filter), then symlinks it to the source. Dependencies still resolve through the symlink, so mirrored skills stay runnable, with no duplication, no transient disk peak, and no per-startup I/O churn. Used by both mirrorUserSkillsToCodex and syncCodexSkills.
  • New POSTHOG_DISABLE_CODEX_MIRROR=1 env opt-out for users who don't use Codex; gates the startup calls (with a log line) plus early-returns in both copy paths.

Tests

packages/workspace-server vitest, all green:

  • node_modules becomes a symlink and a dependency resolves through it
  • a skill without node_modules is mirrored unchanged
  • the opt-out no-ops both copy paths (mirrorUserSkillsToCodex + syncCodexSkills)

pnpm --filter @posthog/workspace-server test passes; tsc --noEmit clean; biome clean.

Notes

Playwright browser binaries live in the shared ~/Library/Caches/ms-playwright, outside node_modules, so they are unaffected.

Possible follow-up (happy to do if wanted): surface the opt-out as a Settings toggle backed by IWorkspaceSettings (a SettingRow/Switch, mirroring the existing "Keep awake while agents work" pattern). Left out here to keep this focused, since the placement and default feel like a product call for the team.


Created with PostHog Code

…ying

The Codex skills mirror deep-copied each skill into ~/.agents/skills with
fs.cp({ recursive: true, dereference: true }) and no filter, so a skill's
node_modules was duplicated on every startup, every 30-min tick, and after
any skill change. A dependency-heavy skill (~1.1 GB, ~700 MB node_modules)
became a ~1.4 GB copy, most of it duplicated deps.

- Add copySkillDirLinkingNodeModules(): skip the skill's top-level
  node_modules during the copy (fs.cp filter) and symlink it to the source
  instead. Deps still resolve through the symlink, so mirrored skills stay
  runnable, with no duplication, no transient disk peak, and no per-startup
  I/O churn. Used by mirrorUserSkillsToCodex and syncCodexSkills.
- Add POSTHOG_DISABLE_CODEX_MIRROR=1 opt-out for users who don't use Codex;
  gates the startup calls and early-returns both copy paths.

Tests: node_modules becomes a symlink and a dep resolves through it; a skill
without node_modules is mirrored unchanged; the opt-out no-ops both paths.

Generated-By: PostHog Code
Task-Id: 212331c1-dba9-4b85-b5a3-be8416d292d6
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/workspace-server/src/services/posthog-plugin/codex-mirror.ts:49-55
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");
  }
```

### Issue 2 of 2
packages/workspace-server/src/services/posthog-plugin/codex-mirror.test.ts:140-155
**Env-var save/restore repeated across test files**

The identical `prev = process.env[key]` / `try { set } finally { restore }` block appears here and again in `update-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.

Reviews (1): Last reviewed commit: "fix(skills): symlink node_modules in Cod..." | Re-trigger Greptile

Comment on lines +49 to +55
if (fs.existsSync(srcNodeModules)) {
const destNodeModules = path.join(dest, "node_modules");
await fs.promises.rm(destNodeModules, { recursive: true, force: true });
// "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");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
if (fs.existsSync(srcNodeModules)) {
const destNodeModules = path.join(dest, "node_modules");
await fs.promises.rm(destNodeModules, { recursive: true, force: true });
// "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");
}
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");
}
Prompt To Fix With AI
This 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!

Comment on lines +140 to +155
it("does nothing when POSTHOG_DISABLE_CODEX_MIRROR=1", async () => {
await createSkill(userDir, "alpha");
const prev = process.env.POSTHOG_DISABLE_CODEX_MIRROR;
process.env.POSTHOG_DISABLE_CODEX_MIRROR = "1";
try {
await mirrorUserSkillsToCodex(userDir, codexDir);
} finally {
if (prev === undefined) {
delete process.env.POSTHOG_DISABLE_CODEX_MIRROR;
} else {
process.env.POSTHOG_DISABLE_CODEX_MIRROR = prev;
}
}

expect(existsSync(path.join(codexDir, "alpha"))).toBe(false);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Env-var save/restore repeated across test files

The identical prev = process.env[key] / try { set } finally { restore } block appears here and again in update-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
This is a comment left during a code review.
Path: packages/workspace-server/src/services/posthog-plugin/codex-mirror.test.ts
Line: 140-155

Comment:
**Env-var save/restore repeated across test files**

The identical `prev = process.env[key]` / `try { set } finally { restore }` block appears here and again in `update-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.

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!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 30c01f1c49

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

await fs.promises.rm(destNodeModules, { recursive: true, force: true });
// "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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back when node_modules symlinks cannot be created

When the target home directory is on a filesystem or policy that rejects symlinks/junctions, this throws after the skill has already been copied without node_modules. syncCodexSkills then swallows the error and leaves a mirrored skill missing its dependencies, while mirrorUserSkillsToCodex deletes/drops the user-skill mirror; before this change the deep copy path still worked in that environment. Please fall back to copying node_modules or clean up the partial destination when symlink creation fails.

Useful? React with 👍 / 👎.

- Fall back to copying node_modules when symlink/junction creation is
  rejected (restricted filesystem or sandbox policy), so a mirrored skill
  never ends up without its dependencies. Preserves the pre-change deep-copy
  behavior in those environments.
- Extract the env save/restore boilerplate in the opt-out tests into a shared
  withEnvVar() helper (test-helpers.ts), used by both test files.
- Drop the dead rm before the happy-path symlink (the fs.cp filter never
  writes node_modules and callers remove dest first).

Generated-By: PostHog Code
Task-Id: 212331c1-dba9-4b85-b5a3-be8416d292d6
@VXNCXNX

VXNCXNX commented Jun 17, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Addressed all three points in 6c3717ed:

  • Symlink fallback (P2): when symlink/junction creation is rejected (restricted filesystem or sandbox policy), it now falls back to a real node_modules copy, so a mirrored skill never ends up without its dependencies. This preserves the pre-change deep-copy behavior in those environments. Added a test that forces symlink to throw and asserts the deps are copied.
  • Redundant rm (P2): removed from the happy path (the fs.cp filter never writes node_modules and callers remove dest first). The rm now only runs in the fallback branch before the recovery copy.
  • Duplicated env save/restore (P2): extracted into a shared withEnvVar() helper in test-helpers.ts, used by both opt-out tests.

pnpm --filter @posthog/workspace-server test is green (12 tests), tsc --noEmit clean, biome clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant