Skip to content
Closed
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
"build:app": "vite build",
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"test:node-pty-permissions": "node --import tsx --test src/node-pty-postinstall.test.ts",
"start": "node dist/cli.js serve",
"test": "tsx src/git-worktrees.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts",
"test": "npm run test:node-pty-permissions && tsx src/git-worktrees.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
40 changes: 28 additions & 12 deletions scripts/fix-node-pty-permissions.mjs
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
import { chmod } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";

if (process.platform === "darwin") {
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export function resolveNodePtyPackage(hookUrl = import.meta.url) {
const require = createRequire(hookUrl);
let packageJson;
try {
packageJson = require.resolve("node-pty/package.json");
} catch (error) {
if (
error?.code === "MODULE_NOT_FOUND" &&
String(error?.message).split("\n", 1)[0] === "Cannot find module 'node-pty/package.json'"
)
return undefined;
throw error;
}
return dirname(packageJson);
}

export async function fixNodePtyPermissions({
platform = process.platform,
hookUrl = import.meta.url,
} = {}) {
if (platform !== "darwin") return;
const packageRoot = resolveNodePtyPackage(hookUrl);
if (!packageRoot) return;
for (const architecture of ["arm64", "x64"]) {
const helper = resolve(
projectRoot,
"node_modules",
"node-pty",
"prebuilds",
`darwin-${architecture}`,
"spawn-helper",
);
const helper = join(packageRoot, "prebuilds", `darwin-${architecture}`, "spawn-helper");
try {
await chmod(helper, 0o755);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
}

await fixNodePtyPermissions();
115 changes: 115 additions & 0 deletions src/node-pty-postinstall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import test from "node:test";

const execFileAsync = promisify(execFile);
const hookSource = fileURLToPath(new URL("../scripts/fix-node-pty-permissions.mjs", import.meta.url));
const helperModes = ["darwin-arm64", "darwin-x64"];
const hookText = await readFile(hookSource, "utf8");

async function fixture(layout: "nested" | "hoisted") {
const root = await mkdtemp(join(tmpdir(), "devspace-pty-") );
const app = layout === "nested" ? root : join(root, "app");
const hook = join(app, "scripts", "fix-node-pty-permissions.mjs");
const pty = join(layout === "nested" ? app : root, "node_modules/node-pty");
await mkdir(join(app, "scripts"), { recursive: true });
await mkdir(pty, { recursive: true });
await writeFile(join(app, "package.json"), '{"type":"module"}\n');
await writeFile(join(pty, "package.json"), '{"name":"node-pty","version":"fixture","main":"index.js"}\n');
await writeFile(join(pty, "index.js"), "export {};\n");
for (const mode of helperModes) {
await mkdir(join(pty, "prebuilds", mode), { recursive: true });
await writeFile(join(pty, "prebuilds", mode, "spawn-helper"), "sentinel");
await chmod(join(pty, "prebuilds", mode, "spawn-helper"), 0o644);
}
await writeFile(hook, hookText);
return { root, app, hook, pty };
}

async function runHook(hook: string) {
return execFileAsync(process.execPath, [hook], { encoding: "utf8" });
}

test("repairs nested node-pty helpers and preserves sentinel bytes", { skip: process.platform !== "darwin" }, async () => {
const f = await fixture("nested");
try {
await runHook(f.hook);
for (const mode of helperModes) {
const helper = join(f.pty, "prebuilds", mode, "spawn-helper");
assert.equal((await (await import("node:fs/promises")).stat(helper)).mode & 0o777, 0o755);
assert.equal(await readFile(helper, "utf8"), "sentinel");
}
} finally { await rm(f.root, { recursive: true, force: true }); }
});

test("repairs hoisted node-pty helpers through Node resolution", { skip: process.platform !== "darwin" }, async () => {
const f = await fixture("hoisted");
try {
await runHook(f.hook);
for (const mode of helperModes) {
const helper = join(f.pty, "prebuilds", mode, "spawn-helper");
assert.equal((await (await import("node:fs/promises")).stat(helper)).mode & 0o777, 0o755);
assert.equal(await readFile(helper, "utf8"), "sentinel");
}
} finally { await rm(f.root, { recursive: true, force: true }); }
});

test("missing optional node-pty is a no-op and non-darwin is a no-op", async () => {
const f = await fixture("nested");
try {
await rm(join(f.app, "node_modules"), { recursive: true, force: true });
const mod = await import(`${pathToFileURL(f.hook).href}?linux=${Date.now()}`);
await mod.fixNodePtyPermissions({ platform: "linux", hookUrl: pathToFileURL(f.hook).href });
await mod.fixNodePtyPermissions({ platform: "darwin", hookUrl: pathToFileURL(f.hook).href });
} finally { await rm(f.root, { recursive: true, force: true }); }
});

test("non-ENOENT resolver errors propagate", { skip: process.platform !== "darwin" }, async () => {
const f = await fixture("nested");
try {
await writeFile(join(f.pty, "package.json"), "{broken\n");
await assert.rejects(runHook(f.hook), /Invalid package config|Unexpected token/);
} finally { await rm(f.root, { recursive: true, force: true }); }
});

test("other MODULE_NOT_FOUND resolver failures propagate unchanged", async () => {
const f = await fixture("nested");
try {
const code = `
import Module from 'node:module';
import assert from 'node:assert/strict';
const hook = await import(${JSON.stringify(pathToFileURL(f.hook).href)});
const original = Module._resolveFilename;
const failure = Object.assign(new Error("Cannot find module 'resolver-internal'\\nRequire stack:"), {code: 'MODULE_NOT_FOUND'});
Module._resolveFilename = () => { throw failure; };
try { assert.throws(() => hook.resolveNodePtyPackage(), error => error === failure); }
finally { Module._resolveFilename = original; }
`;
await execFileAsync(process.execPath, ["--input-type=module", "-e", code]);
} finally { await rm(f.root, { recursive: true, force: true }); }
});

test("non-darwin preserves helper permissions and unrelated dependency permissions", async () => {
const f = await fixture("nested");
try {
const mod = await import(pathToFileURL(f.hook).href);
const helper = join(f.pty, "prebuilds", "darwin-arm64", "spawn-helper");
await chmod(helper, 0o644);
const unrelated = join(f.app, "node_modules", "unrelated-helper");
await writeFile(unrelated, "unchanged");
await chmod(unrelated, 0o644);
const { stat } = await import("node:fs/promises");
const before = (await stat(helper)).mode;
const otherBefore = (await stat(unrelated)).mode;
await mod.fixNodePtyPermissions({platform: "linux"});
assert.equal((await stat(helper)).mode, before);
await mod.fixNodePtyPermissions({platform: "darwin"});
assert.equal((await stat(unrelated)).mode, otherBefore);
} finally { await rm(f.root, { recursive: true, force: true }); }
});
Loading