driggsby 0.6.2 → 0.7.0

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
@@ -22,11 +22,9 @@ npx driggsby@latest mcp setup other
22
22
  After setup, authenticate Driggsby through your client — run `/mcp` in
23
23
  Claude Code, or sign in through the browser window Codex opens.
24
24
 
25
- So your Driggsby MCP page can tell your connections apart, `login` sends
25
+ So your Driggsby MCP page can tell your CLI sign-ins apart, `login` sends
26
26
  Driggsby this computer's name and operating system version (for example
27
- `mbp-studio` and `macOS 15.6`) with its sign-in, and
28
- `mcp setup claude-code` saves them as headers on the Driggsby entry it
29
- adds to Claude Code, which sends them with each request.
27
+ `mbp-studio` and `macOS 15.6`) with its sign-in.
30
28
 
31
29
  For another MCP client, choose Other. Driggsby currently supports only
32
30
  OAuth-based remote MCP at:
@@ -37,7 +35,9 @@ https://app.driggsby.com/mcp
37
35
 
38
36
  Some driggsby commands work with your Driggsby account directly and need
39
37
  an app token saved on your machine: run
40
- `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
41
41
  stored in your platform keychain when one is available, otherwise in
42
42
  `~/.driggsby/credentials.json`, and is never printed.
43
43
  `npx driggsby@latest logout` removes it.
@@ -69,6 +69,8 @@ can't reach it, but anything running locally can, so stop it when you're
69
69
  done: press Ctrl+C, or run `npx driggsby@latest dev --stop` in the app's
70
70
  folder. It also stops on its own after 30 minutes with no page open.
71
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.
72
74
 
73
75
  ## Deploying an app
74
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;
package/dist/device.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { isIP } from "node:net";
3
3
  import { hostname, platform, release, version } from "node:os";
4
- // Load-bearing: these allowlists are what make the values safe as command
5
- // arguments (spawn-plan.ts quotes them for cmd.exe) and as "Name: value"
6
- // headers. They must never admit % ! ^ & | < > " \ : or a control
7
- // character; device.test.ts checks each.
4
+ // These allowlists keep what leaves the CLI plain text: they never admit
5
+ // % ! ^ & | < > " \ : or a control character (device.test.ts checks each).
8
6
  const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/;
9
7
  const SYSTEM_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,63}$/;
10
8
  const MAC_VERSION_FILE = "/System/Library/CoreServices/SystemVersion.plist";
@@ -38,17 +36,6 @@ export function knownDevice(device) {
38
36
  };
39
37
  return Object.keys(known).length === 0 ? null : known;
40
38
  }
41
- // The Driggsby headers `mcp setup` gives Claude Code, one per known value.
42
- export function deviceHeaders(device) {
43
- const headers = [];
44
- if (device.name !== null) {
45
- headers.push(`X-Driggsby-Device-Name: ${device.name}`);
46
- }
47
- if (device.system !== null) {
48
- headers.push(`X-Driggsby-Device-System: ${device.system}`);
49
- }
50
- return headers;
51
- }
52
39
  // "mbp-studio.local" and "devbox-02.corp.example.test" are mbp-studio and
53
40
  // devbox-02: the first label is the name people give their computer. An
54
41
  // address or localhost names no computer.
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
+ }
@@ -1,8 +1,9 @@
1
1
  // The app-token claim flow's HTTP client, against Driggsby's public
2
- // app-token claim API: create a pending claim, send the user to claim_url,
3
- // poll with the claim id + poll secret until the user approves. The app
4
- // token is returned by exactly one poll; callers must never poll again after
5
- // an approval.
2
+ // app-token claim API: create a pending claim carrying a PKCE challenge,
3
+ // send the user to claim_url, and trade the one-time code the approval
4
+ // produced, with the verifier, for the app token. The poll only tells a
5
+ // waiting sign-in that its claim is gone (denied or expired); Driggsby
6
+ // hands a PKCE claim's token only to the code trade.
6
7
  import { CliError } from "../cli-error.js";
7
8
  import { knownDevice } from "../device.js";
8
9
  import { sanitizeForTerminal } from "../terminal-text.js";
@@ -26,11 +27,18 @@ export const SIGN_IN_START_FAILURE = "We weren't able to start a Driggsby sign-i
26
27
  const SIGN_IN_FINISH_FAILURE = "We weren't able to finish your Driggsby sign-in just now. Please try again\nin a minute.";
27
28
  // device: this computer's name and system (device.ts), so the sign-in shows
28
29
  // up on Driggsby's MCP page as the computer it is; only known values go.
29
- export async function createClaimRequest(baseUrl, device = { name: null, system: null }) {
30
+ export async function createClaimRequest(baseUrl, pkce, device = { name: null, system: null }) {
30
31
  const response = await fetch(`${baseUrl}/app-tokens/claim-requests`, {
31
32
  method: "POST",
32
33
  headers: { "Content-Type": "application/json" },
33
- body: JSON.stringify({ app_name: CLAIM_APP_NAME, scope: CLAIM_SCOPE, ...deviceField(device) }),
34
+ body: JSON.stringify({
35
+ app_name: CLAIM_APP_NAME,
36
+ scope: CLAIM_SCOPE,
37
+ code_challenge: pkce.challenge,
38
+ code_challenge_method: "S256",
39
+ ...(pkce.redirectUri === null ? {} : { redirect_uri: pkce.redirectUri }),
40
+ ...deviceField(device),
41
+ }),
34
42
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
35
43
  // Never follow a redirect: a 307/308 would replay this claim flow —
36
44
  // and, on the poll below, the poll secret — to whatever origin a
@@ -119,6 +127,47 @@ export async function pollClaimRequest(baseUrl, credentials) {
119
127
  return { kind: "transient" };
120
128
  }
121
129
  }
130
+ const CODE_REJECTED = "That sign-in code didn't work. It may have been mistyped, used\nalready, or expired.";
131
+ export async function tradeCode(baseUrl, trade) {
132
+ let response;
133
+ try {
134
+ response = await fetch(`${baseUrl}/app-tokens/claim-requests/token`, {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({
138
+ claim_request_id: trade.claimRequestId,
139
+ code: trade.code,
140
+ code_verifier: trade.codeVerifier,
141
+ }),
142
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
143
+ // The verifier and code must never follow a redirect to another origin.
144
+ redirect: "error",
145
+ });
146
+ }
147
+ catch {
148
+ return { kind: "transient" };
149
+ }
150
+ const body = await readJsonBody(response);
151
+ if (response.status === 200) {
152
+ const appToken = stringField(asRecord(body), "app_token");
153
+ if (appToken === null) {
154
+ throw new CliError(SIGN_IN_FINISH_FAILURE, 1);
155
+ }
156
+ return { kind: "approved", appToken };
157
+ }
158
+ // The CLI says what to do next itself (paste again, or start over), so a
159
+ // refused code reads in its own words.
160
+ // 422 too: a paste Driggsby reads as blank (a Unicode space the CLI
161
+ // doesn't trim) is a wrong code, never the end of the sign-in.
162
+ if (response.status === 400 || response.status === 422) {
163
+ return { kind: "rejected", message: CODE_REJECTED };
164
+ }
165
+ if (response.status === 429 || response.status >= 500) {
166
+ return { kind: "transient" };
167
+ }
168
+ // Any other answer means our request is wrong, which a retry can't fix.
169
+ throw new CliError(errorDescription(body) ?? SIGN_IN_FINISH_FAILURE, 1);
170
+ }
122
171
  async function readJsonBody(response) {
123
172
  let text;
124
173
  try {
@@ -1,42 +1,151 @@
1
- // driggsby login: create an app-token claim, send the user to the approval
2
- // page, poll until they approve, and store the token. The token is shown to
3
- // no one — it goes straight from the poll response into the credential
4
- // store.
1
+ // driggsby login: sign in with PKCE. The CLI keeps a verifier, listens on
2
+ // its loopback when it can, and opens Driggsby's approval page itself.
3
+ // Approving there hands the one-time code to this CLI's loopback;
4
+ // approving from any other browser shows the code, to paste here or to
5
+ // finish with `driggsby login --code <CODE>`. Only the code and the
6
+ // verifier together mint the token, which goes straight into the
7
+ // credential store and is shown to no one.
5
8
  import { apiBaseUrl, apiHost, baseUrlMayReceiveSavedSignIn } from "../api/base-url.js";
6
9
  import { blockedNetworkError } from "../api/network-error.js";
7
10
  import { CliError } from "../cli-error.js";
8
11
  import { describeDevice } from "../device.js";
9
12
  import { defaultCredentialEnvironment, describeStorageLocation, environmentToken, saveToken, } from "../credentials/store.js";
10
13
  import { sanitizeForTerminal, wrapProse } from "../terminal-text.js";
11
- import { createClaimRequest, pollClaimRequest, SIGN_IN_START_FAILURE, } from "./claim-client.js";
14
+ import { awaitToken, ClaimGone, CODE_COMMAND, CODE_WINDOW_MS, LOGIN_RETRY_COMMAND, tradeWithRetries, } from "./await-token.js";
15
+ import { createClaimRequest, SIGN_IN_START_FAILURE } from "./claim-client.js";
16
+ import { startLoopback } from "./loopback.js";
12
17
  import { tryOpenUrl } from "./open-url.js";
13
- const LOGIN_RETRY_COMMAND = "npx driggsby@latest login";
18
+ import { clearPendingLogin, markPendingLoginFinished, readPendingLogin, savePendingLogin, } from "./pending.js";
19
+ import { createPkcePair } from "./pkce.js";
20
+ import { abortableSleep } from "./sleep.js";
21
+ import { terminalCodePrompt } from "./terminal-prompt.js";
14
22
  const DEFAULT_POLL_INTERVAL_MS = 4_000;
23
+ // How long a waiting login gives `login --code` to mark a spent claim.
24
+ export const FINISH_MARK_WAIT_MS = 3_000;
25
+ // Where a browser opened "here" may really run on another computer (an SSH
26
+ // session, a cloud dev box forwarding its opener), so its approval could
27
+ // never reach this loopback: the page shows the code to paste instead.
28
+ const REMOTE_SESSION_VARIABLES = ["SSH_CONNECTION", "SSH_TTY", "CODESPACES", "GITPOD_WORKSPACE_ID"];
15
29
  function defaultLoginIo() {
16
30
  return {
17
31
  out: (text) => {
18
32
  process.stdout.write(text);
19
33
  },
20
34
  openUrl: tryOpenUrl,
21
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
35
+ sleep: abortableSleep,
22
36
  now: () => Date.now(),
23
37
  pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
38
+ startLoopback,
39
+ codePrompt: terminalCodePrompt,
24
40
  };
25
41
  }
26
42
  export async function runLogin(environment = defaultCredentialEnvironment(), io = defaultLoginIo()) {
27
43
  const baseUrl = apiBaseUrl(environment.env);
28
44
  io.out("Sign in to Driggsby\n\n");
29
- const claim = await createClaim(baseUrl);
30
- const claimUrl = approvedClaimUrl(claim.claimUrl, baseUrl);
31
- const browserOpened = await io.openUrl(claimUrl);
32
- io.out(browserOpened
33
- ? "Your browser should open a Driggsby approval page. If it doesn't, open\nthis link:\n\n"
34
- : "Open this link in your browser to approve access for this machine:\n\n");
35
- io.out(` ${claimUrl}\n\n`);
36
- const minutes = approximateMinutes(claim.expiresInSeconds);
37
- const minutesWord = minutes === 1 ? "minute" : "minutes";
38
- io.out(`Waiting for your approval (the link is good for about ${minutes} ${minutesWord})...\n`);
39
- const appToken = await pollUntilApproved(baseUrl, claim, io);
45
+ const pkce = createPkcePair();
46
+ const remoteSession = REMOTE_SESSION_VARIABLES.some((name) => (environment.env[name] ?? "") !== "");
47
+ const loopback = remoteSession ? null : await io.startLoopback();
48
+ let prompt = null;
49
+ try {
50
+ const claim = await createClaim(baseUrl, { challenge: pkce.challenge, redirectUri: loopback?.redirectUri ?? null });
51
+ const claimUrl = approvedClaimUrl(claim.claimUrl, baseUrl);
52
+ const expiresAt = io.now() + claim.expiresInSeconds * 1_000;
53
+ const pending = {
54
+ baseUrl,
55
+ claimRequestId: claim.claimRequestId,
56
+ codeVerifier: pkce.verifier,
57
+ expiresAt: expiresAt + CODE_WINDOW_MS,
58
+ finished: false,
59
+ };
60
+ const remembered = await rememberPendingLogin(environment, pending);
61
+ prompt = io.codePrompt();
62
+ // With no one at a prompt and no loopback, only `login --code` can
63
+ // finish, and it needs the record: refuse before a page opens that
64
+ // nothing could finish.
65
+ if (prompt === null && loopback === null && !remembered) {
66
+ throw cantFinishHere();
67
+ }
68
+ // Only a page this CLI opened itself may hand its code to the
69
+ // loopback; the printed link, opened anywhere, shows the code instead.
70
+ const browserOpened = await io.openUrl(loopback === null ? claimUrl : handoffUrl(claimUrl));
71
+ const minutes = approximateMinutes(claim.expiresInSeconds);
72
+ const minutesWord = minutes === 1 ? "minute" : "minutes";
73
+ // No one at a prompt, and no page this CLI opened can hand the code to
74
+ // the loopback: an agent, or a remote session. Nothing in this process
75
+ // could ever receive the code, so it doesn't wait: the page shows the
76
+ // code, and `login --code` finishes with the record.
77
+ const finishesElsewhere = prompt === null && (loopback === null || !browserOpened);
78
+ if (finishesElsewhere && !remembered) {
79
+ throw cantFinishHere();
80
+ }
81
+ io.out(browserOpened
82
+ ? "Your browser should open a Driggsby approval page. If it doesn't, open\nthis link:\n\n"
83
+ : "Open this link in your browser to approve access for this machine:\n\n");
84
+ io.out(` ${claimUrl}\n\n`);
85
+ if (finishesElsewhere) {
86
+ io.out(`After you approve, the page shows a code. Finish signing in with:\n ${CODE_COMMAND}\n\n`);
87
+ io.out(`The link is good for about ${minutes} ${minutesWord}.\n`);
88
+ return;
89
+ }
90
+ io.out(`Waiting for your approval (the link is good for about ${minutes} ${minutesWord})...\n`);
91
+ if (prompt === null && remembered) {
92
+ io.out(`If the page shows a code instead, finish with:\n ${CODE_COMMAND}\n`);
93
+ }
94
+ const finish = await awaitTokenOrElsewhere(pending, awaitToken({
95
+ baseUrl,
96
+ claimRequestId: claim.claimRequestId,
97
+ pollSecret: claim.pollSecret,
98
+ codeVerifier: pkce.verifier,
99
+ deadline: expiresAt,
100
+ landingUrl: claimUrl,
101
+ loopback,
102
+ prompt,
103
+ promptQuestion: "If the page shows a code instead, paste it here: ",
104
+ }, io), remembered, environment, io);
105
+ prompt?.close();
106
+ prompt = null;
107
+ // The claim is spent either way; only this sign-in's own record goes.
108
+ await forgetPendingLogin(environment, pending, io);
109
+ if (finish.kind === "elsewhere") {
110
+ io.out("\nThis sign-in was finished by npx driggsby@latest login --code in\n" +
111
+ "another command; its output says where the token was saved.\n");
112
+ return;
113
+ }
114
+ await finishSignIn(finish.appToken, environment, io);
115
+ }
116
+ finally {
117
+ prompt?.close();
118
+ loopback?.close();
119
+ }
120
+ }
121
+ // driggsby login --code <CODE>: finish the sign-in waiting on this computer
122
+ // with the code its approval page showed.
123
+ export async function runLoginWithCode(code, environment = defaultCredentialEnvironment(), io = defaultLoginIo()) {
124
+ const baseUrl = apiBaseUrl(environment.env);
125
+ const pending = await readPendingLogin(environment.homeDirectory, io.now());
126
+ if (pending?.baseUrl !== baseUrl) {
127
+ throw new CliError("No sign-in is waiting for a code on this computer. It may have expired.\n\n" +
128
+ `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
129
+ }
130
+ if (pending.finished) {
131
+ throw new CliError(`This sign-in already finished.\n\nTo sign in again:\n ${LOGIN_RETRY_COMMAND}`, 1);
132
+ }
133
+ const result = await tradeWithRetries(pending, code.trim(), io);
134
+ if (result.kind === "unreachable") {
135
+ throw new CliError(`${result.message}\n\nYour code may still work: run the same command again in a minute.\n` +
136
+ `Or start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
137
+ }
138
+ if (result.kind === "rejected") {
139
+ // One record per computer: a newer `login` replaced an older one's.
140
+ throw new CliError(`${result.message}\n\nCheck the code and run the command again. Only the code from the latest\n` +
141
+ `sign-in started on this computer works here. Or start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
142
+ }
143
+ // Marked once the claim is spent, before the token is saved: a login
144
+ // still waiting on this claim sees it finished here, not failed.
145
+ await markFinished(environment, pending, io);
146
+ await finishSignIn(result.appToken, environment, io);
147
+ }
148
+ async function finishSignIn(appToken, environment, io) {
40
149
  // Warnings are held back and printed after the "Approved." line — and when
41
150
  // one fires, it replaces the all-clear closer: the last thing the user
42
151
  // reads must never contradict a warning they were just shown.
@@ -53,6 +162,81 @@ export async function runLogin(environment = defaultCredentialEnvironment(), io
53
162
  }
54
163
  io.out("\nNext:\n You're all set — driggsby commands will use this saved token\n automatically. Deploy an app with npx driggsby@latest deploy.\n");
55
164
  }
165
+ // The record `login --code` finishes from. Best effort: without it, the
166
+ // loopback and the prompt still work. True once it is saved.
167
+ async function rememberPendingLogin(environment, pending) {
168
+ try {
169
+ await savePendingLogin(environment.homeDirectory, pending);
170
+ return true;
171
+ }
172
+ catch {
173
+ // The credential save reports an unwritable home directory itself.
174
+ return false;
175
+ }
176
+ }
177
+ // The wait's token, or "elsewhere" when `login --code` in another command
178
+ // finished this very sign-in meanwhile: the claim reads gone, and this
179
+ // claim's record says finished. Any other failure also ends this sign-in
180
+ // for good, so its record goes with it.
181
+ async function awaitTokenOrElsewhere(pending, wait, remembered, environment, io) {
182
+ try {
183
+ return { kind: "token", appToken: await wait };
184
+ }
185
+ catch (error) {
186
+ if (remembered && error instanceof ClaimGone && (await finishedElsewhere(environment, pending, io))) {
187
+ return { kind: "elsewhere" };
188
+ }
189
+ await forgetPendingLogin(environment, pending, io);
190
+ throw error;
191
+ }
192
+ }
193
+ // `login --code` marks the record just after its trade spends the claim,
194
+ // so a poll can read gone a moment before the mark lands: a record still
195
+ // this claim's but not yet finished is read once more after a pause.
196
+ async function finishedElsewhere(environment, pending, io) {
197
+ for (let look = 1;; look += 1) {
198
+ let current;
199
+ try {
200
+ current = await readPendingLogin(environment.homeDirectory, io.now());
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ if (current?.claimRequestId !== pending.claimRequestId) {
206
+ return false;
207
+ }
208
+ if (current.finished || look >= 2) {
209
+ return current.finished;
210
+ }
211
+ await io.sleep(FINISH_MARK_WAIT_MS);
212
+ }
213
+ }
214
+ async function forgetPendingLogin(environment, pending, io) {
215
+ try {
216
+ await clearPendingLogin(environment.homeDirectory, pending.claimRequestId, io.now());
217
+ }
218
+ catch {
219
+ // It expires on its own.
220
+ }
221
+ }
222
+ async function markFinished(environment, pending, io) {
223
+ try {
224
+ await markPendingLoginFinished(environment.homeDirectory, pending.claimRequestId, io.now());
225
+ }
226
+ catch {
227
+ // Only a login still waiting on this claim reads it, and it then
228
+ // reports the claim gone: the token below is saved all the same.
229
+ }
230
+ }
231
+ function cantFinishHere() {
232
+ return new CliError("We couldn't save this sign-in on this machine to finish it later, so it\n" +
233
+ `can't finish here. Run it in a terminal instead:\n ${LOGIN_RETRY_COMMAND}`, 1);
234
+ }
235
+ function handoffUrl(claimUrl) {
236
+ const url = new URL(claimUrl);
237
+ url.searchParams.set("handoff", "loopback");
238
+ return url.href;
239
+ }
56
240
  // The one-time token is already consumed by the time saving starts, so a
57
241
  // storage failure must say clearly that a fresh sign-in is needed — never
58
242
  // the generic "something went wrong".
@@ -68,12 +252,12 @@ async function storeApprovedToken(appToken, environment, warn) {
68
252
  `on this machine. Please sign in again:\n ${LOGIN_RETRY_COMMAND}`, 1);
69
253
  }
70
254
  }
71
- async function createClaim(baseUrl) {
255
+ async function createClaim(baseUrl, pkce) {
72
256
  try {
73
257
  // This computer's name goes only to Driggsby (or a local run), never
74
258
  // to another host a base-URL override names.
75
259
  const device = baseUrlMayReceiveSavedSignIn(baseUrl) ? describeDevice() : { name: null, system: null };
76
- return await createClaimRequest(baseUrl, device);
260
+ return await createClaimRequest(baseUrl, pkce, device);
77
261
  }
78
262
  catch (error) {
79
263
  const blocked = blockedNetworkError(error, {
@@ -112,27 +296,6 @@ function approvedClaimUrl(rawClaimUrl, baseUrl) {
112
296
  }
113
297
  return sanitizeForTerminal(parsed.href);
114
298
  }
115
- async function pollUntilApproved(baseUrl, claim, io) {
116
- const deadline = io.now() + claim.expiresInSeconds * 1_000;
117
- const credentials = { claimRequestId: claim.claimRequestId, pollSecret: claim.pollSecret };
118
- while (io.now() < deadline) {
119
- await io.sleep(io.pollIntervalMs);
120
- const result = await pollClaimRequest(baseUrl, credentials);
121
- switch (result.kind) {
122
- case "approved":
123
- // The token is delivered exactly once; from here on, never poll again.
124
- return result.appToken;
125
- case "gone":
126
- throw new CliError("This sign-in link is no longer active — it may have been declined or timed out.\n\n" +
127
- `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
128
- case "pending":
129
- case "transient":
130
- break;
131
- }
132
- }
133
- throw new CliError("The sign-in link expired before it was approved.\n\n" +
134
- `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
135
- }
136
299
  // DRIGGSBY_TOKEN outranks every saved token, so a login that saved one has
137
300
  // not actually changed which token commands use. The warning joins the same
138
301
  // held-back channel as storage warnings: after the "Approved." line, in
@@ -0,0 +1,153 @@
1
+ // The loopback listener for driggsby login (RFC 8252). When this CLI opens
2
+ // Driggsby's approval page itself, approving there sends the browser to
3
+ // http://127.0.0.1:<port>/callback with the one-time code (or
4
+ // error=access_denied). A browser on any other computer lands on its own
5
+ // loopback, where nothing listens, so the code reaches only this machine.
6
+ //
7
+ // Any page in any browser on this computer can also reach the loopback, so
8
+ // a callback proves nothing by itself: the caller trades each code (only
9
+ // the approval's real code works with this CLI's verifier) and confirms a
10
+ // denial with Driggsby before believing it. Each callback's browser waits
11
+ // until the caller answers it: a real one goes back to the Driggsby page
12
+ // that says how the sign-in went, and a refused one learns nothing, not
13
+ // even where that page is. Once the sign-in has ended, a callback taken
14
+ // before the listener closes goes back to that page, which by then can only
15
+ // say it is over; one still held when it closes is told it can close the
16
+ // tab.
17
+ import { createServer } from "node:http";
18
+ const HOST = "127.0.0.1";
19
+ const CALLBACK_PATH = "/callback";
20
+ // Driggsby's codes are four groups of five; anything else is not one.
21
+ const CODE_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
22
+ // A browser is never left hanging if a callback goes unanswered.
23
+ const HELD_RESPONSE_MS = 30_000;
24
+ // Callbacks waiting for the caller; more than this is someone knocking.
25
+ const MAX_QUEUED = 32;
26
+ const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
27
+ // null when this environment can't listen on the loopback (a sandbox that
28
+ // forbids it): the approval page then shows the code to paste instead.
29
+ export function startLoopback() {
30
+ return new Promise((resolve) => {
31
+ const queued = [];
32
+ const waiting = [];
33
+ // Each waiting browser, with the timer that answers it if no one does.
34
+ const held = new Map();
35
+ let port = 0;
36
+ const server = createServer((request, response) => {
37
+ const parsed = readCallback(request, port);
38
+ if (parsed === null || queued.length >= MAX_QUEUED) {
39
+ response.writeHead(404, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
40
+ response.end("Not found\n");
41
+ return;
42
+ }
43
+ const settle = (landingUrl, refused) => {
44
+ clearTimeout(held.get(response));
45
+ held.delete(response);
46
+ answer(response, landingUrl, refused);
47
+ };
48
+ held.set(response, setTimeout(() => {
49
+ settle(null, false);
50
+ }, HELD_RESPONSE_MS));
51
+ const reply = {
52
+ answer: (landingUrl) => {
53
+ settle(landingUrl, false);
54
+ },
55
+ refuse: () => {
56
+ settle(null, true);
57
+ },
58
+ };
59
+ const callback = parsed.kind === "code" ? { kind: "code", code: parsed.code, ...reply } : { kind: "denied", ...reply };
60
+ const taker = waiting.shift();
61
+ if (taker === undefined) {
62
+ queued.push(callback);
63
+ }
64
+ else {
65
+ taker(callback);
66
+ }
67
+ });
68
+ let stopped = false;
69
+ const stop = () => {
70
+ if (stopped) {
71
+ return;
72
+ }
73
+ stopped = true;
74
+ for (const [response, timer] of held) {
75
+ clearTimeout(timer);
76
+ answer(response, null, false);
77
+ }
78
+ held.clear();
79
+ server.close();
80
+ server.closeAllConnections();
81
+ };
82
+ // Before listening, an error means this CLI can't listen here; after,
83
+ // any error (an accept failure, say) stops the listener rather than
84
+ // the command.
85
+ let listening = false;
86
+ server.on("error", () => {
87
+ if (listening) {
88
+ stop();
89
+ }
90
+ else {
91
+ resolve(null);
92
+ }
93
+ });
94
+ server.listen(0, HOST, () => {
95
+ const address = server.address();
96
+ if (address === null || typeof address === "string") {
97
+ stop();
98
+ resolve(null);
99
+ return;
100
+ }
101
+ port = address.port;
102
+ listening = true;
103
+ resolve({
104
+ redirectUri: `http://${HOST}:${port}${CALLBACK_PATH}`,
105
+ next: () => {
106
+ const ready = queued.shift();
107
+ return ready === undefined ? new Promise((take) => waiting.push(take)) : Promise.resolve(ready);
108
+ },
109
+ close: stop,
110
+ });
111
+ });
112
+ });
113
+ }
114
+ // Only a GET for /callback, addressed to this very listener (a Host header
115
+ // naming anything else is a DNS-rebinding page, not Driggsby's redirect),
116
+ // carrying a code or Driggsby's denial. Everything else is ignored.
117
+ function readCallback(request, port) {
118
+ if (request.method !== "GET" || request.headers.host !== `${HOST}:${port}`) {
119
+ return null;
120
+ }
121
+ let url;
122
+ try {
123
+ url = new URL(request.url ?? "", `http://${HOST}:${port}`);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ if (url.pathname !== CALLBACK_PATH) {
129
+ return null;
130
+ }
131
+ const code = url.searchParams.get("code");
132
+ if (code !== null && CODE_PATTERN.test(code)) {
133
+ return { kind: "code", code };
134
+ }
135
+ return url.searchParams.get("error") === "access_denied" ? { kind: "denied" } : null;
136
+ }
137
+ function answer(response, landingUrl, refused) {
138
+ if (response.writableEnded) {
139
+ return;
140
+ }
141
+ if (refused) {
142
+ response.writeHead(400, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
143
+ response.end("This sign-in callback wasn't accepted.\n");
144
+ return;
145
+ }
146
+ if (landingUrl === null) {
147
+ response.writeHead(200, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
148
+ response.end("You can close this tab and go back to your terminal.\n");
149
+ return;
150
+ }
151
+ response.writeHead(303, { ...HEADERS, Location: landingUrl });
152
+ response.end();
153
+ }
@@ -0,0 +1,68 @@
1
+ // The sign-in waiting for its code, kept at ~/.driggsby/pending-login.json
2
+ // (owner-only, like the saved token) so `driggsby login --code <CODE>` can
3
+ // finish it from another command: an agent without a terminal prompt runs
4
+ // login, the person approves in a browser elsewhere, and the agent finishes
5
+ // with the code the page showed. The verifier alone mints nothing, and a
6
+ // record is ignored once it expires.
7
+ //
8
+ // Only the command that started a sign-in removes its record. `login
9
+ // --code` marks it finished instead, so a login still waiting on the same
10
+ // claim can tell the sign-in finished there rather than failed.
11
+ import { readFile, rm } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { driggsbyDirectory, writeOwnerOnlyFile } from "../owner-only-file.js";
14
+ const PENDING_FILE_NAME = "pending-login.json";
15
+ export async function savePendingLogin(homeDirectory, pending) {
16
+ const body = {
17
+ base_url: pending.baseUrl,
18
+ claim_request_id: pending.claimRequestId,
19
+ code_verifier: pending.codeVerifier,
20
+ expires_at: pending.expiresAt,
21
+ finished: pending.finished,
22
+ };
23
+ await writeOwnerOnlyFile(homeDirectory, PENDING_FILE_NAME, `${JSON.stringify(body, null, 2)}\n`);
24
+ }
25
+ // null when nothing is waiting, the record is unreadable, or it expired.
26
+ export async function readPendingLogin(homeDirectory, now) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(await readFile(pendingPath(homeDirectory), "utf8"));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (typeof parsed !== "object" || parsed === null) {
35
+ return null;
36
+ }
37
+ const record = parsed;
38
+ const { base_url: baseUrl, claim_request_id: claimRequestId, code_verifier: codeVerifier } = record;
39
+ const expiresAt = record.expires_at;
40
+ if (typeof baseUrl !== "string" ||
41
+ typeof claimRequestId !== "string" ||
42
+ typeof codeVerifier !== "string" ||
43
+ typeof expiresAt !== "number" ||
44
+ expiresAt <= now) {
45
+ return null;
46
+ }
47
+ return { baseUrl, claimRequestId, codeVerifier, expiresAt, finished: record.finished === true };
48
+ }
49
+ // Removes the record only while it is still this claim's: a newer sign-in
50
+ // may have replaced it.
51
+ export async function clearPendingLogin(homeDirectory, claimRequestId, now) {
52
+ const current = await readPendingLogin(homeDirectory, now);
53
+ if (current === null || current.claimRequestId === claimRequestId) {
54
+ await rm(pendingPath(homeDirectory), { force: true });
55
+ }
56
+ }
57
+ // Marks the record finished only while it is still this claim's (a newer
58
+ // sign-in may have replaced it), and drops the spent verifier with it.
59
+ export async function markPendingLoginFinished(homeDirectory, claimRequestId, now) {
60
+ const current = await readPendingLogin(homeDirectory, now);
61
+ if (current?.claimRequestId !== claimRequestId) {
62
+ return;
63
+ }
64
+ await savePendingLogin(homeDirectory, { ...current, codeVerifier: "", finished: true });
65
+ }
66
+ function pendingPath(homeDirectory) {
67
+ return join(driggsbyDirectory(homeDirectory), PENDING_FILE_NAME);
68
+ }
@@ -0,0 +1,14 @@
1
+ // PKCE for driggsby login (RFC 7636, S256). The verifier never leaves this
2
+ // process except to trade the one-time code the approval page produced;
3
+ // Driggsby mints the token only for the code and the verifier together, so
4
+ // whoever else holds the approval link gets nothing from someone's approval.
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ export function createPkcePair() {
7
+ // 32 random bytes are 43 base64url characters: the RFC's minimum length
8
+ // and 256 bits of entropy.
9
+ const verifier = randomBytes(32).toString("base64url");
10
+ return { verifier, challenge: challengeFor(verifier) };
11
+ }
12
+ export function challengeFor(verifier) {
13
+ return createHash("sha256").update(verifier, "ascii").digest("base64url");
14
+ }
@@ -0,0 +1,21 @@
1
+ // A sleep that ends early, quietly, once its signal aborts, so a finished
2
+ // wait never holds the process open. Its abort listener goes when the
3
+ // timer fires too: a wait that sleeps every few seconds on one signal
4
+ // must not pile listeners onto it (Node warns past ten).
5
+ export function abortableSleep(ms, signal) {
6
+ return new Promise((resolve) => {
7
+ if (signal?.aborted === true) {
8
+ resolve();
9
+ return;
10
+ }
11
+ const onAbort = () => {
12
+ clearTimeout(timer);
13
+ resolve();
14
+ };
15
+ const timer = setTimeout(() => {
16
+ signal?.removeEventListener("abort", onAbort);
17
+ resolve();
18
+ }, ms);
19
+ signal?.addEventListener("abort", onAbort, { once: true });
20
+ });
21
+ }
@@ -0,0 +1,51 @@
1
+ // The code prompt for driggsby login on a real terminal: null when stdin or
2
+ // stdout isn't one (an agent running the command). One close listener
3
+ // settles whichever question is open, and closing mid-question ends its
4
+ // line, so what prints next starts on a fresh one. Ctrl-C at the prompt
5
+ // ends the command, as it does everywhere else.
6
+ import { createInterface } from "node:readline";
7
+ import {} from "./await-token.js";
8
+ export function terminalCodePrompt() {
9
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
10
+ return null;
11
+ }
12
+ const lines = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
13
+ let closed = false;
14
+ let open = null;
15
+ lines.on("close", () => {
16
+ closed = true;
17
+ const settle = open;
18
+ open = null;
19
+ settle?.(null);
20
+ });
21
+ lines.on("SIGINT", () => {
22
+ if (open !== null) {
23
+ process.stdout.write("\n");
24
+ }
25
+ lines.close();
26
+ process.kill(process.pid, "SIGINT");
27
+ });
28
+ return {
29
+ ask: (question) => {
30
+ if (closed) {
31
+ return Promise.resolve(null);
32
+ }
33
+ return new Promise((resolve) => {
34
+ open = resolve;
35
+ lines.question(question, (answer) => {
36
+ open = null;
37
+ resolve(answer);
38
+ });
39
+ });
40
+ },
41
+ close: () => {
42
+ if (closed) {
43
+ return;
44
+ }
45
+ if (open !== null) {
46
+ process.stdout.write("\n");
47
+ }
48
+ lines.close();
49
+ },
50
+ };
51
+ }
package/dist/main.js CHANGED
@@ -12,7 +12,7 @@ import { runRollback } from "./deploy/rollback-command.js";
12
12
  import { runVersions } from "./deploy/versions-command.js";
13
13
  import { runQuery } from "./query/query-command.js";
14
14
  import { runRules } from "./rules/rules-command.js";
15
- import { runLogin } from "./login/login.js";
15
+ import { runLogin, runLoginWithCode } from "./login/login.js";
16
16
  import { runLogout } from "./login/logout.js";
17
17
  import { runMcpSetup } from "./mcp-setup/setup.js";
18
18
  import { VERSION } from "./version.js";
@@ -31,7 +31,7 @@ async function run(argv) {
31
31
  await runMcpSetup({ client: command.client, print: command.print, scope: command.scope });
32
32
  return 0;
33
33
  case "login":
34
- await runLogin();
34
+ await (command.code === null ? runLogin() : runLoginWithCode(command.code));
35
35
  return 0;
36
36
  case "logout":
37
37
  return await runLogout();
@@ -19,9 +19,12 @@ function reportsMissingConfig(text) {
19
19
  function matchesExpectedConfig(client, scope, text) {
20
20
  switch (client) {
21
21
  case "claude-code":
22
+ // An entry an earlier version gave device headers doesn't match:
23
+ // Claude Code doesn't send its sign-in on requests to it.
22
24
  return (hasLine(text, "Type: http") &&
23
25
  hasLine(text, `URL: ${DRIGGSBY_MCP_URL}`) &&
24
- hasClaudeScope(text, scope ?? "user"));
26
+ hasClaudeScope(text, scope ?? "user") &&
27
+ !text.includes("X-Driggsby-Device-"));
25
28
  case "codex":
26
29
  return (text.includes('"enabled": true') &&
27
30
  text.includes('"type": "streamable_http"') &&
@@ -1,11 +1,8 @@
1
1
  import {} from "./known-client.js";
2
2
  export const DRIGGSBY_MCP_URL = "https://app.driggsby.com/mcp";
3
- // headers: this computer's name and system for Driggsby's MCP page
4
- // (device.ts builds them from allowlisted values only). Claude Code takes
5
- // them after the URL (--header is variadic, so before the name it would
6
- // swallow it); Codex has no header option, so it never gets any. The
7
- // commands printed for a person to run carry none.
8
- export function buildInstallerCommand(client, scope, headers = []) {
3
+ // The plain entry, with no custom headers: Claude Code doesn't send its
4
+ // sign-in on requests to an entry that carries them.
5
+ export function buildInstallerCommand(client, scope) {
9
6
  switch (client) {
10
7
  case "claude-code":
11
8
  return {
@@ -19,7 +16,6 @@ export function buildInstallerCommand(client, scope, headers = []) {
19
16
  scope ?? "user",
20
17
  "driggsby",
21
18
  DRIGGSBY_MCP_URL,
22
- ...headers.flatMap((header) => ["--header", header]),
23
19
  ],
24
20
  };
25
21
  case "codex":
@@ -1,6 +1,5 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { CliError } from "../cli-error.js";
3
- import { describeDevice, deviceHeaders } from "../device.js";
4
3
  import { trimLikeRust } from "../terminal-text.js";
5
4
  import { runClientCommand } from "./client-command.js";
6
5
  import { classifyExistingMcpConfig } from "./classify.js";
@@ -25,11 +24,7 @@ export async function runMcpSetup(options) {
25
24
  return;
26
25
  }
27
26
  write(`Adding Driggsby to ${displayName(client)} MCP config...\n`);
28
- // The command it runs also names this computer (Claude Code only); the
29
- // commands it prints for a person stay the plain, portable ones.
30
- const headers = installClient === "claude-code" ? deviceHeaders(describeDevice()) : [];
31
- const runInstaller = buildInstallerCommand(installClient, options.scope, headers);
32
- const result = await runClientCommand(runInstaller, streamConfigOutput(client));
27
+ const result = await runClientCommand(installer, streamConfigOutput(client));
33
28
  switch (result.kind) {
34
29
  case "output":
35
30
  if (result.output.succeeded) {
@@ -5,9 +5,8 @@
5
5
  // PATH + PATHEXT (never the current directory, which Windows shells would
6
6
  // search first) and run shims through an explicit, absolute cmd.exe
7
7
  // invocation with our own quoting. Real commands only ever carry fixed
8
- // constant arguments (see commands.ts) or this computer's name and system,
9
- // allowlisted by device.ts to characters cmd.exe treats as plain; nothing
10
- // user-controlled may ever flow into a spawn plan.
8
+ // constant arguments (see commands.ts); nothing user-controlled may ever
9
+ // flow into a spawn plan.
11
10
  import { existsSync } from "node:fs";
12
11
  import { isAbsolute, join } from "node:path";
13
12
  import {} from "./commands.js";
@@ -81,9 +80,8 @@ export function resolveWindowsProgram(program, lookup = defaultLookup()) {
81
80
  // (the cmd convention) and trailing backslashes doubled (so a path ending
82
81
  // in \ cannot escape the closing quote for the child's own parser). The
83
82
  // safe set covers every constant argument the CLI actually runs, including
84
- // the MCP URL; only fixed constants, PATH-resolved program paths, and the
85
- // device values device.ts allowlists (no % ! ^ & | < > " \ or control
86
- // characters) may reach this function.
83
+ // the MCP URL; only fixed constants and PATH-resolved program paths may
84
+ // reach this function.
87
85
  export function quoteForCmd(value) {
88
86
  if (/^[A-Za-z0-9_\-.:\\/=]+$/.test(value)) {
89
87
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driggsby",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Set up and build on Driggsby, the MCP server for personal finance.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "node": ">=18"
29
29
  },
30
30
  "dependencies": {
31
- "@driggsby/deploy": "0.6.2",
32
- "@driggsby/sdk": "0.6.2"
31
+ "@driggsby/deploy": "0.7.0",
32
+ "@driggsby/sdk": "0.7.0"
33
33
  }
34
34
  }