setup-agent 0.0.6 → 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/README.md +3 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +107 -8
- package/dist/cli.js.map +1 -1
- package/dist/client.js +1 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +4 -2
- package/dist/host.js.map +1 -1
- package/dist/presets.js +1 -2
- package/dist/protocol.d.ts +9 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -2
- package/dist/protocol.js.map +1 -1
- package/package.json +3 -3
- package/src/cli.test.ts +403 -0
- package/src/cli.ts +205 -4
- package/src/host.test.ts +104 -0
- package/src/host.ts +18 -0
- package/src/protocol.ts +9 -3
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 {
|
|
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
|
-
|
|
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
|
|
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
|
}
|