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 CHANGED
@@ -25,9 +25,11 @@ A setup opens `waiting` with its products and nothing else: no steps, no questio
25
25
 
26
26
  The URL is the browser's link to its agent, not one setup. A finished or cancelled setup stays on the link, and the browser's next setup replaces it with a new `checkout/create` carrying a new `id`: the plans, steps, inputs and log start over, the agent's presence carries across when it is still connected, and the stream prints `setup.created` with the new products. The agent keeps the stream running after `finished` or `cancelled` and blocks on `wait start`, which returns once the next setup begins planning; a setup already closed when the wait begins is skipped rather than reported.
27
27
 
28
+ The browser sends the setup `id` and the displayed plan `revision` with each `checkout/plan` decision. The host rejects a mismatched setup or revision with `stale-plan`, so a queued decision cannot approve or revise a newer plan. Older clients may omit these fields and retain their existing behavior. The guard requires a host running this version of `setup-agent`.
29
+
28
30
  ## Inputs
29
31
 
30
- Inputs include `text`, `choice`, `model` and `entry-point`, plus the legacy `product` kind. A choice carries `options` (`id`, `label`, `description`, `icon`, `variants`), an optional `default` and a `help` note the browser renders as a "help me choose" disclosure. Option ids are unique, and neither option nor variant ids may contain `:`. The answer is the option id, `<option>:<variant>` when the option has variants (`langgraph:python`), or the user's own text when none of the options fit (including any text with more than one `:`); the stream marks that answer `custom`. An agent's own choice is multi-select unless asked with `--single`: the user checks any number of options (none may have variants), and the answer is a JSON array of those entries, which `wait` and the stream print as an array and `status` holds as text. `--single` is for options that exclude each other (one framework, one model, yes or no) and answers one id. Every one of the agent's own options must carry an `icon` from the pack `OPTION_ICONS` exports (`--icons "slack=message,email=mail"`); the CLI refuses the question otherwise. A preset option carries a brand mark instead. Every answer may carry a `note` from the user. A model input lists providers as its options; the browser collects the provider, its API key, the model and an optional reasoning effort, and the answer is that object as JSON (`{"provider","model","reasoningEffort"?}`); the API key is deposited separately and only ever reaches the env file the agent names through `env`.
32
+ Inputs include `text`, `choice`, `model` and `entry-point`, plus the legacy `product` kind. A choice carries `options` (`id`, `label`, `description`, `icon`, `variants`), an optional `default` and a `help` note the browser renders as a "help me choose" disclosure. Option ids are unique, and neither option nor variant ids may contain `:`. The answer is the option id, `<option>:<variant>` when the option has variants (`langgraph:python`), or the user's own text when none of the options fit (including any text with more than one `:`); the stream marks that answer `custom`. An agent's own choice is multi-select unless asked with `--single`: the user checks any number of options (none may have variants), and the answer is a JSON array of those entries, which `wait` and the stream print as an array and `status` holds as text. `--single` is for options that exclude each other (one framework, one model, yes or no) and answers one id. Every one of the agent's own options must carry an `icon` from the pack `OPTION_ICONS` exports (`--icons "slack=message,email=mail"`); the CLI refuses the question otherwise. A preset option carries a brand mark instead. Every answer may carry a `note` from the user. A model input lists providers as its options; the browser collects the provider, its API key, the model and an optional reasoning effort, and the answer is that object as JSON (`{"provider","model","reasoningEffort"?}`); the API key is deposited separately and only ever reaches the env file the agent names through `env`. Only the CLI that asked can take it: `ask` keeps a redeem key in a private file under the user's state directory, or a per-user directory under the temp directory, and refuses a model question when neither can hold it, so `env` runs as the same user on the same machine.
31
33
 
32
34
  When the setup puts an assistant in the app, the agent asks for its entry point after discovering the app, with `ask "<prompt>" --entry-points '<JSON array>' [--default <id>]`. Each of 1–3 options includes a stable `id`, `label`, contextual `description`, and `entryPoint: { formFactor: "modal" | "sidebar" | "full-page", placement, trigger, recommended? }`. At most one option is recommended, and the agent marks it with `recommended` rather than `--default`, which would preselect it. Two alternatives can share a form factor when their placements differ. Entry-point questions accept exactly one option id, with an optional user note; variants, custom ids, a help note and multi-selection are refused. The agent describes the actual app's workflow, placement and opening action, then incorporates the user's selection into the approved plan.
33
35
 
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import type { CheckoutClient } from "./client.js";
2
2
  import type { Checkout } from "./protocol.js";
3
3
  export declare const HELP: string;
4
- export declare const agentInstructions: (url: string) => string;
5
- export declare const INSTRUCTIONS: (url: string) => string;
4
+ /**
5
+ * The normalized setup URL, or an error saying why it is refused. A setup URL
6
+ * is printed into shell commands and the coding agent's prompt, and the API
7
+ * key travels over it, so it must be https (http only on this machine), carry
8
+ * no credentials, query or fragment, and hold only address characters.
9
+ */
10
+ export declare const parseSetupUrl: (value: string) => string;
11
+ export declare const agentInstructions: (setupUrl: string) => string;
12
+ export declare const INSTRUCTIONS: (setupUrl: string) => string;
6
13
  type Parsed = {
7
14
  positional: string[];
8
15
  flags: Map<string, string | true>;
@@ -65,7 +72,7 @@ export declare const upsertEnvLine: (content: string, key: string, value: string
65
72
  * secret consumes it and an unwritable destination must fail while it is
66
73
  * still deposited; it is read after, so lines written meanwhile survive.
67
74
  */
68
- export declare const writeEnvSecret: (file: string, key: string, take: () => Promise<string>) => Promise<void>;
75
+ export declare const writeEnvSecret: (file: string, key: string, take: () => Promise<string>, projectRoot?: string) => Promise<void>;
69
76
  export declare const main: (argv: readonly string[]) => Promise<undefined>;
70
77
  export {};
71
78
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAiB;AAmB/C,OAAO,KAAK,EAAE,QAAQ,EAAE,sBAAmB;AAE3C,eAAO,MAAM,IAAI,QAgChB,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAC5B,KAAK,MAAM,WA2CZ,CAAC;AAEF,eAAO,MAAM,YAAY,QA7ClB,MAAM,WA6CgC,CAAC;AAE9C,KAAK,MAAM,GAAG;IAAE,UAAU,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAA;CAAE,CAAC;AA6C1E,kGAAkG;AAClG,eAAO,MAAM,eAAe,GAAI,MAAK,MAAM,CAAC,UAAwB,uBACd,CAAC;AAyCvD,2GAA2G;AAC3G,eAAO,MAAM,kBAAkB,GAC7B,MAAK,MAAM,CAAC,UAAwB,EACpC,QAAO;IAAE,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAAkB,EACtD,SAAQ;IAAE,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAAmB,YAInC,CAAC;AA0FxB,KAAK,WAAW,GACZ;IAAE,KAAK,EAAE,kBAAkB,CAAA;CAAE,GAC7B;IAAE,KAAK,EAAE,iBAAiB,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC7D;IACE,KAAK,EAAE,gBAAgB,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2GAA2G;IAC3G,MAAM,CAAC,EAAE,IAAI,CAAC;CACf,GACD;IAAE,KAAK,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC7D;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,KAAK,EAAE,wBAAwB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACvE;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAKnD,eAAO,MAAM,UAAU,GACrB,QAAQ,QAAQ,CAAC,KAAK,EACtB,OAAO,QAAQ,CAAC,KAAK,KACpB,WAAW,EAqEb,CAAC;AA4CF,2LAA2L;AAC3L,eAAO,MAAM,YAAY,GACvB,QAAQ,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,WAAW,CAAC,6BAiBjD,CAAC;AAwDL,eAAO,MAAM,OAAO,GAClB,MAAM,SAAS,MAAM,EAAE,EACvB,OAAO,MAAM,CAAC,OAAO,CAAC,KACrB,QAAQ,CAAC,SAAS,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CA+HxC,CAAC;AAQF,+EAA+E;AAC/E,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,EAAE,KAAK,MAAM,EAAE,OAAO,MAAM,WAWxE,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GACzB,MAAM,MAAM,EACZ,KAAK,MAAM,EACX,MAAM,MAAM,OAAO,CAAC,MAAM,CAAC,kBAa5B,CAAC;AAkBF,eAAO,MAAM,IAAI,GAAU,MAAM,SAAS,MAAM,EAAE,uBA+LjD,CAAC"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { connectCheckout } from "./client.js";
2
2
  import { INPUT_PRESETS, isPresetId, presetInput } from "./presets.js";
3
- import { AGENT_HEARTBEAT_MS, OPTION_ICONS, classifyChoiceAnswer, currentPlan, isAgentPresent, isClosed, isOptionIcon, isValidModelAnswer, parseEntryPointOptions, parseMultipleAnswer, stepProgress } from "./protocol.js";
3
+ import { AGENT_HEARTBEAT_MS, OPTION_ICONS, classifyChoiceAnswer, currentPlan, isAgentPresent, isClosed, isLoopbackHostname, isOptionIcon, isValidModelAnswer, parseEntryPointOptions, parseMultipleAnswer, stepProgress } from "./protocol.js";
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 { chmod, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/promises";
8
+ import { homedir, tmpdir, userInfo } from "node:os";
6
9
  import { createInterface } from "node:readline/promises";
7
- import { resolve } from "node:path";
8
- //#region src/cli.ts
10
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+ // src/cli.ts
9
12
  const HELP = `setup-agent <url> [command]
10
13
 
11
14
  Commands (the url always comes first):
@@ -39,7 +42,33 @@ Commands (the url always comes first):
39
42
  done [--summary <text>] [--preview <url>]
40
43
  Mark every remaining step done and propose closing; the user closes it.
41
44
  `;
42
- const agentInstructions = (url) => `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.
45
+ /** Characters a setup URL may hold: an origin and a path, nothing a shell or a reader treats as more than an address. */
46
+ const SETUP_URL_PATTERN = /^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$|^http:\/\/\[::1\](?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$/;
47
+ /**
48
+ * The normalized setup URL, or an error saying why it is refused. A setup URL
49
+ * is printed into shell commands and the coding agent's prompt, and the API
50
+ * key travels over it, so it must be https (http only on this machine), carry
51
+ * no credentials, query or fragment, and hold only address characters.
52
+ */
53
+ const parseSetupUrl = (value) => {
54
+ let url;
55
+ try {
56
+ url = new URL(value);
57
+ } catch {
58
+ throw new Error(`"${value}" is not a URL`);
59
+ }
60
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) throw new Error("the setup URL must use https (http only on localhost)");
61
+ if (url.username !== "" || url.password !== "") throw new Error("the setup URL must not carry credentials");
62
+ if (url.search !== "" || url.hash !== "" || value.includes("?") || value.includes("#")) throw new Error("the setup URL must not have a query or fragment");
63
+ const href = url.href.replace(/\/$/, "");
64
+ if (!SETUP_URL_PATTERN.test(href)) throw new Error("the setup URL has characters a setup URL does not use");
65
+ return href;
66
+ };
67
+ /** `value` as one shell word, quoted only when it holds more than plain URL characters. */
68
+ const shellWord = (value) => /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
69
+ const agentInstructions = (setupUrl) => {
70
+ const url = shellWord(setupUrl);
71
+ 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.
43
72
 
44
73
  First, start a persistent Monitor on
45
74
  npx setup-agent ${url} stream
@@ -82,6 +111,7 @@ Before that, start the project's dev server in the background when the project h
82
111
  npx setup-agent ${url} wait start
83
112
  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.
84
113
  `;
114
+ };
85
115
  const INSTRUCTIONS = agentInstructions;
86
116
  const parseArgs = (argv) => {
87
117
  const positional = [];
@@ -181,7 +211,7 @@ const chooseLauncher = async () => {
181
211
  };
182
212
  const launchAgent = async (url) => {
183
213
  const launcher = await chooseLauncher();
184
- const prompt = `Run \`npx setup-agent ${url}\` and follow the instructions it prints.`;
214
+ const prompt = `Run \`npx setup-agent ${shellWord(url)}\` and follow the instructions it prints.`;
185
215
  process.stdout.write(`\nStarting ${launcher.name}...\n\n`);
186
216
  const child = spawn(launcher.command, launcher.args(prompt), { stdio: "inherit" });
187
217
  const code = await new Promise((resolve) => {
@@ -485,6 +515,8 @@ const assertEnvKey = (key) => {
485
515
  /** Sets `key=value` in dotenv text, replacing an existing line for the key. */
486
516
  const upsertEnvLine = (content, key, value) => {
487
517
  assertEnvKey(key);
518
+ const isLineBreaking = (code) => code < 32 || code >= 127 && code <= 159 || code === 8232 || code === 8233;
519
+ if ([...value].some((char) => isLineBreaking(char.charCodeAt(0)))) throw new Error(`the value for ${key} must be a single line`);
488
520
  const line = `${key}=${value}`;
489
521
  const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
490
522
  const index = lines.findIndex((entry) => /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(entry)?.[1] === key);
@@ -498,19 +530,98 @@ const upsertEnvLine = (content, key, value) => {
498
530
  * secret consumes it and an unwritable destination must fail while it is
499
531
  * still deposited; it is read after, so lines written meanwhile survive.
500
532
  */
501
- const writeEnvSecret = async (file, key, take) => {
533
+ const writeEnvSecret = async (file, key, take, projectRoot = process.cwd()) => {
502
534
  assertEnvKey(key);
503
- const handle = await open(file, "a+", 384);
535
+ try {
536
+ if ((await lstat(file)).isSymbolicLink()) throw new Error(`${file} is a symbolic link; pass --file with its target to write there`);
537
+ } catch (error) {
538
+ if (error.code !== "ENOENT") throw error;
539
+ }
540
+ const projectRelative = relative(projectRoot, file);
541
+ if (projectRelative !== ".." && !projectRelative.startsWith(`..${sep}`) && !isAbsolute(projectRelative)) {
542
+ const root = await realpath(projectRoot);
543
+ const parent = await realpath(dirname(file));
544
+ const parentRelative = relative(root, parent);
545
+ if (parentRelative === ".." || parentRelative.startsWith(`..${sep}`) || isAbsolute(parentRelative)) throw new Error(`${file} has a parent directory outside the project`);
546
+ }
547
+ const handle = await open(file, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 384).catch((error) => {
548
+ if (error.code === "ELOOP") throw new Error(`${file} is a symbolic link; pass --file with its target to write there`);
549
+ throw error;
550
+ });
504
551
  try {
505
552
  await handle.chmod(384);
506
553
  const value = await take();
507
554
  const current = await handle.readFile("utf8");
555
+ const next = upsertEnvLine(current, key, value);
508
556
  await handle.truncate(0);
509
- await handle.writeFile(upsertEnvLine(current, key, value));
557
+ await handle.writeFile(next);
510
558
  } finally {
511
559
  await handle.close();
512
560
  }
513
561
  };
562
+ const redeemKeyDirectories = () => {
563
+ const stateHome = process.platform === "win32" ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local") : process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
564
+ return [join(stateHome, "setup-agent"), join(tmpdir(), `setup-agent-${process.platform === "win32" ? userInfo().username : process.getuid()}`)];
565
+ };
566
+ const checkRedeemKeyDirectory = async (directory) => {
567
+ if (!isAbsolute(directory)) throw new Error("state home must be an absolute path");
568
+ const info = await lstat(directory);
569
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`redeem key directory must not be a symbolic link: ${directory}`);
570
+ if (process.platform !== "win32") {
571
+ if (info.uid !== process.getuid()) throw new Error(`redeem key directory must be owned by the user: ${directory}`);
572
+ if ((info.mode & 63) !== 0) await chmod(directory, 448);
573
+ }
574
+ };
575
+ const prepareRedeemKeyDirectory = async (directory) => {
576
+ if (isAbsolute(directory)) await mkdir(directory, {
577
+ recursive: true,
578
+ mode: 448
579
+ });
580
+ await checkRedeemKeyDirectory(directory);
581
+ };
582
+ const saveRedeemKey = async (hash, key) => {
583
+ let lastError;
584
+ for (const directory of redeemKeyDirectories()) {
585
+ const file = join(directory, `${hash}.key`);
586
+ let created = false;
587
+ try {
588
+ await prepareRedeemKeyDirectory(directory);
589
+ const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
590
+ created = true;
591
+ try {
592
+ await handle.writeFile(key);
593
+ } finally {
594
+ await handle.close();
595
+ }
596
+ return file;
597
+ } catch (error) {
598
+ if (created) await unlink(file).catch(() => {});
599
+ lastError = error;
600
+ }
601
+ }
602
+ throw lastError;
603
+ };
604
+ const readRedeemKey = async (hash) => {
605
+ if (!/^[0-9a-f]{64}$/.test(hash)) return void 0;
606
+ for (const directory of redeemKeyDirectories()) try {
607
+ await checkRedeemKeyDirectory(directory);
608
+ const file = join(directory, `${hash}.key`);
609
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
610
+ try {
611
+ const info = await handle.stat();
612
+ if (!info.isFile() || process.platform !== "win32" && (info.mode & 63) !== 0) throw new Error("redeem key file must be private and regular");
613
+ const key = await handle.readFile("utf8");
614
+ if (createHash("sha256").update(key).digest("hex") === hash) return {
615
+ key,
616
+ file
617
+ };
618
+ } finally {
619
+ await handle.close();
620
+ }
621
+ } catch {
622
+ continue;
623
+ }
624
+ };
514
625
  const answerFields = (input, answer) => ({
515
626
  answer: input.multiple && parseMultipleAnswer(answer) || answer,
516
627
  ...input.note !== void 0 && { note: input.note },
@@ -529,7 +640,12 @@ const main = async (argv) => {
529
640
  process.stdout.write(HELP);
530
641
  return process.exit(first === void 0 ? 1 : 0);
531
642
  }
532
- const url = first;
643
+ let url;
644
+ try {
645
+ url = parseSetupUrl(first);
646
+ } catch (error) {
647
+ return fail(error instanceof Error ? error.message : String(error));
648
+ }
533
649
  if (command === "intro" && isDirectInvocation()) return launchAgent(url);
534
650
  const client = await connectCheckout(url).catch((error) => fail(error instanceof Error ? error.message : String(error)));
535
651
  const commands = client.commands;
@@ -599,7 +715,20 @@ const main = async (argv) => {
599
715
  break;
600
716
  }
601
717
  case "ask": {
602
- const { inputId } = await commands["agent/ask"](askSeed(rest, flags));
718
+ const seed = askSeed(rest, flags);
719
+ const redeemKey = seed.kind === "model" ? randomBytes(32).toString("hex") : void 0;
720
+ const redeemKeyHash = redeemKey === void 0 ? void 0 : createHash("sha256").update(redeemKey).digest("hex");
721
+ const keyFile = redeemKey === void 0 || redeemKeyHash === void 0 ? void 0 : await saveRedeemKey(redeemKeyHash, redeemKey).catch((error) => fail(`could not store the model redeem key: ${error instanceof Error ? error.message : String(error)}`));
722
+ let inputId;
723
+ try {
724
+ ({inputId} = await commands["agent/ask"]({
725
+ ...seed,
726
+ ...redeemKeyHash !== void 0 && { redeemKeyHash }
727
+ }));
728
+ } catch (error) {
729
+ if (keyFile !== void 0) await unlink(keyFile).catch(() => {});
730
+ throw error;
731
+ }
603
732
  if (!flags.has("wait")) {
604
733
  print({
605
734
  ok: true,
@@ -641,12 +770,22 @@ const main = async (argv) => {
641
770
  if (isClosed(state)) fail("the setup is not open");
642
771
  if (input.status !== "answered") fail(`input "${inputId}" is ${input.status}`);
643
772
  if (input.kind !== "model" || !isValidModelAnswer(input, input.answer ?? "")) fail(`input "${inputId}" is not an llm-provider answer`);
773
+ const storedKey = input.redeemKeyHash === void 0 ? void 0 : await readRedeemKey(input.redeemKeyHash);
774
+ if (input.redeemKeyHash !== void 0 && storedKey === void 0) fail(`no local redeem key for "${inputId}"`);
644
775
  const file = resolve(flagText(flags, "file") ?? ".env.local");
645
776
  await writeEnvSecret(file, envKey, async () => {
646
- const response = await fetch(`${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`);
777
+ const secretUrl = new URL(url);
778
+ if (secretUrl.protocol !== "https:" && !(secretUrl.protocol === "http:" && isLoopbackHostname(secretUrl.hostname))) fail("the key is only fetched over https");
779
+ const response = await fetch(`${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`, storedKey === void 0 ? void 0 : { headers: { "Setup-Agent-Redeem-Key": storedKey.key } });
780
+ if (response.status === 403) fail(`redeem key rejected for "${inputId}"`);
647
781
  if (response.status === 404) fail(`the key for "${inputId}" was already taken or never deposited`);
648
782
  if (!response.ok) fail(`could not fetch the key: ${response.status}`);
649
- return response.text();
783
+ const key = await response.text();
784
+ if (!/^[A-Za-z0-9._~+/=:@%,-]+$/.test(key)) fail("the deposited key is not a valid API key");
785
+ return key;
786
+ });
787
+ if (storedKey !== void 0) await unlink(storedKey.file).catch((error) => {
788
+ if (error.code !== "ENOENT") process.stderr.write(`warning: could not remove a redeem key: ${error.message}\n`);
650
789
  });
651
790
  print({
652
791
  ok: true,
@@ -689,7 +828,6 @@ const main = async (argv) => {
689
828
  client.dispose();
690
829
  }
691
830
  };
692
- //#endregion
693
- export { HELP, INSTRUCTIONS, agentInstructions, askSeed, detectAgentKind, diffEvents, isDirectInvocation, main, upsertEnvLine, waitForStart, writeEnvSecret };
831
+ export { HELP, INSTRUCTIONS, agentInstructions, askSeed, detectAgentKind, diffEvents, isDirectInvocation, main, parseSetupUrl, upsertEnvLine, waitForStart, writeEnvSecret };
694
832
 
695
833
  //# sourceMappingURL=cli.js.map