setup-agent 0.0.5 → 0.0.7

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,6 +20,7 @@ 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,
@@ -23,6 +31,15 @@ import {
23
31
  writeEnvSecret,
24
32
  } from "./cli";
25
33
 
34
+ const { connectCheckoutMock } = vi.hoisted(() => ({
35
+ connectCheckoutMock: vi.fn<typeof connectCheckout>(),
36
+ }));
37
+
38
+ vi.mock("./client", async (importOriginal) => ({
39
+ ...(await importOriginal<typeof import("./client")>()),
40
+ connectCheckout: connectCheckoutMock,
41
+ }));
42
+
26
43
  describe("the agent's briefing", () => {
27
44
  it("preserves the published instructions export and setup-created event wording", () => {
28
45
  expect(INSTRUCTIONS).toBe(agentInstructions);
@@ -571,6 +588,38 @@ describe("writeEnvSecret", () => {
571
588
  expect(take).not.toHaveBeenCalled();
572
589
  });
573
590
 
591
+ it("refuses a symbolic link without taking the secret or changing its target", async () => {
592
+ const dir = await tempDir();
593
+ const target = join(dir, "target");
594
+ const file = join(dir, ".env.local");
595
+ await writeFile(target, "ORIGINAL=1\n");
596
+ await chmod(target, 0o644);
597
+ await symlink(target, file);
598
+ const take = vi.fn(async () => "sk-test");
599
+ await expect(writeEnvSecret(file, "OPENAI_API_KEY", take)).rejects.toThrow(
600
+ `${file} is a symbolic link; pass --file with its target to write there`,
601
+ );
602
+ expect(take).not.toHaveBeenCalled();
603
+ expect(await readFile(target, "utf8")).toBe("ORIGINAL=1\n");
604
+ expect((await stat(target)).mode & 0o777).toBe(0o644);
605
+ });
606
+
607
+ it("refuses a parent directory linked outside the project", async () => {
608
+ const project = await tempDir();
609
+ const outside = await tempDir();
610
+ await symlink(outside, join(project, "config"), "dir");
611
+ const take = vi.fn(async () => "sk-test");
612
+ await expect(
613
+ writeEnvSecret(
614
+ join(project, "config", ".env.local"),
615
+ "OPENAI_API_KEY",
616
+ take,
617
+ project,
618
+ ),
619
+ ).rejects.toThrow("outside the project");
620
+ expect(take).not.toHaveBeenCalled();
621
+ });
622
+
574
623
  it.skipIf(process.platform === "win32")(
575
624
  "creates the file readable only by its owner",
576
625
  async () => {
@@ -613,3 +662,357 @@ describe("writeEnvSecret", () => {
613
662
  );
614
663
  });
615
664
  });
665
+
666
+ describe("model key handoff", () => {
667
+ const fixture = async (stateHomePath?: (root: string) => Promise<string>) => {
668
+ const root = await mkdtemp(join(tmpdir(), "setup-agent-state-"));
669
+ const stateHome = stateHomePath
670
+ ? await stateHomePath(root)
671
+ : await realpath(root);
672
+ const stateHomeVariable =
673
+ process.platform === "win32" ? "LOCALAPPDATA" : "XDG_STATE_HOME";
674
+ const prior = process.env[stateHomeVariable];
675
+ process.env[stateHomeVariable] = stateHome;
676
+ const state: Checkout.State = {
677
+ ...initialCheckoutState(),
678
+ id: "setup-a",
679
+ createdAt: 1,
680
+ status: "planning",
681
+ };
682
+ const ask = vi.fn(async (seed: Checkout.InputSeed) => {
683
+ state.inputs.push({
684
+ ...seed,
685
+ id: "q1",
686
+ phase: "planning",
687
+ kind: "model",
688
+ optional: false,
689
+ status: "open",
690
+ createdAt: 2,
691
+ });
692
+ return { inputId: "q1" };
693
+ });
694
+ const client = { state, commands: { "agent/ask": ask }, dispose: vi.fn() };
695
+ connectCheckoutMock.mockResolvedValue(
696
+ client as unknown as Awaited<ReturnType<typeof connectCheckout>>,
697
+ );
698
+ const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
699
+ onTestFinished(async () => {
700
+ stdout.mockRestore();
701
+ connectCheckoutMock.mockReset();
702
+ vi.unstubAllGlobals();
703
+ vi.unstubAllEnvs();
704
+ if (prior === undefined) delete process.env[stateHomeVariable];
705
+ else process.env[stateHomeVariable] = prior;
706
+ await rm(root, { recursive: true, force: true });
707
+ });
708
+ return { state, ask, stateHome, root };
709
+ };
710
+
711
+ const setup = async (stateHomePath?: (root: string) => Promise<string>) => {
712
+ const context = await fixture(stateHomePath);
713
+ await main([
714
+ "https://checkout.test/session",
715
+ "ask",
716
+ "--preset",
717
+ "llm-provider",
718
+ ]);
719
+ const directory = join(context.stateHome, "setup-agent");
720
+ const files = await readdir(directory);
721
+ const keyFile = join(directory, files[0]!);
722
+ const redeemKey = await readFile(keyFile, "utf8");
723
+ return { ...context, directory, files, keyFile, redeemKey };
724
+ };
725
+
726
+ const answerModel = (state: Checkout.State) => {
727
+ state.inputs[0]!.status = "answered";
728
+ state.inputs[0]!.answer = JSON.stringify({
729
+ provider: "openai",
730
+ model: "gpt-test",
731
+ });
732
+ };
733
+
734
+ const fallbackDirectory = async () => {
735
+ const root = await mkdtemp(join(tmpdir(), "setup-agent-fallback-"));
736
+ for (const variable of ["TMPDIR", "TMP", "TEMP"])
737
+ vi.stubEnv(variable, root);
738
+ onTestFinished(() => rm(root, { recursive: true, force: true }));
739
+ return join(
740
+ root,
741
+ `setup-agent-${process.platform === "win32" ? (await import("node:os")).userInfo().username : process.getuid!()}`,
742
+ );
743
+ };
744
+
745
+ const redeem = async (state: Checkout.State, url: string) => {
746
+ answerModel(state);
747
+ const fetch = vi.fn(
748
+ async (_input: string, _init?: RequestInit) =>
749
+ new Response("provider-key"),
750
+ );
751
+ vi.stubGlobal("fetch", fetch);
752
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-env-"));
753
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
754
+ const file = join(directory, ".env.local");
755
+ await main([url, "env", "q1", "OPENAI_API_KEY", "--file", file]);
756
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=provider-key\n");
757
+ return fetch;
758
+ };
759
+
760
+ it.skipIf(process.platform === "win32")(
761
+ "stores the model redeem key privately under the user state home",
762
+ async () => {
763
+ const { ask, directory, files, keyFile, redeemKey } = await setup();
764
+ expect(files).toHaveLength(1);
765
+ expect((await stat(directory)).mode & 0o777).toBe(0o700);
766
+ expect((await stat(keyFile)).mode & 0o777).toBe(0o600);
767
+ expect(redeemKey).toMatch(/^[0-9a-f]{64}$/);
768
+ expect(ask.mock.calls[0]?.[0].redeemKeyHash).toBe(
769
+ createHash("sha256").update(redeemKey).digest("hex"),
770
+ );
771
+ expect(files).toEqual([`${ask.mock.calls[0]?.[0].redeemKeyHash}.key`]);
772
+ },
773
+ );
774
+
775
+ it.skipIf(process.platform === "win32")(
776
+ "stores and redeems through a symlinked state home ancestor",
777
+ async () => {
778
+ const { state, keyFile, redeemKey } = await setup(async (root) => {
779
+ await mkdir(join(root, "real", "state"), { recursive: true });
780
+ await symlink(join(root, "real"), join(root, "link"));
781
+ return join(root, "link", "state");
782
+ });
783
+ const fetch = await redeem(state, "https://checkout.test/session");
784
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
785
+ headers: { "Setup-Agent-Redeem-Key": redeemKey },
786
+ });
787
+ expect(await readdir(join(keyFile, ".."))).toEqual([]);
788
+ },
789
+ );
790
+
791
+ it.skipIf(process.platform === "win32")(
792
+ "rejects a symlinked setup-agent directory and uses the temp fallback",
793
+ async () => {
794
+ const { state, ask, stateHome, root } = await fixture();
795
+ const target = join(root, "target");
796
+ await mkdir(target);
797
+ await symlink(target, join(stateHome, "setup-agent"));
798
+ const fallback = await fallbackDirectory();
799
+ await main([
800
+ "https://checkout.test/session",
801
+ "ask",
802
+ "--preset",
803
+ "llm-provider",
804
+ ]);
805
+ const hash = ask.mock.calls[0]?.[0].redeemKeyHash;
806
+ expect(await readdir(target)).toEqual([]);
807
+ expect(await readdir(fallback)).toEqual([`${hash}.key`]);
808
+ const key = await readFile(join(fallback, `${hash}.key`), "utf8");
809
+ const fetch = await redeem(state, "https://checkout.test/session");
810
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
811
+ headers: { "Setup-Agent-Redeem-Key": key },
812
+ });
813
+ expect(await readdir(fallback)).toEqual([]);
814
+ },
815
+ );
816
+
817
+ it("refuses a model question when neither directory can store its key", async () => {
818
+ const { ask, stateHome } = await fixture();
819
+ const fallback = await fallbackDirectory();
820
+ await writeFile(join(stateHome, "setup-agent"), "occupied");
821
+ await writeFile(fallback, "occupied");
822
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
823
+ throw new Error("exit");
824
+ });
825
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
826
+ onTestFinished(() => {
827
+ exit.mockRestore();
828
+ stderr.mockRestore();
829
+ });
830
+
831
+ await expect(
832
+ main([
833
+ "https://checkout.test/session",
834
+ "ask",
835
+ "--preset",
836
+ "llm-provider",
837
+ ]),
838
+ ).rejects.toThrow("exit");
839
+
840
+ expect(ask).not.toHaveBeenCalled();
841
+ expect(String(stderr.mock.calls[0]?.[0])).toMatch(
842
+ /^setup-agent: could not store the model redeem key: /,
843
+ );
844
+ });
845
+
846
+ it("removes a stored key when agent/ask fails", async () => {
847
+ const { ask, stateHome } = await fixture();
848
+ let storedKey: string | undefined;
849
+ ask.mockImplementation(async (seed) => {
850
+ storedKey = await readFile(
851
+ join(stateHome, "setup-agent", `${seed.redeemKeyHash}.key`),
852
+ "utf8",
853
+ );
854
+ throw new Error("ask rejected");
855
+ });
856
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
857
+ throw new Error("exit");
858
+ });
859
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
860
+ onTestFinished(() => {
861
+ exit.mockRestore();
862
+ stderr.mockRestore();
863
+ });
864
+ await expect(
865
+ main([
866
+ "https://checkout.test/session",
867
+ "ask",
868
+ "--preset",
869
+ "llm-provider",
870
+ ]),
871
+ ).rejects.toThrow("exit");
872
+ expect(storedKey).toMatch(/^[0-9a-f]{64}$/);
873
+ expect(await readdir(join(stateHome, "setup-agent"))).toEqual([]);
874
+ });
875
+
876
+ it("pairs ask and env across a trailing slash in the endpoint", async () => {
877
+ const { state, redeemKey, keyFile } = await setup();
878
+ const fetch = await redeem(state, "https://checkout.test/session/");
879
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
880
+ headers: { "Setup-Agent-Redeem-Key": redeemKey },
881
+ });
882
+ expect(await readdir(join(keyFile, ".."))).toEqual([]);
883
+ });
884
+
885
+ it("succeeds when the key file disappears before env cleanup", async () => {
886
+ const { state, keyFile } = await setup();
887
+ answerModel(state);
888
+ vi.stubGlobal(
889
+ "fetch",
890
+ vi.fn(async () => {
891
+ await unlink(keyFile);
892
+ return new Response("provider-key");
893
+ }),
894
+ );
895
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-env-"));
896
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
897
+ const file = join(directory, ".env.local");
898
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
899
+ onTestFinished(() => stderr.mockRestore());
900
+ await main([
901
+ "https://checkout.test/session",
902
+ "env",
903
+ "q1",
904
+ "OPENAI_API_KEY",
905
+ "--file",
906
+ file,
907
+ ]);
908
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=provider-key\n");
909
+ expect(stderr).not.toHaveBeenCalled();
910
+ });
911
+
912
+ it("sends the stored key on env redemption and removes it after writing", async () => {
913
+ const { state, keyFile, redeemKey } = await setup();
914
+ state.inputs[0]!.status = "answered";
915
+ state.inputs[0]!.answer = JSON.stringify({
916
+ provider: "openai",
917
+ model: "gpt-test",
918
+ });
919
+ const fetch = vi.fn(
920
+ async (_input: string, _init?: RequestInit) =>
921
+ new Response("provider-key"),
922
+ );
923
+ vi.stubGlobal("fetch", fetch);
924
+ const file = join(
925
+ await mkdtemp(join(tmpdir(), "setup-agent-env-")),
926
+ ".env.local",
927
+ );
928
+ onTestFinished(() =>
929
+ rm(join(file, ".."), { recursive: true, force: true }),
930
+ );
931
+ await main([
932
+ "https://checkout.test/session",
933
+ "env",
934
+ "q1",
935
+ "OPENAI_API_KEY",
936
+ "--file",
937
+ file,
938
+ ]);
939
+ expect(fetch.mock.calls[0]?.[1]).toEqual({
940
+ headers: { "Setup-Agent-Redeem-Key": redeemKey },
941
+ });
942
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=provider-key\n");
943
+ expect(await readdir(join(keyFile, ".."))).toEqual([]);
944
+ });
945
+
946
+ it("keeps the stored key after a forbidden redemption", async () => {
947
+ const { state, keyFile, redeemKey } = await setup();
948
+ state.inputs[0]!.status = "answered";
949
+ state.inputs[0]!.answer = JSON.stringify({
950
+ provider: "openai",
951
+ model: "gpt-test",
952
+ });
953
+ vi.stubGlobal(
954
+ "fetch",
955
+ vi.fn(async () => new Response(null, { status: 403 })),
956
+ );
957
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
958
+ throw new Error("exit");
959
+ });
960
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
961
+ onTestFinished(() => {
962
+ exit.mockRestore();
963
+ stderr.mockRestore();
964
+ });
965
+ const file = join(
966
+ await mkdtemp(join(tmpdir(), "setup-agent-env-")),
967
+ ".env.local",
968
+ );
969
+ onTestFinished(() =>
970
+ rm(join(file, ".."), { recursive: true, force: true }),
971
+ );
972
+ await expect(
973
+ main([
974
+ "https://checkout.test/session",
975
+ "env",
976
+ "q1",
977
+ "OPENAI_API_KEY",
978
+ "--file",
979
+ file,
980
+ ]),
981
+ ).rejects.toThrow("exit");
982
+ expect(stderr).toHaveBeenCalledWith(
983
+ expect.stringContaining('redeem key rejected for "q1"'),
984
+ );
985
+ expect(await readFile(keyFile, "utf8")).toBe(redeemKey);
986
+ });
987
+
988
+ it.skipIf(process.platform === "win32")(
989
+ "refuses a symbolic link for the stored key",
990
+ async () => {
991
+ const { state, keyFile } = await setup();
992
+ state.inputs[0]!.status = "answered";
993
+ state.inputs[0]!.answer = JSON.stringify({
994
+ provider: "openai",
995
+ model: "gpt-test",
996
+ });
997
+ const target = join(keyFile, "..", "other-key");
998
+ await writeFile(target, "other-value");
999
+ await unlink(keyFile);
1000
+ await symlink(target, keyFile);
1001
+ const fetch = vi.fn();
1002
+ vi.stubGlobal("fetch", fetch);
1003
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
1004
+ throw new Error("exit");
1005
+ });
1006
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1007
+ onTestFinished(() => {
1008
+ exit.mockRestore();
1009
+ stderr.mockRestore();
1010
+ });
1011
+ await expect(
1012
+ main(["https://checkout.test/session", "env", "q1", "OPENAI_API_KEY"]),
1013
+ ).rejects.toThrow("exit");
1014
+ expect(fetch).not.toHaveBeenCalled();
1015
+ expect(await readFile(target, "utf8")).toBe("other-value");
1016
+ },
1017
+ );
1018
+ });
package/src/cli.ts CHANGED
@@ -2,9 +2,20 @@ import { connectCheckout } from "./client";
2
2
  import type { CheckoutClient } from "./client";
3
3
  import { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
4
4
  import { spawn } from "node:child_process";
5
- import { open, readFile } from "node:fs/promises";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { constants } from "node:fs";
7
+ import {
8
+ chmod,
9
+ lstat,
10
+ mkdir,
11
+ open,
12
+ readFile,
13
+ realpath,
14
+ unlink,
15
+ } from "node:fs/promises";
16
+ import { homedir, tmpdir, userInfo } from "node:os";
6
17
  import { createInterface } from "node:readline/promises";
7
- import { resolve } from "node:path";
18
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
19
  import {
9
20
  AGENT_HEARTBEAT_MS,
10
21
  OPTION_ICONS,
@@ -664,9 +675,49 @@ export const writeEnvSecret = async (
664
675
  file: string,
665
676
  key: string,
666
677
  take: () => Promise<string>,
678
+ projectRoot = process.cwd(),
667
679
  ) => {
668
680
  assertEnvKey(key);
669
- const handle = await open(file, "a+", 0o600);
681
+ try {
682
+ if ((await lstat(file)).isSymbolicLink()) {
683
+ throw new Error(
684
+ `${file} is a symbolic link; pass --file with its target to write there`,
685
+ );
686
+ }
687
+ } catch (error) {
688
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
689
+ }
690
+ const projectRelative = relative(projectRoot, file);
691
+ if (
692
+ projectRelative !== ".." &&
693
+ !projectRelative.startsWith(`..${sep}`) &&
694
+ !isAbsolute(projectRelative)
695
+ ) {
696
+ const root = await realpath(projectRoot);
697
+ const parent = await realpath(dirname(file));
698
+ const parentRelative = relative(root, parent);
699
+ if (
700
+ parentRelative === ".." ||
701
+ parentRelative.startsWith(`..${sep}`) ||
702
+ isAbsolute(parentRelative)
703
+ ) {
704
+ throw new Error(`${file} has a parent directory outside the project`);
705
+ }
706
+ }
707
+ const handle = await open(
708
+ file,
709
+ constants.O_RDWR |
710
+ constants.O_APPEND |
711
+ constants.O_CREAT |
712
+ constants.O_NOFOLLOW,
713
+ 0o600,
714
+ ).catch((error: NodeJS.ErrnoException) => {
715
+ if (error.code === "ELOOP")
716
+ throw new Error(
717
+ `${file} is a symbolic link; pass --file with its target to write there`,
718
+ );
719
+ throw error;
720
+ });
670
721
  try {
671
722
  await handle.chmod(0o600);
672
723
  const value = await take();
@@ -678,6 +729,109 @@ export const writeEnvSecret = async (
678
729
  }
679
730
  };
680
731
 
732
+ const redeemKeyDirectories = () => {
733
+ const stateHome =
734
+ process.platform === "win32"
735
+ ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
736
+ : process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
737
+ return [
738
+ join(stateHome, "setup-agent"),
739
+ join(
740
+ tmpdir(),
741
+ `setup-agent-${process.platform === "win32" ? userInfo().username : process.getuid!()}`,
742
+ ),
743
+ ];
744
+ };
745
+
746
+ const checkRedeemKeyDirectory = async (directory: string) => {
747
+ if (!isAbsolute(directory)) {
748
+ throw new Error("state home must be an absolute path");
749
+ }
750
+ const info = await lstat(directory);
751
+ if (!info.isDirectory() || info.isSymbolicLink()) {
752
+ throw new Error(
753
+ `redeem key directory must not be a symbolic link: ${directory}`,
754
+ );
755
+ }
756
+ if (process.platform !== "win32") {
757
+ if (info.uid !== process.getuid!()) {
758
+ throw new Error(
759
+ `redeem key directory must be owned by the user: ${directory}`,
760
+ );
761
+ }
762
+ if ((info.mode & 0o077) !== 0) await chmod(directory, 0o700);
763
+ }
764
+ };
765
+
766
+ const prepareRedeemKeyDirectory = async (directory: string) => {
767
+ if (isAbsolute(directory)) {
768
+ await mkdir(directory, { recursive: true, mode: 0o700 });
769
+ }
770
+ await checkRedeemKeyDirectory(directory);
771
+ };
772
+
773
+ const saveRedeemKey = async (hash: string, key: string) => {
774
+ let lastError: unknown;
775
+ for (const directory of redeemKeyDirectories()) {
776
+ const file = join(directory, `${hash}.key`);
777
+ let created = false;
778
+ try {
779
+ await prepareRedeemKeyDirectory(directory);
780
+ const handle = await open(
781
+ file,
782
+ constants.O_WRONLY |
783
+ constants.O_CREAT |
784
+ constants.O_EXCL |
785
+ constants.O_NOFOLLOW,
786
+ 0o600,
787
+ );
788
+ created = true;
789
+ try {
790
+ await handle.writeFile(key);
791
+ } finally {
792
+ await handle.close();
793
+ }
794
+ return file;
795
+ } catch (error) {
796
+ if (created) await unlink(file).catch(() => {});
797
+ lastError = error;
798
+ }
799
+ }
800
+ throw lastError;
801
+ };
802
+
803
+ const readRedeemKey = async (hash: string) => {
804
+ if (!/^[0-9a-f]{64}$/.test(hash)) return undefined;
805
+ for (const directory of redeemKeyDirectories()) {
806
+ try {
807
+ await checkRedeemKeyDirectory(directory);
808
+ const file = join(directory, `${hash}.key`);
809
+ const handle = await open(
810
+ file,
811
+ constants.O_RDONLY | constants.O_NOFOLLOW,
812
+ );
813
+ try {
814
+ const info = await handle.stat();
815
+ if (
816
+ !info.isFile() ||
817
+ (process.platform !== "win32" && (info.mode & 0o077) !== 0)
818
+ ) {
819
+ throw new Error("redeem key file must be private and regular");
820
+ }
821
+ const key = await handle.readFile("utf8");
822
+ if (createHash("sha256").update(key).digest("hex") === hash) {
823
+ return { key, file };
824
+ }
825
+ } finally {
826
+ await handle.close();
827
+ }
828
+ } catch {
829
+ continue;
830
+ }
831
+ }
832
+ return undefined;
833
+ };
834
+
681
835
  const answerFields = (input: Checkout.Input, answer: string) => ({
682
836
  answer: (input.multiple && parseMultipleAnswer(answer)) || answer,
683
837
  ...(input.note !== undefined && { note: input.note }),
@@ -775,7 +929,32 @@ export const main = async (argv: readonly string[]) => {
775
929
  break;
776
930
  }
777
931
  case "ask": {
778
- const { inputId } = await commands["agent/ask"](askSeed(rest, flags));
932
+ const seed = askSeed(rest, flags);
933
+ const redeemKey =
934
+ seed.kind === "model" ? randomBytes(32).toString("hex") : undefined;
935
+ const redeemKeyHash =
936
+ redeemKey === undefined
937
+ ? undefined
938
+ : createHash("sha256").update(redeemKey).digest("hex");
939
+ const keyFile =
940
+ redeemKey === undefined || redeemKeyHash === undefined
941
+ ? undefined
942
+ : await saveRedeemKey(redeemKeyHash, redeemKey).catch(
943
+ (error: unknown) =>
944
+ fail(
945
+ `could not store the model redeem key: ${error instanceof Error ? error.message : String(error)}`,
946
+ ),
947
+ );
948
+ let inputId: string;
949
+ try {
950
+ ({ inputId } = await commands["agent/ask"]({
951
+ ...seed,
952
+ ...(redeemKeyHash !== undefined && { redeemKeyHash }),
953
+ }));
954
+ } catch (error) {
955
+ if (keyFile !== undefined) await unlink(keyFile).catch(() => {});
956
+ throw error;
957
+ }
779
958
  if (!flags.has("wait")) {
780
959
  print({ ok: true, inputId });
781
960
  break;
@@ -835,11 +1014,24 @@ export const main = async (argv: readonly string[]) => {
835
1014
  ) {
836
1015
  fail(`input "${inputId}" is not an llm-provider answer`);
837
1016
  }
1017
+ const storedKey =
1018
+ input.redeemKeyHash === undefined
1019
+ ? undefined
1020
+ : await readRedeemKey(input.redeemKeyHash);
1021
+ if (input.redeemKeyHash !== undefined && storedKey === undefined) {
1022
+ fail(`no local redeem key for "${inputId}"`);
1023
+ }
838
1024
  const file = resolve(flagText(flags, "file") ?? ".env.local");
839
1025
  await writeEnvSecret(file, envKey, async () => {
840
1026
  const response = await fetch(
841
1027
  `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`,
1028
+ storedKey === undefined
1029
+ ? undefined
1030
+ : { headers: { "Setup-Agent-Redeem-Key": storedKey.key } },
842
1031
  );
1032
+ if (response.status === 403) {
1033
+ fail(`redeem key rejected for "${inputId}"`);
1034
+ }
843
1035
  if (response.status === 404) {
844
1036
  fail(
845
1037
  `the key for "${inputId}" was already taken or never deposited`,
@@ -848,6 +1040,15 @@ export const main = async (argv: readonly string[]) => {
848
1040
  if (!response.ok) fail(`could not fetch the key: ${response.status}`);
849
1041
  return response.text();
850
1042
  });
1043
+ if (storedKey !== undefined) {
1044
+ await unlink(storedKey.file).catch((error: NodeJS.ErrnoException) => {
1045
+ if (error.code !== "ENOENT") {
1046
+ process.stderr.write(
1047
+ `warning: could not remove a redeem key: ${error.message}\n`,
1048
+ );
1049
+ }
1050
+ });
1051
+ }
851
1052
  print({ ok: true, envKey, file });
852
1053
  break;
853
1054
  }