driggsby 0.6.3 → 0.7.1

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
@@ -35,7 +35,9 @@ https://app.driggsby.com/mcp
35
35
 
36
36
  Some driggsby commands work with your Driggsby account directly and need
37
37
  an app token saved on your machine: run
38
- `npx driggsby@latest login` and approve the page it opens. The token is
38
+ `npx driggsby@latest login` and approve the page it opens (approving in a
39
+ browser on another computer shows a code to paste, or to finish with
40
+ `npx driggsby@latest login --code <CODE>`). The token is
39
41
  stored in your platform keychain when one is available, otherwise in
40
42
  `~/.driggsby/credentials.json`, and is never printed.
41
43
  `npx driggsby@latest logout` removes it.
@@ -67,6 +69,8 @@ can't reach it, but anything running locally can, so stop it when you're
67
69
  done: press Ctrl+C, or run `npx driggsby@latest dev --stop` in the app's
68
70
  folder. It also stops on its own after 30 minutes with no page open.
69
71
  Previews for several apps can run at once; each prints its own addresses.
72
+ Each data call the page makes prints one line in the terminal, after any
73
+ retries: a check and how long it took, or a cross and the reason.
70
74
 
71
75
  ## Deploying an app
72
76
 
@@ -0,0 +1,41 @@
1
+ // Argv parsing for driggsby login: help, and --code to finish a sign-in that
2
+ // is waiting on this computer with the code its approval page showed. Same
3
+ // contract as the rest of the tree: usage errors exit 2, echoed argv is
4
+ // sanitized.
5
+ import { helpCommand, unexpectedArgument } from "./args-shared.js";
6
+ import { CliError } from "./cli-error.js";
7
+ import { LOGIN_HELP } from "./help.js";
8
+ const LOGIN_USAGE = "Usage: npx driggsby@latest login [--code <CODE>]";
9
+ export function parseLogin(argv) {
10
+ let code = null;
11
+ let optionsEnded = false;
12
+ for (let index = 0; index < argv.length; index += 1) {
13
+ const token = argv[index] ?? "";
14
+ if (!optionsEnded && token === "--") {
15
+ optionsEnded = true;
16
+ continue;
17
+ }
18
+ if (!optionsEnded && (token === "-h" || token === "--help")) {
19
+ return helpCommand(LOGIN_HELP);
20
+ }
21
+ if (!optionsEnded && code === null && token === "--code") {
22
+ code = requireCode(argv[index + 1]);
23
+ index += 1;
24
+ continue;
25
+ }
26
+ if (!optionsEnded && code === null && token.startsWith("--code=")) {
27
+ code = requireCode(token.slice("--code=".length));
28
+ continue;
29
+ }
30
+ throw unexpectedArgument(token, LOGIN_USAGE);
31
+ }
32
+ return { kind: "login", code };
33
+ }
34
+ // A code is never empty or flag-like; a pasted one may carry spaces, which
35
+ // Driggsby reads past.
36
+ function requireCode(value) {
37
+ if (value === undefined || value.trim() === "" || value.startsWith("-")) {
38
+ throw new CliError(`error: a value is required for '--code <CODE>' but none was supplied\n\n${LOGIN_USAGE}\n\nFor more information, try '--help'.`, 2);
39
+ }
40
+ return value;
41
+ }
package/dist/args.js CHANGED
@@ -10,13 +10,14 @@ import { parseDeploy, parseRollback, parseVersions } from "./args-deploy.js";
10
10
  import { parseDev } from "./args-dev.js";
11
11
  import { parseDelete } from "./args-delete.js";
12
12
  import { parseInit } from "./args-init.js";
13
+ import { parseLogin } from "./args-login.js";
13
14
  import { parseMcpSetup } from "./args-mcp-setup.js";
14
15
  import { parseQuery } from "./args-query.js";
15
16
  import { parseRules } from "./args-rules.js";
16
17
  import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
17
18
  import { didYouMean } from "./clap-suggestions.js";
18
19
  import { CliError } from "./cli-error.js";
19
- import { LOGIN_HELP, LOGOUT_HELP, MCP_HELP, ROOT_HELP } from "./help.js";
20
+ import { LOGOUT_HELP, MCP_HELP, ROOT_HELP } from "./help.js";
20
21
  import { sanitizeForTerminal } from "./terminal-text.js";
21
22
  export {} from "./args-shared.js";
22
23
  const ROOT_USAGE = "Usage: npx driggsby@latest <COMMAND>";
@@ -90,7 +91,7 @@ function dispatchSubcommand(name, rest) {
90
91
  case "mcp":
91
92
  return parseLevel(MCP_LEVEL, rest);
92
93
  case "login":
93
- return parseBareCommand({ kind: "login" }, LOGIN_HELP, "Usage: npx driggsby@latest login", rest);
94
+ return parseLogin(rest);
94
95
  case "logout":
95
96
  return parseBareCommand({ kind: "logout" }, LOGOUT_HELP, "Usage: npx driggsby@latest logout", rest);
96
97
  case "init":
@@ -117,7 +118,7 @@ function dispatchSubcommand(name, rest) {
117
118
  throw new Error(`unhandled subcommand dispatch: ${name}`);
118
119
  }
119
120
  }
120
- // Parsing for commands that take no arguments beyond help: login and logout.
121
+ // Parsing for commands that take no arguments beyond help: logout.
121
122
  // Same exit codes and error shape as the rest of the tree, no clap quirks.
122
123
  function parseBareCommand(command, helpText, usage, argv) {
123
124
  let optionsEnded = false;
@@ -0,0 +1,78 @@
1
+ // The security headers on every app-origin response, the service worker
2
+ // the app origin hands out, and the policy the host page requires of its
3
+ // frame: a copy of what Driggsby's serving host and console do for a
4
+ // deployed app, so the preview refuses what a deployed app is refused and a
5
+ // page that works here works once deployed. The Driggsby service is
6
+ // canonical: change it there first, then mirror it here.
7
+ //
8
+ // The content security policy keeps the page's loads on its own origin
9
+ // (another site's script, font, image, or fetch is refused), refuses frames,
10
+ // objects, forms, and eval, and sandboxes the page as the serving host does.
11
+ // frame-ancestors names the host page, the only page that may frame the
12
+ // app. A file that can run script (a page, an SVG, JavaScript) also carries
13
+ // Connection-Allowlist, which has Chrome refuse any connection the page
14
+ // starts to anywhere else, WebRTC included. Any other file gets neither the
15
+ // list nor script: its sandbox grants nothing, so it opens in an origin of
16
+ // its own, as it does deployed. Never widen the list to other files; the
17
+ // serving host sends it only where script can run.
18
+ const SCRIPT_MEDIA_TYPES = new Set([
19
+ "text/html",
20
+ "image/svg+xml",
21
+ "text/javascript",
22
+ "application/javascript",
23
+ ]);
24
+ const POLICY_DIRECTIVES = [
25
+ "default-src 'self'",
26
+ "script-src 'self' 'unsafe-inline'",
27
+ "style-src 'self' 'unsafe-inline'",
28
+ "img-src 'self' data: blob:",
29
+ "font-src 'self' data:",
30
+ "connect-src 'self'",
31
+ "frame-src 'none'",
32
+ "object-src 'none'",
33
+ "form-action 'none'",
34
+ "base-uri 'none'",
35
+ ];
36
+ // What the host page requires of every document its frame shows (the
37
+ // iframe's csp attribute), as the console does: it binds even a document
38
+ // that doesn't come from this server.
39
+ export const FRAME_POLICY = POLICY_DIRECTIVES.join("; ");
40
+ // The only service worker either preview origin hands out, verbatim from
41
+ // the serving host. An app's own worker could answer the frame's
42
+ // navigations with documents it builds, carrying none of these headers, and
43
+ // on these fixed ports it would outlive the preview and answer the next
44
+ // app's too; a worker some other local server once left on the host port
45
+ // would answer the host page, which runs tools. This one listens for
46
+ // nothing.
47
+ export const INERT_SERVICE_WORKER = `// This origin's only service worker. It listens for nothing, so it answers
48
+ // no request: every page and file comes from the network with the host's
49
+ // own headers. It behaves like any default worker: it waits until no page
50
+ // is using the worker before it, then takes over.
51
+ `;
52
+ // hostOrigin is empty until the host server has bound (a startup race
53
+ // measured in milliseconds); until then nothing may frame the app.
54
+ export function appResponseHeaders(hostOrigin, contentType) {
55
+ const mediaType = (contentType.split(";")[0] ?? "").trim().toLowerCase();
56
+ const runsScript = SCRIPT_MEDIA_TYPES.has(mediaType);
57
+ const directives = [
58
+ runsScript ? "sandbox allow-scripts allow-same-origin" : "sandbox",
59
+ ...POLICY_DIRECTIVES,
60
+ `frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin}`,
61
+ ];
62
+ const headers = {
63
+ "Content-Security-Policy": directives.join("; "),
64
+ // The served content types are final; the browser never guesses.
65
+ "X-Content-Type-Options": "nosniff",
66
+ "Referrer-Policy": "no-referrer",
67
+ "Cross-Origin-Resource-Policy": "same-origin",
68
+ "Cross-Origin-Opener-Policy": "same-origin",
69
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
70
+ };
71
+ if (hostOrigin !== "") {
72
+ headers["Allow-CSP-From"] = hostOrigin;
73
+ }
74
+ if (runsScript) {
75
+ headers["Connection-Allowlist"] = "(response-origin)";
76
+ }
77
+ return headers;
78
+ }
@@ -5,6 +5,7 @@
5
5
  // only. The app token never appears here — the broker callback holds it in
6
6
  // the CLI process.
7
7
  import { createServer } from "node:http";
8
+ import { appResponseHeaders, INERT_SERVICE_WORKER } from "./app-policy.js";
8
9
  import { DEV_IDENTITY_PATH } from "./dev-state.js";
9
10
  import { hostPageHtml, HOST_PAGE_JS } from "./host-page.js";
10
11
  import { serveStaticFile } from "./static-files.js";
@@ -15,13 +16,16 @@ import { APP_TOOL_ALLOWLIST } from "./tool-allowlist.js";
15
16
  export const GENERIC_TOOL_TROUBLE = "We couldn't run that just now. Try again in a moment.";
16
17
  export const TOOL_NOT_ALLOWED_MESSAGE = "That tool isn't available to Driggsby apps. Apps can use read-only tools like get_overview and list_accounts.";
17
18
  const MAX_TOOL_CALL_BODY_BYTES = 65_536;
19
+ const PLAIN_TEXT = "text/plain; charset=utf-8";
20
+ const JAVASCRIPT = "text/javascript; charset=utf-8";
18
21
  export async function startDevServers(options) {
19
22
  // Both origins bind and advertise 127.0.0.1: advertising "localhost"
20
23
  // while binding only the IPv4 loopback breaks on machines whose browser
21
24
  // resolves localhost to ::1 first.
22
25
  let appPortForGuard = 0;
23
- // Filled in once the host server binds; app responses embed it in their
24
- // frame-ancestors policy so only the real host page can frame the app.
26
+ // Filled in once the host server binds; app responses name it in their
27
+ // frame-ancestors and Allow-CSP-From, so only the real host page can
28
+ // frame the app.
25
29
  let hostOriginForFraming = "";
26
30
  const appServer = createServer((request, response) => {
27
31
  void handleAppRequest(options, appPortForGuard, hostOriginForFraming, request, response);
@@ -70,39 +74,41 @@ export async function startDevServers(options) {
70
74
  // The app origin: the SDK path, then the project's own files.
71
75
  // ---------------------------------------------------------------------------
72
76
  async function handleAppRequest(options, appPort, hostOrigin, request, response) {
73
- // Only the real host page may frame the app: without this, any other
74
- // loopback-served page could embed the app origin, pin itself as the
75
- // app's host, and feed it fabricated numbers. Until the host server has
76
- // bound (a startup race measured in milliseconds), nothing may frame it.
77
- // nosniff keeps the browser honest about the served content types.
78
- const appHeaders = {
79
- "Content-Security-Policy": `frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin};`,
80
- "X-Content-Type-Options": "nosniff",
81
- };
77
+ // Every response carries the deployed app's headers (app-policy.ts). Their
78
+ // frame-ancestors lets only the real host page frame the app: without it,
79
+ // any other loopback-served page could embed the app origin, pin itself
80
+ // as the app's host, and feed it fabricated numbers.
81
+ const plainTextHeaders = appResponseHeaders(hostOrigin, PLAIN_TEXT);
82
82
  // Same rebinding guard as the host origin: a page on another site that
83
83
  // tricked DNS into pointing here carries its own Host, and fails.
84
84
  if (!hostHeaderIsLocal(request.headers.host, appPort)) {
85
- sendText(response, 403, "text/plain; charset=utf-8", "Forbidden.", appHeaders);
85
+ sendText(response, 403, PLAIN_TEXT, "Forbidden.", plainTextHeaders);
86
+ return;
87
+ }
88
+ // Every service worker script request, whatever path it names, gets the
89
+ // inert worker (app-policy.ts), as deployed. Browsers mark each one.
90
+ if (isServiceWorkerScriptRequest(request)) {
91
+ sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER, appResponseHeaders(hostOrigin, JAVASCRIPT));
86
92
  return;
87
93
  }
88
94
  const path = requestPath(request);
89
95
  if (request.method !== "GET" && request.method !== "HEAD") {
90
- sendText(response, 405, "text/plain; charset=utf-8", "Method not allowed.", appHeaders);
96
+ sendText(response, 405, PLAIN_TEXT, "Method not allowed.", plainTextHeaders);
91
97
  return;
92
98
  }
93
99
  if (path === "/-/driggsby-sdk.js") {
94
- sendText(response, 200, "text/javascript; charset=utf-8", options.sdkBundle, appHeaders);
100
+ sendText(response, 200, JAVASCRIPT, options.sdkBundle, appResponseHeaders(hostOrigin, JAVASCRIPT));
95
101
  return;
96
102
  }
97
103
  const file = await serveStaticFile(options.serveDirectory, path);
98
104
  if (file === null) {
99
- sendText(response, 404, "text/plain; charset=utf-8", "Not found.", appHeaders);
105
+ sendText(response, 404, PLAIN_TEXT, "Not found.", plainTextHeaders);
100
106
  return;
101
107
  }
102
108
  response.writeHead(file.status, {
103
109
  "Content-Type": file.contentType,
104
110
  "Cache-Control": "no-store",
105
- ...appHeaders,
111
+ ...appResponseHeaders(hostOrigin, file.contentType),
106
112
  });
107
113
  response.end(file.body);
108
114
  }
@@ -117,6 +123,12 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
117
123
  sendJson(response, 403, { ok: false, error: { message: "Forbidden." } });
118
124
  return;
119
125
  }
126
+ // The host page registers no worker, so a worker script request here is
127
+ // a foreign one; the inert worker replaces it (app-policy.ts).
128
+ if (isServiceWorkerScriptRequest(request)) {
129
+ sendText(response, 200, JAVASCRIPT, INERT_SERVICE_WORKER);
130
+ return;
131
+ }
120
132
  const path = requestPath(request);
121
133
  if (request.method === "GET" && path === DEV_IDENTITY_PATH) {
122
134
  // Lets `dev --stop` (and `dev`) confirm that a record in ~/.driggsby
@@ -129,7 +141,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
129
141
  return;
130
142
  }
131
143
  if (request.method === "GET" && path === "/host.js") {
132
- sendText(response, 200, "text/javascript; charset=utf-8", HOST_PAGE_JS);
144
+ sendText(response, 200, JAVASCRIPT, HOST_PAGE_JS);
133
145
  return;
134
146
  }
135
147
  if (request.method === "GET" && path === "/events") {
@@ -150,7 +162,7 @@ async function handleHostRequest(options, hostPort, appOrigin, sseClients, reque
150
162
  await handleToolCall(options, hostPort, request, response);
151
163
  return;
152
164
  }
153
- sendText(response, 404, "text/plain; charset=utf-8", "Not found.");
165
+ sendText(response, 404, PLAIN_TEXT, "Not found.");
154
166
  return;
155
167
  }
156
168
  async function handleToolCall(options, hostPort, request, response) {
@@ -209,6 +221,9 @@ function requestPath(request) {
209
221
  const queryStart = url.indexOf("?");
210
222
  return queryStart === -1 ? url : url.slice(0, queryStart);
211
223
  }
224
+ function isServiceWorkerScriptRequest(request) {
225
+ return request.headers["service-worker"] === "script" || request.headers["sec-fetch-dest"] === "serviceworker";
226
+ }
212
227
  function hostHeaderIsLocal(host, port) {
213
228
  return host === `localhost:${String(port)}` || host === `127.0.0.1:${String(port)}`;
214
229
  }
@@ -5,6 +5,7 @@
5
5
  // inside Driggsby. The page carries no token and no data; every tool call
6
6
  // goes to the CLI's own /tool-calls endpoint, which holds the token and
7
7
  // enforces the allowlist and bounds.
8
+ import { FRAME_POLICY } from "./app-policy.js";
8
9
  // The page's script is served as its own file so the page can carry a
9
10
  // Content-Security-Policy with no inline scripts.
10
11
  //
@@ -14,7 +15,11 @@
14
15
  // `appOrigin` is built by startDevServers from a literal plus a bound port
15
16
  // number, and `background` has passed readProjectConfig's strict hex-color
16
17
  // rule (or is null). Anything looser than those sources must not be
17
- // passed here.
18
+ // passed here. FRAME_POLICY is a constant with no double quote in it.
19
+ //
20
+ // The frame gets the console's sandbox and, as its csp attribute, the
21
+ // deployed policy (app-policy.ts), which Chrome then requires of every
22
+ // document the frame shows, even one this server didn't send.
18
23
  export function hostPageHtml(slug, appOrigin, background) {
19
24
  return `<!doctype html>
20
25
  <html lang="en" data-app-origin="${appOrigin}">
@@ -84,7 +89,7 @@ export function hostPageHtml(slug, appOrigin, background) {
84
89
  <span class="slug">${slug}</span>
85
90
  <span class="mode">driggsby dev — your live data</span>
86
91
  </header>
87
- <iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
92
+ <iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin" csp="${FRAME_POLICY}"></iframe>
88
93
  <script type="module" src="/host.js"></script>
89
94
  </body>
90
95
  </html>
package/dist/help.js CHANGED
@@ -64,10 +64,15 @@ app token that lets this machine read your Driggsby data, deploy Driggsby
64
64
  apps, and manage your transaction rules. The token is saved locally for
65
65
  other driggsby commands and is never printed.
66
66
 
67
- Usage: npx driggsby@latest login
67
+ If you approve in a browser on another computer, the page shows a code
68
+ instead. Paste it where login is waiting, or finish with --code.
69
+
70
+ Usage: npx driggsby@latest login [--code <CODE>]
68
71
 
69
72
  Options:
70
- -h, --help Print help
73
+ --code <CODE> Finish the sign-in waiting on this machine with the
74
+ code its approval page showed
75
+ -h, --help Print help
71
76
  `;
72
77
  export const LOGOUT_HELP = `Remove the saved Driggsby app token.
73
78
 
@@ -0,0 +1,260 @@
1
+ // The wait inside driggsby login, once the approval page is in front of the
2
+ // person: the first of the loopback's code, a pasted code, or the claim
3
+ // going away (declined or expired) ends it. Every code is traded with the
4
+ // verifier; only that trade returns the token.
5
+ import { CliError } from "../cli-error.js";
6
+ import { pollClaimRequest, tradeCode } from "./claim-client.js";
7
+ import {} from "./loopback.js";
8
+ export const LOGIN_RETRY_COMMAND = "npx driggsby@latest login";
9
+ export const CODE_COMMAND = "npx driggsby@latest login --code <CODE>";
10
+ // Approving leaves at least this long to trade the code, even past the
11
+ // link's own lifetime, so a late approval can still finish.
12
+ export const CODE_WINDOW_MS = 300_000;
13
+ // Driggsby's codes are four groups of five; a paste longer than this is
14
+ // not one (the approval link, say), and is refused here without a trade.
15
+ const MAX_CODE_CHARS = 64;
16
+ // A trade that hits a momentary hiccup is tried again this many times.
17
+ const TRADE_ATTEMPTS = 3;
18
+ const TRADE_RETRY_MS = 2_000;
19
+ // A code from the loopback is the only copy there is: when Driggsby can't
20
+ // be reached, the wait keeps trying it this often until the wait ends.
21
+ const LOOPBACK_RETRY_MS = 5_000;
22
+ const CODE_SHAPE_REFUSED = "That doesn't look like a sign-in code. Copy the code the approval page\nshows.";
23
+ const UNREACHABLE = "We couldn't reach Driggsby to finish signing in just now.";
24
+ // The claim went away: declined, expired, or already spent. Distinct, so
25
+ // login can tell it from other failures (another command may have spent it).
26
+ export class ClaimGone extends CliError {
27
+ }
28
+ // The trades in flight. The trade that spends the claim makes the poll
29
+ // read gone before the trade's own answer arrives; the poll waits for any
30
+ // trade in flight so that answer speaks first.
31
+ class Trades {
32
+ #inFlight = 0;
33
+ #started = 0;
34
+ #idle = [];
35
+ get started() {
36
+ return this.#started;
37
+ }
38
+ busy() {
39
+ return this.#inFlight > 0;
40
+ }
41
+ async run(work) {
42
+ this.#inFlight += 1;
43
+ this.#started += 1;
44
+ try {
45
+ return await work();
46
+ }
47
+ finally {
48
+ this.#inFlight -= 1;
49
+ if (this.#inFlight === 0) {
50
+ for (const resolve of this.#idle.splice(0)) {
51
+ resolve();
52
+ }
53
+ }
54
+ }
55
+ }
56
+ idle() {
57
+ return this.#inFlight === 0 ? Promise.resolve() : new Promise((resolve) => this.#idle.push(resolve));
58
+ }
59
+ }
60
+ export async function awaitToken(sign, clock) {
61
+ const stop = new AbortController();
62
+ const trades = new Trades();
63
+ try {
64
+ const outcome = await Promise.race([
65
+ fromLoopback(sign, clock, trades, stop.signal),
66
+ fromPaste(sign, clock, trades, stop.signal),
67
+ fromPoll(sign, clock, trades, stop.signal),
68
+ ]);
69
+ if (outcome.kind === "error") {
70
+ throw outcome.error;
71
+ }
72
+ return outcome.appToken;
73
+ }
74
+ finally {
75
+ stop.abort();
76
+ }
77
+ }
78
+ // A browser on this computer handed over a code, or Driggsby's denial. Its
79
+ // request waits on the loopback until the trade is done, so it lands on a
80
+ // page that already says how it went. Any page on this computer can reach
81
+ // the loopback, so a callback proves nothing by itself: a code that doesn't
82
+ // trade is refused without saying where the approval page is, and a denial
83
+ // counts only once Driggsby confirms the claim is gone.
84
+ async function fromLoopback(sign, clock, trades, signal) {
85
+ if (sign.loopback === null) {
86
+ return never();
87
+ }
88
+ for (;;) {
89
+ const callback = await sign.loopback.next();
90
+ if (stopped(signal)) {
91
+ // The sign-in already ended; the page says how.
92
+ callback.answer(sign.landingUrl);
93
+ return never();
94
+ }
95
+ if (callback.kind === "denied") {
96
+ const mark = trades.started;
97
+ const poll = await pollLettingTradesSpeak(sign, trades, mark);
98
+ if (poll.kind === "approved") {
99
+ // A server older than PKCE handed this poll the token; it counts,
100
+ // as it does in fromPoll.
101
+ callback.answer(sign.landingUrl);
102
+ return { kind: "token", appToken: poll.appToken };
103
+ }
104
+ if (poll.kind === "gone") {
105
+ // A trade in flight may be what spent the claim; it speaks first.
106
+ await settleTrades(trades, mark);
107
+ callback.answer(sign.landingUrl);
108
+ return stopped(signal) ? never() : { kind: "error", error: declined() };
109
+ }
110
+ callback.refuse();
111
+ continue;
112
+ }
113
+ for (;;) {
114
+ const result = await trades.run(() => tradeWithRetries(sign, callback.code, clock, signal));
115
+ if (stopped(signal)) {
116
+ callback.refuse();
117
+ return never();
118
+ }
119
+ if (result.kind === "approved") {
120
+ callback.answer(sign.landingUrl);
121
+ return { kind: "token", appToken: result.appToken };
122
+ }
123
+ if (result.kind === "rejected") {
124
+ callback.refuse();
125
+ break;
126
+ }
127
+ // Unreachable: the code may still work, and it's the only copy.
128
+ await clock.sleep(LOOPBACK_RETRY_MS, signal);
129
+ if (stopped(signal)) {
130
+ return never();
131
+ }
132
+ }
133
+ }
134
+ }
135
+ // A code pasted at the prompt. A refused code asks again, so a typo costs
136
+ // nothing; the claim's own expiry still ends the wait.
137
+ async function fromPaste(sign, clock, trades, signal) {
138
+ if (sign.prompt === null) {
139
+ return never();
140
+ }
141
+ let question = sign.promptQuestion;
142
+ for (;;) {
143
+ const line = await sign.prompt.ask(question);
144
+ if (stopped(signal) || line === null) {
145
+ return never();
146
+ }
147
+ const code = line.trim();
148
+ if (code === "") {
149
+ continue;
150
+ }
151
+ const result = await trades.run(() => tradeWithRetries(sign, code, clock, signal));
152
+ if (stopped(signal)) {
153
+ return never();
154
+ }
155
+ if (result.kind === "approved") {
156
+ return { kind: "token", appToken: result.appToken };
157
+ }
158
+ question =
159
+ result.kind === "unreachable"
160
+ ? `${result.message}\nPaste the code again to retry: `
161
+ : `${result.message}\nCheck the code and paste it again: `;
162
+ }
163
+ }
164
+ // The claim's own state: declined or expired ends the wait. An approval
165
+ // waiting for its code still polls as pending, so the wait runs past the
166
+ // link's lifetime by up to the code window, until Driggsby says it's gone.
167
+ // Driggsby never hands a PKCE claim's token to the poll; a server that did
168
+ // (one older than PKCE) is still handing it to the CLI that made the claim,
169
+ // so it counts.
170
+ async function fromPoll(sign, clock, trades, signal) {
171
+ const ceiling = sign.deadline + CODE_WINDOW_MS;
172
+ while (clock.now() < ceiling) {
173
+ await clock.sleep(clock.pollIntervalMs, signal);
174
+ if (stopped(signal)) {
175
+ return never();
176
+ }
177
+ const mark = trades.started;
178
+ const result = await pollLettingTradesSpeak(sign, trades, mark);
179
+ if (stopped(signal)) {
180
+ return never();
181
+ }
182
+ if (result.kind === "approved") {
183
+ return { kind: "token", appToken: result.appToken };
184
+ }
185
+ if (result.kind === "gone") {
186
+ await settleTrades(trades, mark);
187
+ if (stopped(signal)) {
188
+ return never();
189
+ }
190
+ return {
191
+ kind: "error",
192
+ error: new ClaimGone("This sign-in link is no longer active — it may have been declined or timed out.\n\n" +
193
+ `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1),
194
+ };
195
+ }
196
+ }
197
+ // A trade still in flight at the ceiling may be the one that spends it.
198
+ await settleTrades(trades, trades.started);
199
+ if (stopped(signal)) {
200
+ return never();
201
+ }
202
+ return {
203
+ kind: "error",
204
+ error: new CliError(`The sign-in link expired before it was approved.\n\nStart a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1),
205
+ };
206
+ }
207
+ // The claim's state. A poll that fails outright (an answer Driggsby never
208
+ // gives) ends the wait, but only after any trade in flight speaks: that
209
+ // trade may be what spent the claim, and its token must not be dropped.
210
+ function pollLettingTradesSpeak(sign, trades, mark) {
211
+ return pollClaimRequest(sign.baseUrl, { claimRequestId: sign.claimRequestId, pollSecret: sign.pollSecret }).catch(async (error) => {
212
+ await settleTrades(trades, mark);
213
+ throw error;
214
+ });
215
+ }
216
+ // Waits out any trade that is in flight, or that started since mark, and
217
+ // then one more turn, so a trade that spent the claim settles the race
218
+ // before a "gone" or "expired" does.
219
+ async function settleTrades(trades, mark) {
220
+ if (!trades.busy() && trades.started === mark) {
221
+ return;
222
+ }
223
+ await trades.idle();
224
+ await new Promise((resolve) => setTimeout(resolve, 0));
225
+ }
226
+ export async function tradeWithRetries(sign, code, clock, signal) {
227
+ if (code === "" || code.length > MAX_CODE_CHARS) {
228
+ return { kind: "rejected", message: CODE_SHAPE_REFUSED };
229
+ }
230
+ for (let attempt = 1;; attempt += 1) {
231
+ if (attempt > 1 && signal !== undefined && stopped(signal)) {
232
+ return { kind: "unreachable", message: UNREACHABLE };
233
+ }
234
+ const result = await tradeCode(sign.baseUrl, {
235
+ claimRequestId: sign.claimRequestId,
236
+ code,
237
+ codeVerifier: sign.codeVerifier,
238
+ });
239
+ if (result.kind !== "transient") {
240
+ return result;
241
+ }
242
+ if (attempt >= TRADE_ATTEMPTS || (signal !== undefined && stopped(signal))) {
243
+ return { kind: "unreachable", message: UNREACHABLE };
244
+ }
245
+ await clock.sleep(TRADE_RETRY_MS, signal);
246
+ }
247
+ }
248
+ // A confirmed denial is the claim gone too, so a login whose sign-in
249
+ // another command finished still tells that apart.
250
+ function declined() {
251
+ return new ClaimGone(`This sign-in was declined.\n\nStart a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
252
+ }
253
+ // Read through a call: the signal flips while this code awaits, which a
254
+ // property check narrowed before the await would not see.
255
+ function stopped(signal) {
256
+ return signal.aborted;
257
+ }
258
+ function never() {
259
+ return new Promise(() => undefined);
260
+ }