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
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import * as nodeFs from "node:fs";
import { existsSync, lstatSync } from "node:fs";
import {
lstat,
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
addMirroredName,
mirrorUserSkillsToCodex,
readCodexMirrorState,
} from "./codex-mirror";
import { withEnvVar } from "./test-helpers";

let root: string;
let userDir: string;
Expand Down Expand Up @@ -81,6 +91,95 @@ describe("mirrorUserSkillsToCodex", () => {
expect((await readCodexMirrorState(codexDir)).mirrored).toEqual([]);
});

it("symlinks node_modules instead of duplicating it, deps still resolve", async () => {
await createSkill(userDir, "alpha");
await mkdir(path.join(userDir, "alpha", "node_modules", "dep"), {
recursive: true,
});
await writeFile(
path.join(userDir, "alpha", "node_modules", "dep", "index.js"),
"module.exports = 42;",
);

await mirrorUserSkillsToCodex(userDir, codexDir);

const mirroredNodeModules = path.join(codexDir, "alpha", "node_modules");
// It's a symlink, not a real copied directory.
expect(lstatSync(mirroredNodeModules).isSymbolicLink()).toBe(true);
// It points at the source skill's node_modules.
expect(await realpath(mirroredNodeModules)).toBe(
await realpath(path.join(userDir, "alpha", "node_modules")),
);
// A dependency file resolves through the symlink.
expect(
await readFile(
path.join(mirroredNodeModules, "dep", "index.js"),
"utf-8",
),
).toBe("module.exports = 42;");
// Non-node_modules content is still really copied (not a symlink).
expect(lstatSync(path.join(codexDir, "alpha", "SKILL.md")).isFile()).toBe(
true,
);
});

it("falls back to copying node_modules when symlink creation is rejected", async () => {
await createSkill(userDir, "alpha");
await mkdir(path.join(userDir, "alpha", "node_modules", "dep"), {
recursive: true,
});
await writeFile(
path.join(userDir, "alpha", "node_modules", "dep", "index.js"),
"module.exports = 1;",
);

// Simulate a filesystem/policy that rejects symlinks.
const spy = vi
.spyOn(nodeFs.promises, "symlink")
.mockRejectedValueOnce(new Error("EPERM: symlinks not permitted"));
try {
await mirrorUserSkillsToCodex(userDir, codexDir);
} finally {
spy.mockRestore();
}

const mirroredNodeModules = path.join(codexDir, "alpha", "node_modules");
// Fell back to a real copy, not a symlink, so deps are still present.
expect(lstatSync(mirroredNodeModules).isSymbolicLink()).toBe(false);
expect(lstatSync(mirroredNodeModules).isDirectory()).toBe(true);
expect(
await readFile(
path.join(mirroredNodeModules, "dep", "index.js"),
"utf-8",
),
).toBe("module.exports = 1;");
});

it("mirrors a skill without node_modules unchanged", async () => {
await createSkill(userDir, "alpha");

await mirrorUserSkillsToCodex(userDir, codexDir);

expect(existsSync(path.join(codexDir, "alpha", "node_modules"))).toBe(
false,
);
expect(
await readFile(path.join(codexDir, "alpha", "SKILL.md"), "utf-8"),
).toBe("# alpha");
expect((await lstat(path.join(codexDir, "alpha"))).isDirectory()).toBe(
true,
);
});

it("does nothing when POSTHOG_DISABLE_CODEX_MIRROR=1", async () => {
await createSkill(userDir, "alpha");
await withEnvVar("POSTHOG_DISABLE_CODEX_MIRROR", "1", async () => {
await mirrorUserSkillsToCodex(userDir, codexDir);
});

expect(existsSync(path.join(codexDir, "alpha"))).toBe(false);
});
Comment on lines +174 to +181

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!


it("takes ownership of an imported skill via addMirroredName", async () => {
// Codex-authored skill, imported to user skills (import records the name).
await mkdir(codexDir, { recursive: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

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!

}

export async function readCodexMirrorState(
codexDir: string,
): Promise<CodexMirrorState> {
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import {
import { TypedEventEmitter } from "@posthog/shared";
import { inject, injectable, postConstruct, preDestroy } from "inversify";
import { getUserSkillsDir } from "../skills/skill-discovery";
import { getCodexSkillsDir, mirrorUserSkillsToCodex } from "./codex-mirror";
import {
getCodexSkillsDir,
isCodexMirrorDisabled,
mirrorUserSkillsToCodex,
} from "./codex-mirror";
import {
overlayDownloadedSkills,
syncCodexSkills,
Expand Down Expand Up @@ -99,8 +103,14 @@ export class PosthogPluginService extends TypedEventEmitter<PosthogPluginEvents>
// Overlay any previously-downloaded skills on top of the runtime plugin
await overlayDownloadedSkills(this.runtimeSkillsDir, this.runtimePluginDir);

await syncCodexSkills(this.getPluginPath(), CODEX_SKILLS_DIR);
await this.mirrorUserSkills();
if (isCodexMirrorDisabled()) {
this.log.info(
"Codex skills mirror disabled (POSTHOG_DISABLE_CODEX_MIRROR=1); not writing ~/.agents/skills",
);
} else {
await syncCodexSkills(this.getPluginPath(), CODEX_SKILLS_DIR);
await this.mirrorUserSkills();
}

// Start periodic updates
this.intervalId = setInterval(() => {
Expand Down
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { Saga } from "@posthog/shared";
import {
copySkillDirLinkingNodeModules,
isCodexMirrorDisabled,
} from "./codex-mirror";
import { extractZip, unzipAsync } from "./extract-zip";

/**
Expand Down Expand Up @@ -45,6 +49,9 @@ export async function syncCodexSkills(
pluginPath: string,
codexSkillsDir: string,
): Promise<void> {
if (isCodexMirrorDisabled()) {
return;
}
const effectiveSkillsDir = join(pluginPath, "skills");
if (!existsSync(effectiveSkillsDir)) {
return;
Expand All @@ -59,7 +66,8 @@ export async function syncCodexSkills(
const src = join(effectiveSkillsDir, entry.name);
const dest = join(codexSkillsDir, entry.name);
await rm(dest, { recursive: true, force: true });
await cp(src, dest, { recursive: true });
// Symlink node_modules instead of deep-copying it (see codex-mirror).
await copySkillDirLinkingNodeModules(src, dest);
}
}
} catch {
Expand Down