@ashlr/stack-core 0.1.1 → 0.3.0
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/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +73 -58
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- package/src/templates.ts +6 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
|
|
7
|
+
import { StackError } from "../errors.ts";
|
|
8
|
+
import {
|
|
9
|
+
__resetPhantomCache,
|
|
10
|
+
addSecret,
|
|
11
|
+
isPhantomInstalled,
|
|
12
|
+
listSecrets,
|
|
13
|
+
removeSecret,
|
|
14
|
+
revealSecret,
|
|
15
|
+
} from "../phantom.ts";
|
|
16
|
+
import { takeRevealRefusals, tryRevealSecret } from "../providers/_helpers.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Producer/consumer contract: Stack's Phantom wrapper against a REAL
|
|
20
|
+
* `phantom` binary (Phantom Secrets 0.7.9 or newer).
|
|
21
|
+
*
|
|
22
|
+
* The fake in `_harness.ts` encodes what Stack assumes Phantom does; this
|
|
23
|
+
* suite checks those assumptions against the producer. It is opt-in because
|
|
24
|
+
* CI does not install Phantom:
|
|
25
|
+
*
|
|
26
|
+
* STACK_PHANTOM_CONTRACT_BIN=/abs/path/to/phantom bun test phantom-contract
|
|
27
|
+
*
|
|
28
|
+
* The vault is an isolated encrypted-file vault under a temp HOME, unlocked
|
|
29
|
+
* with a throwaway passphrase. No keychain, network or real credential is used.
|
|
30
|
+
* Tests share that vault and run in order: later tests rely on names stored
|
|
31
|
+
* by earlier ones.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const realBin = process.env.STACK_PHANTOM_CONTRACT_BIN;
|
|
35
|
+
const enabled = Boolean(realBin && isAbsolute(realBin));
|
|
36
|
+
|
|
37
|
+
const SYNTHETIC_VALUE = "stack-contract-synthetic-value-7f3e";
|
|
38
|
+
|
|
39
|
+
describe.skipIf(!enabled)("phantom CLI contract (real binary)", () => {
|
|
40
|
+
let root = "";
|
|
41
|
+
let project = "";
|
|
42
|
+
const saved: Record<string, string | undefined> = {};
|
|
43
|
+
const cwd = process.cwd();
|
|
44
|
+
|
|
45
|
+
function phantom(args: string[], input?: string) {
|
|
46
|
+
return spawnSync("phantom", args, {
|
|
47
|
+
cwd: project,
|
|
48
|
+
input,
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function vaultNames(): string[] {
|
|
55
|
+
const out = phantom(["list", "--json"]);
|
|
56
|
+
expect(out.status).toBe(0);
|
|
57
|
+
return (JSON.parse(out.stdout) as Array<{ name: string }>).map((e) => e.name).sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeAll(() => {
|
|
61
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "stack-phantom-contract-")));
|
|
62
|
+
const bin = join(root, "bin");
|
|
63
|
+
project = join(root, "project");
|
|
64
|
+
mkdirSync(bin);
|
|
65
|
+
mkdirSync(project);
|
|
66
|
+
mkdirSync(join(root, "home"));
|
|
67
|
+
symlinkSync(realBin as string, join(bin, "phantom"));
|
|
68
|
+
for (const k of ["PATH", "HOME", "PHANTOM_VAULT_PASSPHRASE", "RUST_BACKTRACE"]) {
|
|
69
|
+
saved[k] = process.env[k];
|
|
70
|
+
}
|
|
71
|
+
process.env.PATH = `${bin}:${process.env.PATH ?? ""}`;
|
|
72
|
+
process.env.HOME = join(root, "home");
|
|
73
|
+
process.env.PHANTOM_VAULT_PASSPHRASE = "stack-contract-throwaway-passphrase";
|
|
74
|
+
process.env.RUST_BACKTRACE = "0";
|
|
75
|
+
__resetPhantomCache();
|
|
76
|
+
spawnSync("git", ["init", "-q"], { cwd: project });
|
|
77
|
+
const init = phantom(["init", "--empty"]);
|
|
78
|
+
expect(init.status).toBe(0);
|
|
79
|
+
process.chdir(project);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
process.chdir(cwd);
|
|
84
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
85
|
+
if (v === undefined) delete process.env[k];
|
|
86
|
+
else process.env[k] = v;
|
|
87
|
+
}
|
|
88
|
+
__resetPhantomCache();
|
|
89
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("version probe detects the binary", async () => {
|
|
93
|
+
expect(await isPhantomInstalled()).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Argv is asserted against the fake in phantom-harness.test.ts; the real
|
|
97
|
+
// binary rejects a positional value, so storing at all proves stdin.
|
|
98
|
+
test("addSecret stores a new name", async () => {
|
|
99
|
+
await addSecret("STACK_CONTRACT_A", SYNTHETIC_VALUE, project);
|
|
100
|
+
expect(vaultNames()).toContain("STACK_CONTRACT_A");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("listSecrets returns exactly the stored names", async () => {
|
|
104
|
+
await addSecret("STACK_CONTRACT_B", SYNTHETIC_VALUE, project);
|
|
105
|
+
expect((await listSecrets(project)).sort()).toEqual(vaultNames());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("completed identical creates refuse with typed errors and preserve the full name inventory", async () => {
|
|
109
|
+
await addSecret("STACK_CONTRACT_C", SYNTHETIC_VALUE, project);
|
|
110
|
+
const names = vaultNames();
|
|
111
|
+
expect(names).toContain("STACK_CONTRACT_C");
|
|
112
|
+
for (let repeat = 0; repeat < 2; repeat++) {
|
|
113
|
+
let caught: unknown;
|
|
114
|
+
try {
|
|
115
|
+
await addSecret("STACK_CONTRACT_C", SYNTHETIC_VALUE, project);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
caught = err;
|
|
118
|
+
}
|
|
119
|
+
expect(caught).toBeInstanceOf(StackError);
|
|
120
|
+
if (!(caught instanceof StackError)) throw new Error("Expected a typed collision.");
|
|
121
|
+
expect(caught.code).toBe("PHANTOM_SECRET_EXISTS");
|
|
122
|
+
expect(caught.message).not.toContain(SYNTHETIC_VALUE);
|
|
123
|
+
expect(vaultNames()).toEqual(names);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Phantom offers no headless read, so only the name's survival is observable.
|
|
128
|
+
test("addSecret on an existing name fails with a typed error and keeps the name", async () => {
|
|
129
|
+
let caught: unknown;
|
|
130
|
+
try {
|
|
131
|
+
await addSecret("STACK_CONTRACT_A", "replacement-value", project);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
caught = err;
|
|
134
|
+
}
|
|
135
|
+
expect(caught).toBeInstanceOf(StackError);
|
|
136
|
+
expect((caught as StackError).code).toBe("PHANTOM_SECRET_EXISTS");
|
|
137
|
+
expect((caught as StackError).message).not.toContain("replacement-value");
|
|
138
|
+
expect(vaultNames()).toContain("STACK_CONTRACT_A");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("revealSecret without a terminal fails closed with a typed error", async () => {
|
|
142
|
+
let caught: unknown;
|
|
143
|
+
try {
|
|
144
|
+
await revealSecret("STACK_CONTRACT_A", project);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
caught = err;
|
|
147
|
+
}
|
|
148
|
+
expect((caught as StackError).code).toBe("PHANTOM_TRUSTED_TERMINAL_REQUIRED");
|
|
149
|
+
expect(String((caught as Error).message)).not.toContain(SYNTHETIC_VALUE);
|
|
150
|
+
takeRevealRefusals();
|
|
151
|
+
expect(await tryRevealSecret("STACK_CONTRACT_A")).toBeUndefined();
|
|
152
|
+
// Doctor relies on this to report "stored, unverified" instead of "missing".
|
|
153
|
+
expect(takeRevealRefusals()).toEqual(["STACK_CONTRACT_A"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("removeSecret without a terminal reports that nothing was removed", async () => {
|
|
157
|
+
expect(await removeSecret("STACK_CONTRACT_B", project)).toBe(false);
|
|
158
|
+
expect(vaultNames()).toContain("STACK_CONTRACT_B");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("recipe wiring never writes placeholder values into the vault", async () => {
|
|
162
|
+
const before = vaultNames();
|
|
163
|
+
const result = await wirePhantomForRecipe(
|
|
164
|
+
{
|
|
165
|
+
id: "contract",
|
|
166
|
+
query: "contract",
|
|
167
|
+
createdAt: new Date(0).toISOString(),
|
|
168
|
+
providers: [{ name: "openai" }],
|
|
169
|
+
},
|
|
170
|
+
{ cwd: project },
|
|
171
|
+
);
|
|
172
|
+
expect(result.present).toEqual([]);
|
|
173
|
+
expect(result.missing).toContain("openai:OPENAI_API_KEY");
|
|
174
|
+
expect(result.skipped).toEqual([]);
|
|
175
|
+
expect(vaultNames()).toEqual(before);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import {
|
|
6
|
+
PhantomTimeoutError,
|
|
7
|
+
__resetPhantomCache,
|
|
3
8
|
addSecret,
|
|
9
|
+
assertProjectInitialized,
|
|
10
|
+
completionForClose,
|
|
11
|
+
countSecrets,
|
|
12
|
+
exec,
|
|
4
13
|
isPhantomInstalled,
|
|
5
14
|
listSecrets,
|
|
15
|
+
probePhantomInstalled,
|
|
6
16
|
removeSecret,
|
|
7
17
|
revealSecret,
|
|
8
18
|
} from "../phantom.ts";
|
|
9
|
-
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
19
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
10
20
|
|
|
11
21
|
describe("phantom wrapper against the fake harness", () => {
|
|
12
22
|
let h: Harness;
|
|
@@ -23,10 +33,170 @@ describe("phantom wrapper against the fake harness", () => {
|
|
|
23
33
|
expect(await isPhantomInstalled()).toBe(true);
|
|
24
34
|
});
|
|
25
35
|
|
|
26
|
-
test("
|
|
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
|
+
|
|
83
|
+
test("addSecret writes to the fake vault over stdin, never argv", async () => {
|
|
27
84
|
await addSecret("NEW_KEY", "new-value");
|
|
28
|
-
expect(await
|
|
29
|
-
expect(h.callsTo("add")
|
|
85
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("new-value");
|
|
86
|
+
expect(h.callsTo("add")).toEqual([{ cmd: "add", args: ["NEW_KEY", "--stdin"] }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test.each(["one\ntwo", "one\rtwo", "one\r\ntwo", "one\n", "one\r"])(
|
|
90
|
+
"addSecret refuses unsupported CR/LF input before spawning (%j)",
|
|
91
|
+
async (value) => {
|
|
92
|
+
await expect(addSecret("NEW_KEY", value)).rejects.toMatchObject({
|
|
93
|
+
code: "PHANTOM_SECRET_VALUE_UNSUPPORTED",
|
|
94
|
+
});
|
|
95
|
+
expect(h.calls()).toEqual([]);
|
|
96
|
+
expect((await readVault(h.dir)).NEW_KEY).toBeUndefined();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
test("the inert producer fixture matches 0.7.9 read_line and CR/LF stripping", async () => {
|
|
101
|
+
await exec(["add", "DIRECT_KEY", "--stdin"], { stdin: "first\r\nsecond" });
|
|
102
|
+
expect((await readVault(h.dir)).DIRECT_KEY).toBe("first");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("signal-terminated add fails and never authorizes a later collision", async () => {
|
|
106
|
+
const bin = join(h.dir, "phantom");
|
|
107
|
+
const original = readFileSync(bin, "utf8");
|
|
108
|
+
writeFileSync(
|
|
109
|
+
bin,
|
|
110
|
+
'#!/bin/sh\ncase "$1" in --version) echo fixture;; *) kill -TERM $$;; esac\n',
|
|
111
|
+
);
|
|
112
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
113
|
+
code: "PHANTOM_EXEC_FAILED",
|
|
114
|
+
});
|
|
115
|
+
writeFileSync(h.vaultPath, JSON.stringify({ NEW_KEY: "foreign" }));
|
|
116
|
+
writeFileSync(bin, original);
|
|
117
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
118
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
119
|
+
});
|
|
120
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("foreign");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("signal-terminated remove reports failure; subsequent collision still refuses", async () => {
|
|
124
|
+
await addSecret("NEW_KEY", "same");
|
|
125
|
+
const bin = join(h.dir, "phantom");
|
|
126
|
+
const original = readFileSync(bin, "utf8");
|
|
127
|
+
writeFileSync(bin, "#!/bin/sh\nkill -TERM $$\n");
|
|
128
|
+
const result = await exec(["remove", "NEW_KEY"], { allowFailure: true });
|
|
129
|
+
expect(result).toMatchObject({ code: 1, signal: "SIGTERM", completion: "signal" });
|
|
130
|
+
expect(await removeSecret("NEW_KEY")).toBe(false);
|
|
131
|
+
writeFileSync(bin, original);
|
|
132
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
133
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
134
|
+
});
|
|
135
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("same");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("collision wording from a killed repeat never becomes confirmed success", async () => {
|
|
139
|
+
await addSecret("NEW_KEY", "same");
|
|
140
|
+
const bin = join(h.dir, "phantom");
|
|
141
|
+
const original = readFileSync(bin, "utf8");
|
|
142
|
+
writeFileSync(bin, "#!/bin/sh\necho 'already protected' >&2\nkill -TERM $$\n");
|
|
143
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
144
|
+
code: "PHANTOM_EXEC_FAILED",
|
|
145
|
+
message: "Phantom add did not complete; secret storage is unconfirmed.",
|
|
146
|
+
});
|
|
147
|
+
writeFileSync(bin, original);
|
|
148
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
149
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
150
|
+
});
|
|
151
|
+
expect((await readVault(h.dir)).NEW_KEY).toBe("same");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("null close fields remain unknown without a signal; signals never attest completion", () => {
|
|
155
|
+
expect(completionForClose(null, null)).toBe("unknown");
|
|
156
|
+
expect(completionForClose(null, "SIGTERM")).toBe("signal");
|
|
157
|
+
expect(completionForClose(0, "SIGTERM")).toBe("signal");
|
|
158
|
+
expect(completionForClose(0, null)).toBe("exited");
|
|
159
|
+
expect(completionForClose(9, null)).toBe("exited");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("initialization preflight consumes only metadata status", async () => {
|
|
163
|
+
await assertProjectInitialized(h.dir);
|
|
164
|
+
expect(h.callsTo("status")).toEqual([{ cmd: "status", args: ["--json"] }]);
|
|
165
|
+
expect(h.callsTo("list")).toEqual([]);
|
|
166
|
+
expect(h.callsTo("init")).toEqual([]);
|
|
167
|
+
expect(h.callsTo("reveal")).toEqual([]);
|
|
168
|
+
expect(h.callsTo("add")).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("failed inventory stays unknown instead of appearing empty", async () => {
|
|
172
|
+
writeFileSync(
|
|
173
|
+
join(h.dir, "phantom"),
|
|
174
|
+
'#!/bin/sh\ncase "$1" in --version) echo fixture;; *) exit 9;; esac\n',
|
|
175
|
+
);
|
|
176
|
+
await expect(listSecrets()).rejects.toMatchObject({ code: "PHANTOM_METADATA_UNAVAILABLE" });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("addSecret of an existing name fails with PHANTOM_SECRET_EXISTS", async () => {
|
|
180
|
+
await expect(addSecret("EXISTING_KEY", "other")).rejects.toMatchObject({
|
|
181
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
182
|
+
});
|
|
183
|
+
expect((await readVault(h.dir)).EXISTING_KEY).toBe("preset-value");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("addSecret refuses identical and different completed repeats", async () => {
|
|
187
|
+
await addSecret("NEW_KEY", "same");
|
|
188
|
+
await expect(addSecret("NEW_KEY", "same")).rejects.toMatchObject({
|
|
189
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
190
|
+
});
|
|
191
|
+
await expect(addSecret("NEW_KEY", "different")).rejects.toMatchObject({
|
|
192
|
+
code: "PHANTOM_SECRET_EXISTS",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("revealSecret fails closed without a terminal", async () => {
|
|
197
|
+
await expect(revealSecret("EXISTING_KEY")).rejects.toMatchObject({
|
|
198
|
+
code: "PHANTOM_TRUSTED_TERMINAL_REQUIRED",
|
|
199
|
+
});
|
|
30
200
|
});
|
|
31
201
|
|
|
32
202
|
test("listSecrets returns vault keys", async () => {
|
|
@@ -36,9 +206,57 @@ describe("phantom wrapper against the fake harness", () => {
|
|
|
36
206
|
expect(keys).toContain("ANOTHER");
|
|
37
207
|
});
|
|
38
208
|
|
|
39
|
-
test("removeSecret
|
|
40
|
-
await removeSecret("EXISTING_KEY");
|
|
41
|
-
|
|
42
|
-
|
|
209
|
+
test("removeSecret without a terminal reports false and keeps the secret", async () => {
|
|
210
|
+
expect(await removeSecret("EXISTING_KEY")).toBe(false);
|
|
211
|
+
expect(await listSecrets()).toContain("EXISTING_KEY");
|
|
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);
|
|
43
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
|
+
);
|
|
44
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
|
+
});
|