@ashlr/stack-core 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/__tests__/agent-onboard.test.ts +62 -0
- package/src/__tests__/base-urls.test.ts +48 -2
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +23 -0
- package/src/__tests__/config-split.test.ts +66 -0
- package/src/__tests__/config.test.ts +92 -3
- package/src/__tests__/credential-staging.test.ts +3 -2
- package/src/__tests__/detect-source.test.ts +182 -3
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +38 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/phantom-harness.test.ts +102 -1
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-wire.test.ts +372 -2
- package/src/__tests__/pipeline-integration.test.ts +32 -0
- package/src/__tests__/publish-script.test.ts +63 -18
- package/src/__tests__/recipe.test.ts +23 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +16 -1
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/template-litellm-gateway.test.ts +29 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +14 -0
- package/src/ai/phantom-wire.ts +44 -6
- package/src/detect-source.ts +14 -1
- package/src/errors.ts +6 -0
- package/src/index.ts +1 -0
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +144 -33
- package/src/stack-manifest.ts +39 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -33,6 +33,7 @@ describe("import pipeline", () => {
|
|
|
33
33
|
afterEach(() => {
|
|
34
34
|
process.chdir(originalCwd);
|
|
35
35
|
h.cleanup();
|
|
36
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
test("typical Next.js .env gets parsed, stored, and mapped to services", async () => {
|
|
@@ -49,7 +50,7 @@ PORT=3000
|
|
|
49
50
|
`.trim();
|
|
50
51
|
writeFileSync(join(cwd, ".env"), envContent);
|
|
51
52
|
|
|
52
|
-
const parsed = parseEnv(
|
|
53
|
+
const parsed = parseEnv(readFileSync(join(cwd, ".env"), "utf8"));
|
|
53
54
|
expect(parsed.map((p) => p.key)).toContain("OPENAI_API_KEY");
|
|
54
55
|
expect(parsed.map((p) => p.key)).toContain("NODE_ENV");
|
|
55
56
|
|
|
@@ -70,7 +71,12 @@ PORT=3000
|
|
|
70
71
|
const vault = await readVault(h.dir);
|
|
71
72
|
expect(vault.OPENAI_API_KEY).toBe("sk-fake");
|
|
72
73
|
expect(vault.NODE_ENV).toBe("development");
|
|
73
|
-
|
|
74
|
+
const expectedVault = Object.fromEntries(parsed.map(({ key, value }) => [key, value]));
|
|
75
|
+
expect(vault).toEqual(expectedVault);
|
|
76
|
+
expect((await listSecrets()).sort()).toEqual(Object.keys(expectedVault).sort());
|
|
77
|
+
expect(h.callsTo("add").map(({ args }) => args)).toEqual(
|
|
78
|
+
parsed.map(({ key }) => [key, "--stdin"]),
|
|
79
|
+
);
|
|
74
80
|
|
|
75
81
|
// Build a .stack.toml from the detected groups.
|
|
76
82
|
const config = emptyConfig("import-test");
|
|
@@ -83,5 +89,13 @@ PORT=3000
|
|
|
83
89
|
expect(roundTripped.services.supabase).toBeDefined();
|
|
84
90
|
expect(roundTripped.services.openai).toBeDefined();
|
|
85
91
|
expect(roundTripped.services.sentry.mcp).toBe("sentry");
|
|
86
|
-
|
|
92
|
+
expect(Object.keys(roundTripped.services).sort()).toEqual(["openai", "sentry", "supabase"]);
|
|
93
|
+
for (const [provider, keys] of Object.entries(grouped)) {
|
|
94
|
+
expect(roundTripped.services[provider].secrets).toEqual(keys);
|
|
95
|
+
}
|
|
96
|
+
const serialized = readFileSync(join(cwd, ".stack.toml"), "utf8");
|
|
97
|
+
for (const { value } of parsed) expect(serialized).not.toContain(value);
|
|
98
|
+
// Eight sequential real child processes plus list/version probes are integration
|
|
99
|
+
// work, not a five-second performance assertion (especially under coverage).
|
|
100
|
+
}, 30_000);
|
|
87
101
|
});
|
|
@@ -176,6 +176,44 @@ describe("LocalSLMBackend — infer", () => {
|
|
|
176
176
|
expect(call).toBeGreaterThanOrEqual(2);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
test.each(["invalid UTF-8", "replacement character"])(
|
|
180
|
+
"falls back when a 200 response contains an unusable %s assistant message",
|
|
181
|
+
async (kind) => {
|
|
182
|
+
const calls: string[] = [];
|
|
183
|
+
const fetchImpl = (async (input: string | URL | Request) => {
|
|
184
|
+
const url =
|
|
185
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
186
|
+
calls.push(url);
|
|
187
|
+
if (url.includes("secondary")) return okChatResponse(VALID_RECIPE_JSON);
|
|
188
|
+
|
|
189
|
+
// The envelope is valid JSON even after a malformed byte is decoded as U+FFFD.
|
|
190
|
+
const envelope = '{"choices":[{"message":{"content":"X"}}]}';
|
|
191
|
+
const bytes = new TextEncoder().encode(envelope);
|
|
192
|
+
if (kind === "invalid UTF-8") {
|
|
193
|
+
bytes[envelope.indexOf("X")] = 0xff;
|
|
194
|
+
return new Response(bytes, { status: 200 });
|
|
195
|
+
}
|
|
196
|
+
return new Response(envelope.replace("X", "\uFFFD"), { status: 200 });
|
|
197
|
+
}) as unknown as typeof fetch;
|
|
198
|
+
const backend = new LocalSLMBackend({
|
|
199
|
+
endpoints: [
|
|
200
|
+
{ name: "primary", baseUrl: "http://primary/v1", model: "a" },
|
|
201
|
+
{ name: "secondary", baseUrl: "http://secondary/v1", model: "b" },
|
|
202
|
+
],
|
|
203
|
+
fetchImpl,
|
|
204
|
+
costTracker: new CostTracker(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const result = await backend.infer({ query: "x", catalogContext: "" });
|
|
208
|
+
expect(result.recipe?.providers.map((provider) => provider.name)).toEqual(["neon", "clerk"]);
|
|
209
|
+
expect(backend.lastEndpoint).toBe("secondary");
|
|
210
|
+
expect(calls).toEqual([
|
|
211
|
+
"http://primary/v1/chat/completions",
|
|
212
|
+
"http://secondary/v1/chat/completions",
|
|
213
|
+
]);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
179
217
|
test("throws NoInferenceBackendError when every endpoint fails", async () => {
|
|
180
218
|
const fetchImpl = (async () => {
|
|
181
219
|
throw new Error("down");
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { ENTRY_POINTS, findOrphanModules } from "../../../../scripts/no-orphan-modules.ts";
|
|
6
|
+
|
|
7
|
+
const root = resolve(import.meta.dir, "../../../..");
|
|
8
|
+
const scratch: string[] = [];
|
|
9
|
+
const core = "packages/core/src/";
|
|
10
|
+
const cli = "packages/cli/src/";
|
|
11
|
+
const entries = [`${core}index.ts`, `${cli}index.ts`];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function fixture(files: Record<string, string>): string {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-orphans-"));
|
|
19
|
+
scratch.push(dir);
|
|
20
|
+
for (const [path, text] of Object.entries({
|
|
21
|
+
"tsconfig.json": JSON.stringify({
|
|
22
|
+
compilerOptions: {
|
|
23
|
+
moduleResolution: "bundler",
|
|
24
|
+
module: "esnext",
|
|
25
|
+
baseUrl: ".",
|
|
26
|
+
paths: { "@ashlr/stack-core": [`${core}index.ts`], "@ashlr/stack-core/*": [`${core}*`] },
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
"scripts/placeholder.ts": "",
|
|
30
|
+
[entries[0]!]: "",
|
|
31
|
+
[entries[1]!]: "",
|
|
32
|
+
...files,
|
|
33
|
+
})) {
|
|
34
|
+
const file = join(dir, path);
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
writeFileSync(file, text);
|
|
37
|
+
}
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A whole-repository static scan can exceed Bun's 5s default on a loaded runner.
|
|
42
|
+
test("every core and CLI module is reachable from an entry point or production consumer", () => {
|
|
43
|
+
expect(findOrphanModules(root)).toEqual([]);
|
|
44
|
+
}, 30_000);
|
|
45
|
+
|
|
46
|
+
test("the entry point allowlist matches the public core exports and CLI executable", () => {
|
|
47
|
+
const manifest = JSON.parse(readFileSync(join(root, "packages/core/package.json"), "utf8"));
|
|
48
|
+
expect([...ENTRY_POINTS].sort()).toEqual(
|
|
49
|
+
[
|
|
50
|
+
...Object.values(manifest.exports).map(
|
|
51
|
+
(path) => `packages/core/${String(path).replace(/^\.\//, "")}`,
|
|
52
|
+
),
|
|
53
|
+
`${cli}index.ts`,
|
|
54
|
+
].sort(),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reports new orphans, test-only imports, self imports, dead chains and cycles", () => {
|
|
59
|
+
const dir = fixture({
|
|
60
|
+
[`${core}orphan.ts`]: "export const unused = true;",
|
|
61
|
+
[`${core}orphan.test.ts`]: 'import "./orphan.ts";',
|
|
62
|
+
[`${cli}__tests__/keep.test.ts`]: 'import "../../../core/src/orphan.ts";',
|
|
63
|
+
[`${core}types.d.ts`]: 'import "./orphan.ts";',
|
|
64
|
+
"scripts/legacy.node-tests.mjs": 'import "../packages/core/src/orphan.ts";',
|
|
65
|
+
[`${core}self.ts`]: 'import "./self.ts";',
|
|
66
|
+
[`${cli}chain.ts`]: 'import "./leaf.ts";',
|
|
67
|
+
[`${cli}leaf.ts`]: "export {};",
|
|
68
|
+
[`${core}a.ts`]: 'import "./b.ts";',
|
|
69
|
+
[`${core}b.ts`]: 'import "./a.ts";',
|
|
70
|
+
});
|
|
71
|
+
expect(findOrphanModules(dir, entries)).toEqual([
|
|
72
|
+
`${cli}chain.ts`,
|
|
73
|
+
`${cli}leaf.ts`,
|
|
74
|
+
`${core}a.ts`,
|
|
75
|
+
`${core}b.ts`,
|
|
76
|
+
`${core}orphan.ts`,
|
|
77
|
+
`${core}self.ts`,
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("resolves aliases, JS specifiers, reexports, types, dynamic imports and require", () => {
|
|
82
|
+
const dir = fixture({
|
|
83
|
+
[`${cli}index.ts`]:
|
|
84
|
+
'import "@ashlr/stack-core"; import("./lazy.ts"); require("./required.ts");',
|
|
85
|
+
[`${core}index.ts`]: 'export * from "./live.js"; export type { T } from "./types.ts";',
|
|
86
|
+
[`${core}live.ts`]: "export const live = true;",
|
|
87
|
+
[`${core}types.ts`]: "export type T = string;",
|
|
88
|
+
[`${cli}lazy.ts`]:
|
|
89
|
+
'import dep = require("./equals.ts"); type T = import("./import-type.ts").T;',
|
|
90
|
+
[`${cli}required.ts`]: 'import "./side-effect.ts";',
|
|
91
|
+
[`${cli}equals.ts`]: "export {};",
|
|
92
|
+
[`${cli}import-type.ts`]: "export type T = string;",
|
|
93
|
+
[`${cli}side-effect.ts`]: "export {};",
|
|
94
|
+
"packages/mcp/src/consumer.ts": 'import "@ashlr/stack-core/external.ts";',
|
|
95
|
+
[`${core}external.ts`]: "export {};",
|
|
96
|
+
});
|
|
97
|
+
expect(findOrphanModules(dir, entries)).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("comments and strings are not importers; stale entry points fail closed", () => {
|
|
101
|
+
const dir = fixture({
|
|
102
|
+
[`${cli}index.ts`]: [
|
|
103
|
+
'// import "./unused.ts";',
|
|
104
|
+
"const text = 'import(\"./unused.ts\")';",
|
|
105
|
+
String.raw`const regex = /import(".\/unused.ts")/;`,
|
|
106
|
+
'obj.require("./unused.ts");',
|
|
107
|
+
].join("\n"),
|
|
108
|
+
[`${cli}unused.ts`]: "export {};",
|
|
109
|
+
});
|
|
110
|
+
expect(findOrphanModules(dir, entries)).toEqual([`${cli}unused.ts`]);
|
|
111
|
+
expect(() => findOrphanModules(dir, [...entries, `${core}missing.ts`])).toThrow(
|
|
112
|
+
"Missing entry point",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import {
|
|
6
|
+
PhantomTimeoutError,
|
|
7
|
+
__resetPhantomCache,
|
|
5
8
|
addSecret,
|
|
6
9
|
assertProjectInitialized,
|
|
7
10
|
completionForClose,
|
|
11
|
+
countSecrets,
|
|
8
12
|
exec,
|
|
9
13
|
isPhantomInstalled,
|
|
10
14
|
listSecrets,
|
|
15
|
+
probePhantomInstalled,
|
|
11
16
|
removeSecret,
|
|
12
17
|
revealSecret,
|
|
13
18
|
} from "../phantom.ts";
|
|
@@ -28,6 +33,53 @@ describe("phantom wrapper against the fake harness", () => {
|
|
|
28
33
|
expect(await isPhantomInstalled()).toBe(true);
|
|
29
34
|
});
|
|
30
35
|
|
|
36
|
+
test("exec excludes parent credentials and runtime injection variables", async () => {
|
|
37
|
+
const keys = ["STACK_TEST_PARENT_CREDENTIAL", "NODE_OPTIONS", "BASH_ENV", "LD_PRELOAD"];
|
|
38
|
+
const previous = keys.map((key) => process.env[key]);
|
|
39
|
+
try {
|
|
40
|
+
for (const key of keys) process.env[key] = "inert-regression-marker";
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(h.dir, "phantom"),
|
|
43
|
+
'#!/bin/sh\nfor key in STACK_TEST_PARENT_CREDENTIAL NODE_OPTIONS BASH_ENV LD_PRELOAD; do\n if printenv "$key" >/dev/null; then exit 9; fi\ndone\ntest -n "$HOME" && test -n "$PATH"\n',
|
|
44
|
+
);
|
|
45
|
+
expect((await exec(["--version"], { allowFailure: true })).code).toBe(0);
|
|
46
|
+
} finally {
|
|
47
|
+
keys.forEach((key, i) => {
|
|
48
|
+
if (previous[i] === undefined) delete process.env[key];
|
|
49
|
+
else process.env[key] = previous[i];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("exec forwards Phantom's explicit vault unlock input to the child", async () => {
|
|
55
|
+
const previous = process.env.PHANTOM_VAULT_PASSPHRASE;
|
|
56
|
+
try {
|
|
57
|
+
process.env.PHANTOM_VAULT_PASSPHRASE = "inert-regression-passphrase";
|
|
58
|
+
writeFileSync(
|
|
59
|
+
join(h.dir, "phantom"),
|
|
60
|
+
'#!/bin/sh\ntest "$PHANTOM_VAULT_PASSPHRASE" = "inert-regression-passphrase"\n',
|
|
61
|
+
);
|
|
62
|
+
expect((await exec(["--version"], { allowFailure: true })).code).toBe(0);
|
|
63
|
+
} finally {
|
|
64
|
+
if (previous === undefined) Reflect.deleteProperty(process.env, "PHANTOM_VAULT_PASSPHRASE");
|
|
65
|
+
else process.env.PHANTOM_VAULT_PASSPHRASE = previous;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("exec resolves relative PATH entries before entering the project directory", async () => {
|
|
70
|
+
const previousPath = process.env.PATH;
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(join(h.dir, "phantom"), "#!/bin/sh\nexit 9\n");
|
|
73
|
+
// A relative entry must never be reinterpreted inside the requested cwd.
|
|
74
|
+
process.env.PATH = ".:/usr/bin:/bin";
|
|
75
|
+
await expect(exec(["--version"], { cwd: h.dir })).rejects.toMatchObject({
|
|
76
|
+
code: "PHANTOM_NOT_INSTALLED",
|
|
77
|
+
});
|
|
78
|
+
} finally {
|
|
79
|
+
process.env.PATH = previousPath;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
31
83
|
test("addSecret writes to the fake vault over stdin, never argv", async () => {
|
|
32
84
|
await addSecret("NEW_KEY", "new-value");
|
|
33
85
|
expect((await readVault(h.dir)).NEW_KEY).toBe("new-value");
|
|
@@ -158,4 +210,53 @@ describe("phantom wrapper against the fake harness", () => {
|
|
|
158
210
|
expect(await removeSecret("EXISTING_KEY")).toBe(false);
|
|
159
211
|
expect(await listSecrets()).toContain("EXISTING_KEY");
|
|
160
212
|
});
|
|
213
|
+
|
|
214
|
+
test("countSecrets reports the vault size, including when the install probe is skipped", async () => {
|
|
215
|
+
expect(await countSecrets()).toBe(1);
|
|
216
|
+
expect(await countSecrets(undefined, { skipInstallCheck: true })).toBe(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("exec timeout kills a hung phantom and the install probe reports not installed", async () => {
|
|
220
|
+
writeFileSync(join(h.dir, "phantom"), "#!/bin/sh\nexec sleep 5\n");
|
|
221
|
+
__resetPhantomCache();
|
|
222
|
+
const started = Date.now();
|
|
223
|
+
const result = await exec(["--version"], { allowFailure: true, timeoutMs: 200 });
|
|
224
|
+
expect(result.completion).toBe("signal");
|
|
225
|
+
expect(result.code).not.toBe(0);
|
|
226
|
+
expect(result.timedOut).toBe(true);
|
|
227
|
+
__resetPhantomCache();
|
|
228
|
+
expect(await probePhantomInstalled({ timeoutMs: 200 })).toBe("timeout");
|
|
229
|
+
// A timeout is not cached: once Phantom answers, it is installed.
|
|
230
|
+
writeFileSync(join(h.dir, "phantom"), "#!/bin/sh\necho phantom 0.9.0\n");
|
|
231
|
+
expect(await probePhantomInstalled({ timeoutMs: 2_000 })).toBe("installed");
|
|
232
|
+
writeFileSync(join(h.dir, "phantom"), "#!/bin/sh\nexec sleep 5\n");
|
|
233
|
+
__resetPhantomCache();
|
|
234
|
+
expect(await isPhantomInstalled({ timeoutMs: 200 })).toBe(false);
|
|
235
|
+
__resetPhantomCache();
|
|
236
|
+
await expect(
|
|
237
|
+
listSecrets(undefined, { skipInstallCheck: true, timeoutMs: 200 }),
|
|
238
|
+
).rejects.toBeInstanceOf(PhantomTimeoutError);
|
|
239
|
+
__resetPhantomCache();
|
|
240
|
+
expect(await countSecrets(undefined, { skipInstallCheck: true, timeoutMs: 200 })).toBeNull();
|
|
241
|
+
expect(Date.now() - started).toBeLessThan(2_000);
|
|
242
|
+
});
|
|
243
|
+
test.skipIf(process.platform === "win32")(
|
|
244
|
+
"timeout kills a grandchild that holds stdio and settles within the cap",
|
|
245
|
+
async () => {
|
|
246
|
+
const pidFile = join(tmpdir(), `stack-grandchild-${process.pid}-${Date.now()}.pid`);
|
|
247
|
+
writeFileSync(
|
|
248
|
+
join(h.dir, "phantom"),
|
|
249
|
+
`#!/bin/sh\nsleep 30 &\necho $! > '${pidFile}'\nwait\n`,
|
|
250
|
+
);
|
|
251
|
+
const started = Date.now();
|
|
252
|
+
const result = await exec(["--version"], { allowFailure: true, timeoutMs: 300 });
|
|
253
|
+
expect(result.timedOut).toBe(true);
|
|
254
|
+
expect(Date.now() - started).toBeLessThan(2_500);
|
|
255
|
+
expect(existsSync(pidFile)).toBe(true);
|
|
256
|
+
const pid = Number(readFileSync(pidFile, "utf8").trim());
|
|
257
|
+
expect(pid).toBeGreaterThan(0);
|
|
258
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
259
|
+
expect(() => process.kill(pid, 0)).toThrow();
|
|
260
|
+
},
|
|
261
|
+
);
|
|
161
262
|
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { delimiter, join, resolve } from "node:path";
|
|
6
|
+
import { PhantomNotInstalledError } from "../errors.ts";
|
|
7
|
+
import { phantomEnv, resolvePhantomBinary } from "../phantom-process.ts";
|
|
8
|
+
|
|
9
|
+
const dirs: string[] = [];
|
|
10
|
+
function tmp(): string {
|
|
11
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "phantom-process-")));
|
|
12
|
+
dirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
const name = process.platform === "win32" ? "phantom.exe" : "phantom";
|
|
19
|
+
function binary(dir: string, script = "#!/bin/sh\nexit 0\n"): string {
|
|
20
|
+
const path = join(dir, name);
|
|
21
|
+
writeFileSync(path, script);
|
|
22
|
+
chmodSync(path, 0o755);
|
|
23
|
+
return path;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("Phantom process boundary", () => {
|
|
27
|
+
test("environment has an exact allowlist, including only the explicit vault unlock input", () => {
|
|
28
|
+
const pathEntry = join(tmpdir(), "phantom-env-path");
|
|
29
|
+
const allowed = {
|
|
30
|
+
PATH: pathEntry,
|
|
31
|
+
HOME: "/home/test",
|
|
32
|
+
USER: "test",
|
|
33
|
+
USERPROFILE: "C:\\test",
|
|
34
|
+
SystemRoot: "C:\\Windows",
|
|
35
|
+
APPDATA: "/app",
|
|
36
|
+
LOCALAPPDATA: "/local",
|
|
37
|
+
PATHEXT: ".EXE",
|
|
38
|
+
XDG_CONFIG_HOME: "/config",
|
|
39
|
+
XDG_DATA_HOME: "/data",
|
|
40
|
+
XDG_STATE_HOME: "/state",
|
|
41
|
+
XDG_RUNTIME_DIR: "/runtime",
|
|
42
|
+
TMPDIR: "/tmp",
|
|
43
|
+
TEMP: "/temp",
|
|
44
|
+
TMP: "/tmp",
|
|
45
|
+
LANG: "C",
|
|
46
|
+
LC_ALL: "C",
|
|
47
|
+
LC_CTYPE: "C",
|
|
48
|
+
TERM: "dumb",
|
|
49
|
+
NO_COLOR: "",
|
|
50
|
+
PHANTOM_VAULT_PASSPHRASE: "synthetic-test-input",
|
|
51
|
+
};
|
|
52
|
+
expect(
|
|
53
|
+
phantomEnv({
|
|
54
|
+
...allowed,
|
|
55
|
+
OPENAI_API_KEY: "synthetic",
|
|
56
|
+
GITHUB_TOKEN: "synthetic",
|
|
57
|
+
NODE_OPTIONS: "--inspect",
|
|
58
|
+
LD_PRELOAD: "/untrusted",
|
|
59
|
+
DYLD_INSERT_LIBRARIES: "/untrusted",
|
|
60
|
+
BASH_ENV: "/untrusted",
|
|
61
|
+
PHANTOM_UNKNOWN: "synthetic",
|
|
62
|
+
LOCUS_CONTROL_CAPABILITY: "synthetic",
|
|
63
|
+
}),
|
|
64
|
+
).toEqual(allowed);
|
|
65
|
+
expect(phantomEnv({ HOME: undefined })).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("PATH is normalized: empty entries dropped, relative entries resolved in the caller's cwd", () => {
|
|
69
|
+
const abs = join(tmpdir(), "phantom-env-abs");
|
|
70
|
+
expect(phantomEnv({ PATH: `${delimiter}rel${delimiter}${abs}${delimiter}` })).toEqual({
|
|
71
|
+
PATH: `${resolve("rel")}${delimiter}${abs}`,
|
|
72
|
+
});
|
|
73
|
+
expect(phantomEnv({ PATH: delimiter })).toEqual({ PATH: "" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("resolves the first executable to an absolute path, including relative PATH entries", () => {
|
|
77
|
+
const root = tmp();
|
|
78
|
+
const bin = join(root, "bin");
|
|
79
|
+
mkdirSync(bin);
|
|
80
|
+
const expected = binary(bin);
|
|
81
|
+
expect(resolvePhantomBinary({ PATH: `missing${delimiter}bin` }, root)).toBe(expected);
|
|
82
|
+
expect(resolvePhantomBinary({ PATH: `${bin}${delimiter}${tmp()}` })).toBe(expected);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("missing PATH, empty entries and directories fail closed", () => {
|
|
86
|
+
const root = tmp();
|
|
87
|
+
binary(root);
|
|
88
|
+
expect(() => resolvePhantomBinary({}, root)).toThrow(PhantomNotInstalledError);
|
|
89
|
+
expect(() => resolvePhantomBinary({ PATH: delimiter }, root)).toThrow(PhantomNotInstalledError);
|
|
90
|
+
const bin = tmp();
|
|
91
|
+
mkdirSync(join(bin, name));
|
|
92
|
+
expect(() => resolvePhantomBinary({ PATH: bin })).toThrow(PhantomNotInstalledError);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test.skipIf(process.platform === "win32")(
|
|
96
|
+
"non-executable files do not shadow an installation",
|
|
97
|
+
() => {
|
|
98
|
+
const first = tmp();
|
|
99
|
+
chmodSync(binary(first), 0o644);
|
|
100
|
+
const second = tmp();
|
|
101
|
+
const expected = binary(second);
|
|
102
|
+
expect(resolvePhantomBinary({ PATH: `${first}${delimiter}${second}` })).toBe(expected);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
test.skipIf(process.platform === "win32")(
|
|
107
|
+
"exec uses the resolved path across cwd changes and filters the actual child env",
|
|
108
|
+
() => {
|
|
109
|
+
const root = tmp();
|
|
110
|
+
const bin = join(root, "bin");
|
|
111
|
+
mkdirSync(bin);
|
|
112
|
+
const childCwd = tmp();
|
|
113
|
+
const expected = binary(
|
|
114
|
+
bin,
|
|
115
|
+
`#!/bin/sh
|
|
116
|
+
[ -z "\${OPENAI_API_KEY+x}" ] || exit 21
|
|
117
|
+
[ -z "\${BASH_ENV+x}" ] || exit 22
|
|
118
|
+
[ "$HOME" = "$1" ] || exit 23
|
|
119
|
+
printf '%s' "$0"
|
|
120
|
+
`,
|
|
121
|
+
);
|
|
122
|
+
const modulePath = new URL("../phantom.ts", import.meta.url).href;
|
|
123
|
+
const script = `import { exec } from ${JSON.stringify(modulePath)};
|
|
124
|
+
const r = await exec([${JSON.stringify(root)}], { cwd: ${JSON.stringify(childCwd)} });
|
|
125
|
+
process.stdout.write(r.stdout);`;
|
|
126
|
+
const r = spawnSync(process.execPath, ["--eval", script], {
|
|
127
|
+
cwd: root,
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
env: { HOME: root, PATH: "bin", OPENAI_API_KEY: "synthetic", BASH_ENV: "/nonexistent" },
|
|
130
|
+
});
|
|
131
|
+
expect(r.status).toBe(0);
|
|
132
|
+
expect(r.stdout).toBe(expected);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
});
|