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/README.md +3 -1
- package/dist/cli.d.ts +10 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +153 -15
- 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 +11 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +5 -4
- package/dist/protocol.js.map +1 -1
- package/package.json +4 -4
- package/src/cli.test.ts +586 -0
- package/src/cli.ts +295 -11
- package/src/host.test.ts +104 -0
- package/src/host.ts +18 -0
- package/src/protocol.ts +14 -6
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,
|
|
@@ -12,6 +23,7 @@ import {
|
|
|
12
23
|
currentPlan,
|
|
13
24
|
isAgentPresent,
|
|
14
25
|
isClosed,
|
|
26
|
+
isLoopbackHostname,
|
|
15
27
|
isOptionIcon,
|
|
16
28
|
isValidModelAnswer,
|
|
17
29
|
parseEntryPointOptions,
|
|
@@ -54,9 +66,56 @@ Commands (the url always comes first):
|
|
|
54
66
|
Mark every remaining step done and propose closing; the user closes it.
|
|
55
67
|
`;
|
|
56
68
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
69
|
+
/** Characters a setup URL may hold: an origin and a path, nothing a shell or a reader treats as more than an address. */
|
|
70
|
+
const SETUP_URL_PATTERN =
|
|
71
|
+
/^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$|^http:\/\/\[::1\](?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The normalized setup URL, or an error saying why it is refused. A setup URL
|
|
75
|
+
* is printed into shell commands and the coding agent's prompt, and the API
|
|
76
|
+
* key travels over it, so it must be https (http only on this machine), carry
|
|
77
|
+
* no credentials, query or fragment, and hold only address characters.
|
|
78
|
+
*/
|
|
79
|
+
export const parseSetupUrl = (value: string): string => {
|
|
80
|
+
let url: URL;
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(value);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(`"${value}" is not a URL`);
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
url.protocol !== "https:" &&
|
|
88
|
+
!(url.protocol === "http:" && isLoopbackHostname(url.hostname))
|
|
89
|
+
) {
|
|
90
|
+
throw new Error("the setup URL must use https (http only on localhost)");
|
|
91
|
+
}
|
|
92
|
+
if (url.username !== "" || url.password !== "") {
|
|
93
|
+
throw new Error("the setup URL must not carry credentials");
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
url.search !== "" ||
|
|
97
|
+
url.hash !== "" ||
|
|
98
|
+
value.includes("?") ||
|
|
99
|
+
value.includes("#")
|
|
100
|
+
) {
|
|
101
|
+
throw new Error("the setup URL must not have a query or fragment");
|
|
102
|
+
}
|
|
103
|
+
const href = url.href.replace(/\/$/, "");
|
|
104
|
+
if (!SETUP_URL_PATTERN.test(href)) {
|
|
105
|
+
throw new Error("the setup URL has characters a setup URL does not use");
|
|
106
|
+
}
|
|
107
|
+
return href;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** `value` as one shell word, quoted only when it holds more than plain URL characters. */
|
|
111
|
+
const shellWord = (value: string) =>
|
|
112
|
+
/^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)
|
|
113
|
+
? value
|
|
114
|
+
: `'${value.replaceAll("'", `'\\''`)}'`;
|
|
115
|
+
|
|
116
|
+
export const agentInstructions = (setupUrl: string) => {
|
|
117
|
+
const url = shellWord(setupUrl);
|
|
118
|
+
return `You are the coding agent for a setup the user opened in their browser. The browser shows this setup live; every command below updates it. The user watches the browser, not this terminal: once setup begins, send questions through "ask" so the user can answer in their browser.
|
|
60
119
|
|
|
61
120
|
First, start a persistent Monitor on
|
|
62
121
|
npx setup-agent ${url} stream
|
|
@@ -99,6 +158,7 @@ Before that, start the project's dev server in the background when the project h
|
|
|
99
158
|
npx setup-agent ${url} wait start
|
|
100
159
|
which returns once that setup begins, then run "status" for its products, guides and instructions and go through plan and install again. A "setup.created" event while you are still working means the user replaced this setup: drop what you were doing and start over the same way.
|
|
101
160
|
`;
|
|
161
|
+
};
|
|
102
162
|
|
|
103
163
|
export const INSTRUCTIONS = agentInstructions;
|
|
104
164
|
|
|
@@ -231,7 +291,7 @@ const chooseLauncher = async () => {
|
|
|
231
291
|
|
|
232
292
|
const launchAgent = async (url: string) => {
|
|
233
293
|
const launcher = await chooseLauncher();
|
|
234
|
-
const prompt = `Run \`npx setup-agent ${url}\` and follow the instructions it prints.`;
|
|
294
|
+
const prompt = `Run \`npx setup-agent ${shellWord(url)}\` and follow the instructions it prints.`;
|
|
235
295
|
process.stdout.write(`\nStarting ${launcher.name}...\n\n`);
|
|
236
296
|
const child = spawn(launcher.command, launcher.args(prompt), {
|
|
237
297
|
stdio: "inherit",
|
|
@@ -643,6 +703,16 @@ const assertEnvKey = (key: string) => {
|
|
|
643
703
|
/** Sets `key=value` in dotenv text, replacing an existing line for the key. */
|
|
644
704
|
export const upsertEnvLine = (content: string, key: string, value: string) => {
|
|
645
705
|
assertEnvKey(key);
|
|
706
|
+
// A line break or other control character would end the line and let the
|
|
707
|
+
// value write lines of its own.
|
|
708
|
+
const isLineBreaking = (code: number) =>
|
|
709
|
+
code < 0x20 ||
|
|
710
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
711
|
+
code === 0x2028 ||
|
|
712
|
+
code === 0x2029;
|
|
713
|
+
if ([...value].some((char) => isLineBreaking(char.charCodeAt(0)))) {
|
|
714
|
+
throw new Error(`the value for ${key} must be a single line`);
|
|
715
|
+
}
|
|
646
716
|
const line = `${key}=${value}`;
|
|
647
717
|
const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
|
|
648
718
|
const index = lines.findIndex(
|
|
@@ -664,20 +734,165 @@ export const writeEnvSecret = async (
|
|
|
664
734
|
file: string,
|
|
665
735
|
key: string,
|
|
666
736
|
take: () => Promise<string>,
|
|
737
|
+
projectRoot = process.cwd(),
|
|
667
738
|
) => {
|
|
668
739
|
assertEnvKey(key);
|
|
669
|
-
|
|
740
|
+
try {
|
|
741
|
+
if ((await lstat(file)).isSymbolicLink()) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`${file} is a symbolic link; pass --file with its target to write there`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
748
|
+
}
|
|
749
|
+
const projectRelative = relative(projectRoot, file);
|
|
750
|
+
if (
|
|
751
|
+
projectRelative !== ".." &&
|
|
752
|
+
!projectRelative.startsWith(`..${sep}`) &&
|
|
753
|
+
!isAbsolute(projectRelative)
|
|
754
|
+
) {
|
|
755
|
+
const root = await realpath(projectRoot);
|
|
756
|
+
const parent = await realpath(dirname(file));
|
|
757
|
+
const parentRelative = relative(root, parent);
|
|
758
|
+
if (
|
|
759
|
+
parentRelative === ".." ||
|
|
760
|
+
parentRelative.startsWith(`..${sep}`) ||
|
|
761
|
+
isAbsolute(parentRelative)
|
|
762
|
+
) {
|
|
763
|
+
throw new Error(`${file} has a parent directory outside the project`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const handle = await open(
|
|
767
|
+
file,
|
|
768
|
+
constants.O_RDWR |
|
|
769
|
+
constants.O_APPEND |
|
|
770
|
+
constants.O_CREAT |
|
|
771
|
+
constants.O_NOFOLLOW,
|
|
772
|
+
0o600,
|
|
773
|
+
).catch((error: NodeJS.ErrnoException) => {
|
|
774
|
+
if (error.code === "ELOOP")
|
|
775
|
+
throw new Error(
|
|
776
|
+
`${file} is a symbolic link; pass --file with its target to write there`,
|
|
777
|
+
);
|
|
778
|
+
throw error;
|
|
779
|
+
});
|
|
670
780
|
try {
|
|
671
781
|
await handle.chmod(0o600);
|
|
672
782
|
const value = await take();
|
|
673
783
|
const current = await handle.readFile("utf8");
|
|
784
|
+
// Built before truncating, so a refused value leaves the file intact.
|
|
785
|
+
const next = upsertEnvLine(current, key, value);
|
|
674
786
|
await handle.truncate(0);
|
|
675
|
-
await handle.writeFile(
|
|
787
|
+
await handle.writeFile(next);
|
|
676
788
|
} finally {
|
|
677
789
|
await handle.close();
|
|
678
790
|
}
|
|
679
791
|
};
|
|
680
792
|
|
|
793
|
+
const redeemKeyDirectories = () => {
|
|
794
|
+
const stateHome =
|
|
795
|
+
process.platform === "win32"
|
|
796
|
+
? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
|
|
797
|
+
: process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
798
|
+
return [
|
|
799
|
+
join(stateHome, "setup-agent"),
|
|
800
|
+
join(
|
|
801
|
+
tmpdir(),
|
|
802
|
+
`setup-agent-${process.platform === "win32" ? userInfo().username : process.getuid!()}`,
|
|
803
|
+
),
|
|
804
|
+
];
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const checkRedeemKeyDirectory = async (directory: string) => {
|
|
808
|
+
if (!isAbsolute(directory)) {
|
|
809
|
+
throw new Error("state home must be an absolute path");
|
|
810
|
+
}
|
|
811
|
+
const info = await lstat(directory);
|
|
812
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
813
|
+
throw new Error(
|
|
814
|
+
`redeem key directory must not be a symbolic link: ${directory}`,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (process.platform !== "win32") {
|
|
818
|
+
if (info.uid !== process.getuid!()) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
`redeem key directory must be owned by the user: ${directory}`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if ((info.mode & 0o077) !== 0) await chmod(directory, 0o700);
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const prepareRedeemKeyDirectory = async (directory: string) => {
|
|
828
|
+
if (isAbsolute(directory)) {
|
|
829
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
830
|
+
}
|
|
831
|
+
await checkRedeemKeyDirectory(directory);
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const saveRedeemKey = async (hash: string, key: string) => {
|
|
835
|
+
let lastError: unknown;
|
|
836
|
+
for (const directory of redeemKeyDirectories()) {
|
|
837
|
+
const file = join(directory, `${hash}.key`);
|
|
838
|
+
let created = false;
|
|
839
|
+
try {
|
|
840
|
+
await prepareRedeemKeyDirectory(directory);
|
|
841
|
+
const handle = await open(
|
|
842
|
+
file,
|
|
843
|
+
constants.O_WRONLY |
|
|
844
|
+
constants.O_CREAT |
|
|
845
|
+
constants.O_EXCL |
|
|
846
|
+
constants.O_NOFOLLOW,
|
|
847
|
+
0o600,
|
|
848
|
+
);
|
|
849
|
+
created = true;
|
|
850
|
+
try {
|
|
851
|
+
await handle.writeFile(key);
|
|
852
|
+
} finally {
|
|
853
|
+
await handle.close();
|
|
854
|
+
}
|
|
855
|
+
return file;
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (created) await unlink(file).catch(() => {});
|
|
858
|
+
lastError = error;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
throw lastError;
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const readRedeemKey = async (hash: string) => {
|
|
865
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) return undefined;
|
|
866
|
+
for (const directory of redeemKeyDirectories()) {
|
|
867
|
+
try {
|
|
868
|
+
await checkRedeemKeyDirectory(directory);
|
|
869
|
+
const file = join(directory, `${hash}.key`);
|
|
870
|
+
const handle = await open(
|
|
871
|
+
file,
|
|
872
|
+
constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
873
|
+
);
|
|
874
|
+
try {
|
|
875
|
+
const info = await handle.stat();
|
|
876
|
+
if (
|
|
877
|
+
!info.isFile() ||
|
|
878
|
+
(process.platform !== "win32" && (info.mode & 0o077) !== 0)
|
|
879
|
+
) {
|
|
880
|
+
throw new Error("redeem key file must be private and regular");
|
|
881
|
+
}
|
|
882
|
+
const key = await handle.readFile("utf8");
|
|
883
|
+
if (createHash("sha256").update(key).digest("hex") === hash) {
|
|
884
|
+
return { key, file };
|
|
885
|
+
}
|
|
886
|
+
} finally {
|
|
887
|
+
await handle.close();
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return undefined;
|
|
894
|
+
};
|
|
895
|
+
|
|
681
896
|
const answerFields = (input: Checkout.Input, answer: string) => ({
|
|
682
897
|
answer: (input.multiple && parseMultipleAnswer(answer)) || answer,
|
|
683
898
|
...(input.note !== undefined && { note: input.note }),
|
|
@@ -701,7 +916,12 @@ export const main = async (argv: readonly string[]) => {
|
|
|
701
916
|
process.stdout.write(HELP);
|
|
702
917
|
return process.exit(first === undefined ? 1 : 0);
|
|
703
918
|
}
|
|
704
|
-
|
|
919
|
+
let url: string;
|
|
920
|
+
try {
|
|
921
|
+
url = parseSetupUrl(first);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
924
|
+
}
|
|
705
925
|
if (command === "intro" && isDirectInvocation()) return launchAgent(url);
|
|
706
926
|
|
|
707
927
|
const client = await connectCheckout(url).catch((error: unknown) =>
|
|
@@ -775,7 +995,32 @@ export const main = async (argv: readonly string[]) => {
|
|
|
775
995
|
break;
|
|
776
996
|
}
|
|
777
997
|
case "ask": {
|
|
778
|
-
const
|
|
998
|
+
const seed = askSeed(rest, flags);
|
|
999
|
+
const redeemKey =
|
|
1000
|
+
seed.kind === "model" ? randomBytes(32).toString("hex") : undefined;
|
|
1001
|
+
const redeemKeyHash =
|
|
1002
|
+
redeemKey === undefined
|
|
1003
|
+
? undefined
|
|
1004
|
+
: createHash("sha256").update(redeemKey).digest("hex");
|
|
1005
|
+
const keyFile =
|
|
1006
|
+
redeemKey === undefined || redeemKeyHash === undefined
|
|
1007
|
+
? undefined
|
|
1008
|
+
: await saveRedeemKey(redeemKeyHash, redeemKey).catch(
|
|
1009
|
+
(error: unknown) =>
|
|
1010
|
+
fail(
|
|
1011
|
+
`could not store the model redeem key: ${error instanceof Error ? error.message : String(error)}`,
|
|
1012
|
+
),
|
|
1013
|
+
);
|
|
1014
|
+
let inputId: string;
|
|
1015
|
+
try {
|
|
1016
|
+
({ inputId } = await commands["agent/ask"]({
|
|
1017
|
+
...seed,
|
|
1018
|
+
...(redeemKeyHash !== undefined && { redeemKeyHash }),
|
|
1019
|
+
}));
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
if (keyFile !== undefined) await unlink(keyFile).catch(() => {});
|
|
1022
|
+
throw error;
|
|
1023
|
+
}
|
|
779
1024
|
if (!flags.has("wait")) {
|
|
780
1025
|
print({ ok: true, inputId });
|
|
781
1026
|
break;
|
|
@@ -835,19 +1080,58 @@ export const main = async (argv: readonly string[]) => {
|
|
|
835
1080
|
) {
|
|
836
1081
|
fail(`input "${inputId}" is not an llm-provider answer`);
|
|
837
1082
|
}
|
|
1083
|
+
const storedKey =
|
|
1084
|
+
input.redeemKeyHash === undefined
|
|
1085
|
+
? undefined
|
|
1086
|
+
: await readRedeemKey(input.redeemKeyHash);
|
|
1087
|
+
if (input.redeemKeyHash !== undefined && storedKey === undefined) {
|
|
1088
|
+
fail(`no local redeem key for "${inputId}"`);
|
|
1089
|
+
}
|
|
838
1090
|
const file = resolve(flagText(flags, "file") ?? ".env.local");
|
|
839
1091
|
await writeEnvSecret(file, envKey, async () => {
|
|
1092
|
+
const secretUrl = new URL(url);
|
|
1093
|
+
if (
|
|
1094
|
+
secretUrl.protocol !== "https:" &&
|
|
1095
|
+
!(
|
|
1096
|
+
secretUrl.protocol === "http:" &&
|
|
1097
|
+
isLoopbackHostname(secretUrl.hostname)
|
|
1098
|
+
)
|
|
1099
|
+
) {
|
|
1100
|
+
fail("the key is only fetched over https");
|
|
1101
|
+
}
|
|
840
1102
|
const response = await fetch(
|
|
841
1103
|
`${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`,
|
|
1104
|
+
storedKey === undefined
|
|
1105
|
+
? undefined
|
|
1106
|
+
: { headers: { "Setup-Agent-Redeem-Key": storedKey.key } },
|
|
842
1107
|
);
|
|
1108
|
+
if (response.status === 403) {
|
|
1109
|
+
fail(`redeem key rejected for "${inputId}"`);
|
|
1110
|
+
}
|
|
843
1111
|
if (response.status === 404) {
|
|
844
1112
|
fail(
|
|
845
1113
|
`the key for "${inputId}" was already taken or never deposited`,
|
|
846
1114
|
);
|
|
847
1115
|
}
|
|
848
1116
|
if (!response.ok) fail(`could not fetch the key: ${response.status}`);
|
|
849
|
-
|
|
1117
|
+
const key = await response.text();
|
|
1118
|
+
// An API key is one word of letters, digits and a few separators;
|
|
1119
|
+
// anything else could break out of the env line, be read differently
|
|
1120
|
+
// by dotenv, or run as shell syntax when the file is sourced.
|
|
1121
|
+
if (!/^[A-Za-z0-9._~+/=:@%,-]+$/.test(key)) {
|
|
1122
|
+
fail("the deposited key is not a valid API key");
|
|
1123
|
+
}
|
|
1124
|
+
return key;
|
|
850
1125
|
});
|
|
1126
|
+
if (storedKey !== undefined) {
|
|
1127
|
+
await unlink(storedKey.file).catch((error: NodeJS.ErrnoException) => {
|
|
1128
|
+
if (error.code !== "ENOENT") {
|
|
1129
|
+
process.stderr.write(
|
|
1130
|
+
`warning: could not remove a redeem key: ${error.message}\n`,
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
851
1135
|
print({ ok: true, envKey, file });
|
|
852
1136
|
break;
|
|
853
1137
|
}
|
package/src/host.test.ts
CHANGED
|
@@ -163,6 +163,70 @@ describe("CheckoutHost", () => {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
describe("plans", () => {
|
|
166
|
+
it.each([
|
|
167
|
+
{ decision: "approve" },
|
|
168
|
+
{ decision: "revise", feedback: "Use pnpm" },
|
|
169
|
+
] satisfies Checkout.PlanDecision[])(
|
|
170
|
+
"rejects a $decision decision for a superseded revision",
|
|
171
|
+
async (decision) => {
|
|
172
|
+
const host = mount();
|
|
173
|
+
await host.commands["checkout/create"](seed);
|
|
174
|
+
await host.commands["checkout/begin-plan"]();
|
|
175
|
+
await host.commands["agent/plan"]({ markdown: "Install chat" });
|
|
176
|
+
const pending = { ...decision, id: seed.id, revision: 1 };
|
|
177
|
+
await host.commands["agent/plan"]({ markdown: "Replace the API route" });
|
|
178
|
+
|
|
179
|
+
expect(await reason(host.commands["checkout/plan"](pending))).toEqual({
|
|
180
|
+
reason: "stale-plan",
|
|
181
|
+
});
|
|
182
|
+
expect(currentPlan(host.state)).toMatchObject({
|
|
183
|
+
revision: 2,
|
|
184
|
+
status: "proposed",
|
|
185
|
+
});
|
|
186
|
+
expect(host.state.status).toBe("planning");
|
|
187
|
+
expect(currentPlan(host.state)?.feedback).toBeUndefined();
|
|
188
|
+
|
|
189
|
+
await host.commands["checkout/plan"]({ ...pending, revision: 2 });
|
|
190
|
+
expect(currentPlan(host.state)?.status).toBe(
|
|
191
|
+
decision.decision === "approve" ? "approved" : "changes-requested",
|
|
192
|
+
);
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
it("rejects a decision from an earlier setup with the same plan revision", async () => {
|
|
197
|
+
const host = mount();
|
|
198
|
+
await host.commands["checkout/create"](seed);
|
|
199
|
+
await host.commands["checkout/begin-plan"]();
|
|
200
|
+
await host.commands["agent/plan"]({ markdown: "Install chat" });
|
|
201
|
+
await host.commands["checkout/cancel"]({ id: seed.id });
|
|
202
|
+
await host.commands["checkout/create"]({ ...seed, id: "replacement" });
|
|
203
|
+
await host.commands["checkout/begin-plan"]();
|
|
204
|
+
await host.commands["agent/plan"]({ markdown: "Install cloud" });
|
|
205
|
+
|
|
206
|
+
expect(
|
|
207
|
+
await reason(
|
|
208
|
+
host.commands["checkout/plan"]({
|
|
209
|
+
decision: "approve",
|
|
210
|
+
id: seed.id,
|
|
211
|
+
revision: 1,
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
).toEqual({ reason: "stale-plan" });
|
|
215
|
+
expect(currentPlan(host.state)).toMatchObject({
|
|
216
|
+
revision: 1,
|
|
217
|
+
status: "proposed",
|
|
218
|
+
});
|
|
219
|
+
expect(host.state.status).toBe("planning");
|
|
220
|
+
|
|
221
|
+
await host.commands["checkout/plan"]({
|
|
222
|
+
decision: "approve",
|
|
223
|
+
id: "replacement",
|
|
224
|
+
revision: 1,
|
|
225
|
+
});
|
|
226
|
+
expect(currentPlan(host.state)?.status).toBe("approved");
|
|
227
|
+
expect(host.state.status).toBe("installing");
|
|
228
|
+
});
|
|
229
|
+
|
|
166
230
|
it("refuses steps until the user approves a plan", async () => {
|
|
167
231
|
const host = mount();
|
|
168
232
|
await host.commands["checkout/create"](seed);
|
|
@@ -606,6 +670,46 @@ describe("inputs", () => {
|
|
|
606
670
|
describe("model inputs", () => {
|
|
607
671
|
const answer = { provider: "openai", model: "gpt-test" };
|
|
608
672
|
|
|
673
|
+
it("stores a valid redeem hash only on model inputs", async () => {
|
|
674
|
+
const host = mount();
|
|
675
|
+
await host.commands["checkout/create"](seed);
|
|
676
|
+
await host.commands["checkout/begin-plan"]();
|
|
677
|
+
const hash = "a".repeat(64);
|
|
678
|
+
const { inputId } = await host.commands["agent/ask"]({
|
|
679
|
+
...presetInput("llm-provider"),
|
|
680
|
+
redeemKeyHash: hash,
|
|
681
|
+
});
|
|
682
|
+
expect(
|
|
683
|
+
host.state.inputs.find((input) => input.id === inputId)?.redeemKeyHash,
|
|
684
|
+
).toBe(hash);
|
|
685
|
+
await host.commands["checkout/answer"]({
|
|
686
|
+
inputId,
|
|
687
|
+
answer: JSON.stringify(answer),
|
|
688
|
+
});
|
|
689
|
+
expect(
|
|
690
|
+
host.state.inputs.find((input) => input.id === inputId)?.redeemKeyHash,
|
|
691
|
+
).toBe(hash);
|
|
692
|
+
for (const redeemKeyHash of [
|
|
693
|
+
"A".repeat(64),
|
|
694
|
+
"a".repeat(63),
|
|
695
|
+
"not-a-hash",
|
|
696
|
+
]) {
|
|
697
|
+
expect(
|
|
698
|
+
await reason(
|
|
699
|
+
host.commands["agent/ask"]({
|
|
700
|
+
...presetInput("llm-provider"),
|
|
701
|
+
redeemKeyHash,
|
|
702
|
+
}),
|
|
703
|
+
),
|
|
704
|
+
).toEqual({ reason: "invalid-input" });
|
|
705
|
+
}
|
|
706
|
+
expect(
|
|
707
|
+
await reason(
|
|
708
|
+
host.commands["agent/ask"]({ prompt: "Name?", redeemKeyHash: hash }),
|
|
709
|
+
),
|
|
710
|
+
).toEqual({ reason: "invalid-input" });
|
|
711
|
+
});
|
|
712
|
+
|
|
609
713
|
it("parses a model answer and rejects malformed ones", () => {
|
|
610
714
|
expect(parseModelAnswer(JSON.stringify(answer))).toEqual(answer);
|
|
611
715
|
expect(
|
package/src/host.ts
CHANGED
|
@@ -86,6 +86,12 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
86
86
|
|
|
87
87
|
const ask = (seed: Checkout.InputSeed & { stepId?: string }) => {
|
|
88
88
|
const kind = seed.kind ?? "text";
|
|
89
|
+
if (
|
|
90
|
+
seed.redeemKeyHash !== undefined &&
|
|
91
|
+
(kind !== "model" || !/^[0-9a-f]{64}$/.test(seed.redeemKeyHash))
|
|
92
|
+
) {
|
|
93
|
+
throw reject("invalid-input", "invalid model redeem key hash");
|
|
94
|
+
}
|
|
89
95
|
const options =
|
|
90
96
|
kind === "entry-point"
|
|
91
97
|
? parseEntryPointOptions(seed.options)
|
|
@@ -166,6 +172,9 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
166
172
|
...(kind === "product" && { product: seed.product }),
|
|
167
173
|
...(seed.default !== undefined && { default: seed.default }),
|
|
168
174
|
...(seed.help !== undefined && { help: seed.help }),
|
|
175
|
+
...(seed.redeemKeyHash !== undefined && {
|
|
176
|
+
redeemKeyHash: seed.redeemKeyHash,
|
|
177
|
+
}),
|
|
169
178
|
optional: seed.optional ?? false,
|
|
170
179
|
status: "open",
|
|
171
180
|
...(stepId !== undefined && { stepId }),
|
|
@@ -331,6 +340,15 @@ const useCheckoutHost = (restored: unknown) => {
|
|
|
331
340
|
if (plan === undefined) {
|
|
332
341
|
throw reject("no-plan", "the agent has not proposed a plan yet");
|
|
333
342
|
}
|
|
343
|
+
if (
|
|
344
|
+
(params.id !== undefined && params.id !== state.id) ||
|
|
345
|
+
(params.revision !== undefined && params.revision !== plan.revision)
|
|
346
|
+
) {
|
|
347
|
+
throw reject(
|
|
348
|
+
"stale-plan",
|
|
349
|
+
"review the current setup plan before deciding",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
334
352
|
if (plan.status !== "proposed") {
|
|
335
353
|
throw reject("plan-decided", `the plan is already ${plan.status}`);
|
|
336
354
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -101,6 +101,7 @@ export namespace Checkout {
|
|
|
101
101
|
product?: string;
|
|
102
102
|
default?: string;
|
|
103
103
|
help?: InputHelp;
|
|
104
|
+
redeemKeyHash?: string;
|
|
104
105
|
optional: boolean;
|
|
105
106
|
status: InputStatus;
|
|
106
107
|
answer?: string;
|
|
@@ -192,12 +193,16 @@ export namespace Checkout {
|
|
|
192
193
|
product?: string;
|
|
193
194
|
default?: string;
|
|
194
195
|
help?: InputHelp;
|
|
196
|
+
redeemKeyHash?: string;
|
|
195
197
|
optional?: boolean;
|
|
196
198
|
};
|
|
197
199
|
|
|
198
|
-
export type PlanDecision =
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
export type PlanDecision = {
|
|
201
|
+
/** The setup the browser reviewed; omitted by legacy clients. */
|
|
202
|
+
id?: string;
|
|
203
|
+
/** The plan revision the browser reviewed; omitted by legacy clients. */
|
|
204
|
+
revision?: number;
|
|
205
|
+
} & ({ decision: "approve" } | { decision: "revise"; feedback: string });
|
|
201
206
|
|
|
202
207
|
export type Commands = {
|
|
203
208
|
"checkout/create": (params: {
|
|
@@ -254,6 +259,7 @@ export namespace Checkout {
|
|
|
254
259
|
| "plan-required"
|
|
255
260
|
| "no-plan"
|
|
256
261
|
| "plan-decided"
|
|
262
|
+
| "stale-plan"
|
|
257
263
|
| "finish-not-proposed"
|
|
258
264
|
| "feedback-required"
|
|
259
265
|
| "empty-plan"
|
|
@@ -333,11 +339,13 @@ export const parsePreviewUrl = (value: string | undefined) => {
|
|
|
333
339
|
}
|
|
334
340
|
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
335
341
|
if (url.username !== "" || url.password !== "") return undefined;
|
|
336
|
-
|
|
337
|
-
LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith(".localhost");
|
|
338
|
-
return loopback ? url : undefined;
|
|
342
|
+
return isLoopbackHostname(url.hostname) ? url : undefined;
|
|
339
343
|
};
|
|
340
344
|
|
|
345
|
+
/** True for a hostname that names this machine. */
|
|
346
|
+
export const isLoopbackHostname = (hostname: string) =>
|
|
347
|
+
LOOPBACK_HOSTS.has(hostname) || hostname.endsWith(".localhost");
|
|
348
|
+
|
|
341
349
|
export const openInputs = (state: Checkout.State) =>
|
|
342
350
|
state.inputs.filter((input) => input.status === "open");
|
|
343
351
|
|