@vincentt-xr/cli 0.13.0 → 0.14.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/dist/cli/index.js CHANGED
@@ -42,6 +42,7 @@ import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
42
42
  import * as feedbackCopy from "../lib/feedbackCopy.js";
43
43
  import { record as recordInvocation } from "../dx/journal.js";
44
44
  import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, } from "../dx/command.js";
45
+ import { httpConsentBackend, runConsentInBrowser } from "../dx/consent.js";
45
46
  import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
46
47
  import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
47
48
  import { postReport } from "../dx/send.js";
@@ -948,6 +949,25 @@ async function version() {
948
949
  console.log(line);
949
950
  }
950
951
  // ---- dx ---------------------------------------------------------------------
952
+ /**
953
+ * The API host + PAT for a dx call, or undefined when signed out.
954
+ *
955
+ * resolveConfig THROWS when there is no PAT, and its message is written for a verb that
956
+ * cannot proceed at all. Everything dx does is optional, so a signed-out creator gets
957
+ * one plain line from the command layer instead of a config-file stack trace. Shared by
958
+ * `enable`, `disable`, and `--send` so all three read the credential identically.
959
+ */
960
+ async function dxAuth() {
961
+ try {
962
+ const cfg = await resolveConfig(projectDir(), {
963
+ allowCustomApi: has("--allow-custom-api"),
964
+ });
965
+ return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
966
+ }
967
+ catch {
968
+ return undefined;
969
+ }
970
+ }
951
971
  async function dx() {
952
972
  const options = parseDxOptions(argv);
953
973
  const now = Date.now();
@@ -956,18 +976,30 @@ async function dx() {
956
976
  // precisely the case where nobody reads what was disclosed.
957
977
  const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
958
978
  if (options.enable || options.disable) {
979
+ const consent = httpConsentBackend();
959
980
  const deps = {
960
981
  print: (l) => console.log(l),
961
982
  now: () => now,
962
983
  interactive,
963
984
  confirm: (q) => stdinConfirm()(` ${q} [y/N] `, false),
964
- // `enable` does NOT require being signed in. --send refuses without a PAT
965
- // regardless, so a config holding only enablement keys is inert; and
966
- // ordering the two verbs ("you must log in before you may state a
967
- // preference") would be a rule with no safety behind it.
985
+ // `enable` NOW REQUIRES being signed in, reversing f7's
986
+ // D-Enable-does-not-require-being-signed-in. The browser path records consent on
987
+ // the ACCOUNT, so there has to be an account to record it against and a credential
988
+ // to reach it with — the precondition is load-bearing now rather than ceremonial.
989
+ auth: dxAuth,
968
990
  save: async (next) => {
969
991
  await writeDxEnablement(next);
970
992
  },
993
+ // The no-TTY consent round trip. Defaults are the real seams; the whole thing is
994
+ // injected as ONE function so dxEnableCommand needs no socket to test.
995
+ consentInBrowser: async () => {
996
+ const { consoleUrl } = await resolveAuthUrls();
997
+ return runConsentInBrowser({
998
+ consoleUrl,
999
+ onProgress: (l) => console.log(l),
1000
+ });
1001
+ },
1002
+ revokeAccountConsent: (auth) => consent.revoke(auth.apiUrl, auth.pat),
971
1003
  };
972
1004
  process.exitCode = options.enable
973
1005
  ? await dxEnableCommand(deps, DX_UNATTENDED_SUNSET)
@@ -990,25 +1022,15 @@ async function dx() {
990
1022
  enabled: () => journalEnabled(),
991
1023
  path: journalPath,
992
1024
  cliVersion,
993
- auth: async () => {
994
- // resolveConfig THROWS when there is no PAT, and its message is written
995
- // for a verb that cannot proceed at all. Sending a DX report is
996
- // optional, so a signed-out creator gets one plain line from
997
- // dxSendCommand instead of a config-file stack trace.
998
- try {
999
- const cfg = await resolveConfig(projectDir(), {
1000
- allowCustomApi: has("--allow-custom-api"),
1001
- });
1002
- return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
1003
- }
1004
- catch {
1005
- return undefined;
1006
- }
1007
- },
1025
+ auth: dxAuth,
1008
1026
  post: (apiUrl, pat, report) => postReport(apiUrl, pat, report),
1009
1027
  confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
1010
1028
  interactive,
1011
1029
  enablement: () => enablement,
1030
+ // The third arm: the account-wide grant, read from /me. Consulted only when the
1031
+ // first two have not already allowed the send, and allowed to throw — a /me that
1032
+ // is unreachable falls back to the other two arms rather than blocking.
1033
+ accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1012
1034
  }, options);
1013
1035
  }
1014
1036
  // ---- assets (f5) ------------------------------------------------------------
@@ -1,3 +1,4 @@
1
+ import type { ConsentDecision } from "./consent.js";
1
2
  import type { Enablement } from "./enablement.js";
2
3
  import type { JournalEntry } from "./journal.js";
3
4
  import type { WireReport } from "./send.js";
@@ -46,30 +47,83 @@ export interface DxEnableDeps {
46
47
  save: (next: {
47
48
  at: string;
48
49
  } | undefined) => Promise<void>;
50
+ /**
51
+ * Resolved API host + PAT, or undefined when signed out.
52
+ *
53
+ * `enable` now REQUIRES this, which REVERSES f7's
54
+ * D-Enable-does-not-require-being-signed-in. That decision reasoned that ordering the
55
+ * two verbs would be "a rule with no safety behind it" — true while the grant lived
56
+ * only in a local file. It no longer does: the browser path records consent against
57
+ * the ACCOUNT, so there has to be an account to record it against, and the CLI has to
58
+ * hold a credential to reach it. The precondition is now load-bearing rather than
59
+ * ceremonial, so the refusal names `vincentt login`.
60
+ */
61
+ auth?: () => Promise<{
62
+ apiUrl: string;
63
+ pat: string;
64
+ } | undefined>;
65
+ /**
66
+ * Run the console consent round trip: open the browser, wait for the loopback
67
+ * callback, and return the human's answer. Injected whole so the command layer needs
68
+ * no socket, no browser, and no clock to test.
69
+ */
70
+ consentInBrowser?: () => Promise<ConsentDecision>;
71
+ /**
72
+ * DELETE /me/dx-consent. Used only by `disable`, and allowed to throw — the caller
73
+ * treats a network failure as "local revoke still stands", never as a reason to leave
74
+ * the capability on.
75
+ */
76
+ revokeAccountConsent?: (auth: {
77
+ apiUrl: string;
78
+ pat: string;
79
+ }) => Promise<void>;
49
80
  }
50
81
  /**
51
- * `vincentt dx enable` — grant this machine durable permission to send DX reports
52
- * without asking first.
82
+ * `vincentt dx enable` — grant durable permission to send DX reports without asking
83
+ * first.
84
+ *
85
+ * TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
86
+ *
87
+ * - At a TTY: the original terminal prompt, unchanged. A person is already here and
88
+ * can read the disclosure where they stand; sending them to a browser would be
89
+ * ceremony.
90
+ * - With no TTY: the BROWSER path. This is the case the feature exists for — a
91
+ * creator driving the CLI through a coding agent has no terminal, so the old code
92
+ * refused and the flow dead-ended. It now opens the console, where a human can
93
+ * actually give consent.
53
94
  *
54
- * The TTY requirement is a SPEED BUMP, NOT A BOUNDARY, and is written down as
55
- * such so a later reader does not build on a guarantee that is not there. It
56
- * stops the reflex — an agent that meets a prompt, infers there must be a way
57
- * past it, and tries the obvious thing. It does not stop a caller that allocates
58
- * a PTY or writes config.json directly.
95
+ * The no-TTY path is no longer a refusal, but that is NOT a relaxation of the gate. The
96
+ * old TTY check was explicitly a SPEED BUMP, NOT A BOUNDARY (it never stopped a caller
97
+ * that allocated a PTY or wrote config.json directly); what replaces it is stronger,
98
+ * because the browser path puts the decision in front of a human on a page the agent
99
+ * cannot see, click, or forge — the agent can start the flow but cannot finish it.
59
100
  *
60
- * The refusal path writes NOTHING. Not "writes the same bytes back" — nothing at
61
- * all: this file holds the PAT, and a refusal that round-trips the config through
62
- * a write can lose the credential if it dies mid-write.
101
+ * The refusal paths still write NOTHING. Not "write the same bytes back" — nothing at
102
+ * all: this file holds the PAT, and a refusal that round-trips the config through a
103
+ * write can lose the credential if it dies mid-write.
63
104
  */
64
105
  export declare function dxEnableCommand(deps: DxEnableDeps, sunset: number): Promise<number>;
65
106
  /**
66
107
  * `vincentt dx disable` — the obvious opposite command.
67
108
  *
68
- * Clears BOTH keys. Leaving the timestamp behind would make a later re-enable
69
- * report its age from the first grant, which is the kind of thing that ships
70
- * wrong and is never noticed. Needs no terminal and no confirmation: turning a
71
- * capability OFF is always safe to do, and requiring a TTY here would strand a
72
- * creator who wanted to revoke from a script.
109
+ * Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
110
+ * with. Both halves are needed: a creator who runs `disable` means "stop sending", and
111
+ * clearing only the local flag would leave the account grant standing, so the very next
112
+ * `--send` would be authorized by arm (c) and the command would appear not to have
113
+ * worked.
114
+ *
115
+ * ORDER IS DELIBERATE: local first, network second. The local clear is the one that
116
+ * cannot fail and the one that stops THIS machine, which is the machine the creator is
117
+ * standing at.
118
+ *
119
+ * A NETWORK FAILURE MUST NEVER BLOCK A REVOKE. Turning a capability off is always safe,
120
+ * so the failure path still clears locally, still exits 0, and says plainly that the
121
+ * account grant may remain and where to clear it. The opposite design — fail the command
122
+ * so the creator "knows it did not work" — leaves them with the capability still on and
123
+ * a non-zero exit that suggests nothing happened at all.
124
+ *
125
+ * Needs no terminal and no confirmation: requiring a TTY here would strand a creator who
126
+ * wanted to revoke from a script, and this is the direction that is always safe.
73
127
  */
74
128
  export declare function dxDisableCommand(deps: DxEnableDeps): Promise<number>;
75
129
  export interface DxSendDeps extends DxDeps {
@@ -95,6 +149,16 @@ export interface DxSendDeps extends DxDeps {
95
149
  interactive: () => boolean;
96
150
  /** Durable unattended-send state, read strictly. Absent reads as not enabled. */
97
151
  enablement?: () => Enablement;
152
+ /**
153
+ * The ACCOUNT's consent, read from GET /me. The third arm of the gate.
154
+ *
155
+ * Allowed to throw, and its throw is not fatal — see the gate below. Absent (the dep
156
+ * not wired at all) reads as not consented, never as "unknown, so allow".
157
+ */
158
+ accountConsent?: (auth: {
159
+ apiUrl: string;
160
+ pat: string;
161
+ }) => Promise<Enablement>;
98
162
  }
99
163
  /**
100
164
  * `vincentt dx --send`.
@@ -131,18 +131,28 @@ function sunsetDate(sunset) {
131
131
  return new Date(sunset).toISOString().slice(0, 10);
132
132
  }
133
133
  /**
134
- * `vincentt dx enable` — grant this machine durable permission to send DX reports
135
- * without asking first.
134
+ * `vincentt dx enable` — grant durable permission to send DX reports without asking
135
+ * first.
136
136
  *
137
- * The TTY requirement is a SPEED BUMP, NOT A BOUNDARY, and is written down as
138
- * such so a later reader does not build on a guarantee that is not there. It
139
- * stops the reflex — an agent that meets a prompt, infers there must be a way
140
- * past it, and tries the obvious thing. It does not stop a caller that allocates
141
- * a PTY or writes config.json directly.
137
+ * TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
142
138
  *
143
- * The refusal path writes NOTHING. Not "writes the same bytes back" — nothing at
144
- * all: this file holds the PAT, and a refusal that round-trips the config through
145
- * a write can lose the credential if it dies mid-write.
139
+ * - At a TTY: the original terminal prompt, unchanged. A person is already here and
140
+ * can read the disclosure where they stand; sending them to a browser would be
141
+ * ceremony.
142
+ * - With no TTY: the BROWSER path. This is the case the feature exists for — a
143
+ * creator driving the CLI through a coding agent has no terminal, so the old code
144
+ * refused and the flow dead-ended. It now opens the console, where a human can
145
+ * actually give consent.
146
+ *
147
+ * The no-TTY path is no longer a refusal, but that is NOT a relaxation of the gate. The
148
+ * old TTY check was explicitly a SPEED BUMP, NOT A BOUNDARY (it never stopped a caller
149
+ * that allocated a PTY or wrote config.json directly); what replaces it is stronger,
150
+ * because the browser path puts the decision in front of a human on a page the agent
151
+ * cannot see, click, or forge — the agent can start the flow but cannot finish it.
152
+ *
153
+ * The refusal paths still write NOTHING. Not "write the same bytes back" — nothing at
154
+ * all: this file holds the PAT, and a refusal that round-trips the config through a
155
+ * write can lose the credential if it dies mid-write.
146
156
  */
147
157
  export async function dxEnableCommand(deps, sunset) {
148
158
  if (deps.now() >= sunset) {
@@ -152,15 +162,26 @@ export async function dxEnableCommand(deps, sunset) {
152
162
  deps.print("");
153
163
  return 1;
154
164
  }
155
- if (!deps.interactive()) {
165
+ // Being signed in is now a PRECONDITION, checked before either path and before any
166
+ // disclosure is printed. This REVERSES f7's D-Enable-does-not-require-being-signed-in
167
+ // — see the note on DxEnableDeps.auth for why that reversal is justified rather than
168
+ // merely convenient: the browser path records consent on the ACCOUNT, so there must be
169
+ // one, and reaching it needs a credential. Checked FIRST because a creator who is
170
+ // signed out cannot complete either path, and reading a consent disclosure before
171
+ // being told so is wasted attention.
172
+ const auth = await deps.auth?.();
173
+ if (auth === undefined) {
156
174
  deps.print("");
157
- deps.print(" Enabling unattended sending needs a terminal to ask in, and this");
158
- deps.print(" session has none. Nothing was changed.");
175
+ deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
176
+ deps.print(" this machine is not signed in. Nothing was changed.");
159
177
  deps.print("");
160
- deps.print(" Run `vincentt dx enable` yourself, in your own terminal.");
178
+ deps.print(" Run `vincentt login` first.");
161
179
  deps.print("");
162
180
  return 1;
163
181
  }
182
+ if (!deps.interactive()) {
183
+ return enableViaBrowser(deps);
184
+ }
164
185
  // What is being granted, INCLUDING the note risk. The note is the part a
165
186
  // creator does not picture: enablement is what lets an agent compose prose they
166
187
  // have not read and put it on the wire.
@@ -187,17 +208,111 @@ export async function dxEnableCommand(deps, sunset) {
187
208
  deps.print("");
188
209
  return 0;
189
210
  }
211
+ /**
212
+ * The no-TTY arm: hand the decision to a human in a browser.
213
+ *
214
+ * The disclosure itself is deliberately NOT printed here. It lives on the console page,
215
+ * where the person deciding will actually see it; printing it into an agent's transcript
216
+ * would put the warning everywhere except in front of the human it is written for. What
217
+ * is printed here is only what a reader of the transcript needs — that a browser is
218
+ * being opened, and the URL.
219
+ *
220
+ * Note the CLI's PAT is NOT passed into this round trip and never rides the console URL.
221
+ * The console holds its own session and POSTs /me/dx-consent itself, so the browser
222
+ * carries only a `state` and a decision. The PAT's role was to prove there is an account
223
+ * at all, which the caller has already checked.
224
+ */
225
+ async function enableViaBrowser(deps) {
226
+ if (deps.consentInBrowser === undefined) {
227
+ // No browser seam wired: refuse rather than silently falling back to a path that
228
+ // asks nobody. Reachable only from a caller that built deps by hand.
229
+ deps.print("");
230
+ deps.print(" Enabling unattended sending needs either a terminal to ask in or a");
231
+ deps.print(" browser to open, and this session has neither. Nothing was changed.");
232
+ deps.print("");
233
+ return 1;
234
+ }
235
+ let decision;
236
+ try {
237
+ decision = await deps.consentInBrowser();
238
+ }
239
+ catch (err) {
240
+ // A timeout, a cancelled window, an unreachable console. Nothing is written on any
241
+ // of them — an enable that could not be confirmed is not an enable.
242
+ deps.print("");
243
+ deps.print(` ✗ ${err instanceof Error ? err.message : String(err)}`);
244
+ deps.print(" Nothing was changed.");
245
+ deps.print("");
246
+ return 1;
247
+ }
248
+ if (decision !== "granted") {
249
+ // Denied writes NOTHING and exits 0. Exit 0 because declining is a successful
250
+ // outcome of the command the caller ran, not a malfunction — an agent that reads a
251
+ // non-zero exit here would reasonably retry, which is how a refused consent prompt
252
+ // becomes a loop the human has to keep dismissing.
253
+ deps.print("");
254
+ deps.print(" Not enabled.");
255
+ deps.print("");
256
+ return 0;
257
+ }
258
+ // The grant is already recorded on the account by the console (the page POSTs
259
+ // /me/dx-consent before it redirects). The local flag is cached alongside it so this
260
+ // machine can decide without a network round trip — and so a later --send is not
261
+ // blocked by an API that happens to be unreachable.
262
+ await deps.save({ at: new Date(deps.now()).toISOString() });
263
+ deps.print("");
264
+ deps.print(" Enabled. Reports may now be sent without a prompt.");
265
+ deps.print(" Turn it off with `vincentt dx disable`, or in the console.");
266
+ deps.print("");
267
+ return 0;
268
+ }
190
269
  /**
191
270
  * `vincentt dx disable` — the obvious opposite command.
192
271
  *
193
- * Clears BOTH keys. Leaving the timestamp behind would make a later re-enable
194
- * report its age from the first grant, which is the kind of thing that ships
195
- * wrong and is never noticed. Needs no terminal and no confirmation: turning a
196
- * capability OFF is always safe to do, and requiring a TTY here would strand a
197
- * creator who wanted to revoke from a script.
272
+ * Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
273
+ * with. Both halves are needed: a creator who runs `disable` means "stop sending", and
274
+ * clearing only the local flag would leave the account grant standing, so the very next
275
+ * `--send` would be authorized by arm (c) and the command would appear not to have
276
+ * worked.
277
+ *
278
+ * ORDER IS DELIBERATE: local first, network second. The local clear is the one that
279
+ * cannot fail and the one that stops THIS machine, which is the machine the creator is
280
+ * standing at.
281
+ *
282
+ * A NETWORK FAILURE MUST NEVER BLOCK A REVOKE. Turning a capability off is always safe,
283
+ * so the failure path still clears locally, still exits 0, and says plainly that the
284
+ * account grant may remain and where to clear it. The opposite design — fail the command
285
+ * so the creator "knows it did not work" — leaves them with the capability still on and
286
+ * a non-zero exit that suggests nothing happened at all.
287
+ *
288
+ * Needs no terminal and no confirmation: requiring a TTY here would strand a creator who
289
+ * wanted to revoke from a script, and this is the direction that is always safe.
198
290
  */
199
291
  export async function dxDisableCommand(deps) {
200
292
  await deps.save(undefined);
293
+ // Signed out: there is no account grant this machine can reach, and nothing to say
294
+ // about one. `logout` already cleared the local flag on its way out.
295
+ const auth = await deps.auth?.();
296
+ if (auth !== undefined && deps.revokeAccountConsent !== undefined) {
297
+ try {
298
+ await deps.revokeAccountConsent(auth);
299
+ }
300
+ catch (err) {
301
+ deps.print("");
302
+ deps.print(" Unattended sending is off on this machine.");
303
+ deps.print("");
304
+ deps.print(` ! Could not reach Vincentt to clear the account-wide grant:`);
305
+ deps.print(` ${err instanceof Error ? err.message : String(err)}`);
306
+ deps.print("");
307
+ deps.print(" If you allowed reports in the console, that grant may still be");
308
+ deps.print(" active for your other machines. Clear it in the console settings.");
309
+ deps.print("");
310
+ // Exit 0: the revoke the creator asked for HAPPENED here, and the part that did
311
+ // not is named with the place to finish it. A non-zero exit would read as "the
312
+ // command failed", which would be false about the machine they are on.
313
+ return 0;
314
+ }
315
+ }
201
316
  deps.print("");
202
317
  deps.print(" Unattended sending is off. Reports need your confirmation again.");
203
318
  deps.print("");
@@ -245,8 +360,45 @@ export async function dxSendCommand(deps, options) {
245
360
  // The second arm is the SUPPORTED unattended path: a person, at a terminal,
246
361
  // ran `vincentt dx enable` on this machine. That is a prior deliberate act
247
362
  // rather than a per-send answer, which is what makes it consent at all.
248
- const enablement = deps.enablement?.() ?? { enabled: false };
363
+ //
364
+ // The THIRD arm is the same kind of prior deliberate act, made in the console and
365
+ // recorded on the ACCOUNT rather than on this machine. It exists because the second
366
+ // arm cannot be reached at all by the creator this feature is for: driving the CLI
367
+ // through a coding agent, there is no terminal to run `dx enable` in.
368
+ const localEnablement = deps.enablement?.() ?? { enabled: false };
249
369
  const interactive = deps.interactive();
370
+ // Auth is resolved BEFORE the gate now, because arm (c) needs a credential to read
371
+ // /me. The signed-out refusal still comes AFTER the gate, so a caller with neither a
372
+ // terminal nor consent gets the refusal about consent — the thing they need to fix —
373
+ // rather than being sent to `login` first and only then told that logging in was not
374
+ // sufficient.
375
+ const auth = await deps.auth();
376
+ // Arm (c) is consulted ONLY when the first two have not already allowed the send.
377
+ // Skipping it otherwise is not just an optimization: it keeps `--send` from making a
378
+ // network call it does not need, so a creator at their own terminal is never blocked
379
+ // by an API being slow or down.
380
+ let accountEnablement = { enabled: false };
381
+ if (!interactive && !localEnablement.enabled && auth !== undefined) {
382
+ try {
383
+ accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
384
+ }
385
+ catch {
386
+ // A NETWORK FAILURE FALLS BACK TO THE OTHER TWO ARMS, and since we only get here
387
+ // when both have already said no, that means refusing. This is the fail-CLOSED
388
+ // direction: an unreachable API can never authorize a send.
389
+ //
390
+ // The swallow is deliberate and is the whole point of the arm's placement — a
391
+ // /me that is down must never BLOCK a send that (a) or (b) already allows, and it
392
+ // cannot, because those paths never reach this call at all.
393
+ accountEnablement = { enabled: false };
394
+ }
395
+ }
396
+ // The grant that authorizes this send, and the one whose age gets reported. The local
397
+ // flag wins when both exist: it is the more specific statement (this machine) and it
398
+ // is the one `dx disable` can always clear.
399
+ const enablement = localEnablement.enabled
400
+ ? localEnablement
401
+ : accountEnablement;
250
402
  if (!interactive && !enablement.enabled) {
251
403
  // This refusal NAMES the enabling command. A refusal is an answer to a
252
404
  // command the caller typed, which makes it SOLICITED output — the
@@ -255,19 +407,18 @@ export async function dxSendCommand(deps, options) {
255
407
  // nothing satisfies the letter of that rule and leaves a human at exit 1
256
408
  // with no path forward, for no security gain: guessability was never the
257
409
  // threat.
258
- deps.print("Sending a report needs your confirmation, and this session has no terminal");
259
- deps.print("to ask in. Run `vincentt dx enable` yourself to allow reports from this");
260
- deps.print("machine, or run `vincentt dx --send` in your own terminal.");
410
+ deps.print("Sending a report needs consent that has not been given on this account.");
411
+ deps.print("`vincentt dx enable` opens the Vincentt console so you can allow it.");
412
+ deps.print("It takes one click, and it works from here.");
261
413
  return 1;
262
414
  }
263
- const auth = await deps.auth();
264
415
  if (auth === undefined) {
265
416
  deps.print("Not signed in. Run `vincentt login` to send a report.");
266
417
  return 1;
267
418
  }
268
419
  const report = buildReport(clusters, deps.cliVersion(), options.note);
269
420
  // Above the payload, on the unattended path only: name that this is enabled,
270
- // how old the grant is, and that nothing is going to ask.
421
+ // how old the grant is, WHERE it was granted, and that nothing is going to ask.
271
422
  const unattended = !interactive && enablement.enabled;
272
423
  if (unattended) {
273
424
  const line = enablementLine(enablement, deps.now());
@@ -0,0 +1,77 @@
1
+ import { type Enablement } from "./enablement.js";
2
+ /**
3
+ * The account-consent seam.
4
+ *
5
+ * `read` returns the parsed Enablement rather than the raw envelope so the strict
6
+ * `=== true` interpretation happens in exactly one place and cannot be re-derived
7
+ * loosely by a second caller.
8
+ */
9
+ export interface ConsentBackend {
10
+ /** GET /me → the account's dxReportsConsent fields, read strictly. */
11
+ read(apiUrl: string, pat: string, now: number): Promise<Enablement>;
12
+ /** POST /me/dx-consent — grant. Idempotent; a re-grant refreshes the timestamp. */
13
+ grant(apiUrl: string, pat: string, now: number): Promise<Enablement>;
14
+ /** DELETE /me/dx-consent — revoke. Never fails on already-revoked. */
15
+ revoke(apiUrl: string, pat: string): Promise<void>;
16
+ }
17
+ /** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
18
+ export declare function httpConsentBackend(): ConsentBackend;
19
+ /**
20
+ * The console URL the browser opens for the consent decision:
21
+ * `<console>/cli-dx-consent?redirect_uri=<loopback>&state=…`.
22
+ *
23
+ * Mirrors buildCliAuthUrl deliberately, including the param NAMES — the console's page
24
+ * reads exactly `redirect_uri` and `state`, and it validates that redirect_uri is a
25
+ * 127.0.0.1 loopback. No PKCE material rides here: unlike login, nothing is being
26
+ * redeemed. The callback carries only a decision, and `state` is what binds that
27
+ * decision to this CLI invocation.
28
+ */
29
+ export declare function buildConsentUrl(consoleBase: string, redirectUri: string, state: string): string;
30
+ export type ConsentDecision = "granted" | "denied";
31
+ export type ConsentCallback = {
32
+ ok: true;
33
+ decision: ConsentDecision;
34
+ } | {
35
+ ok: false;
36
+ reason: "state_mismatch" | "missing_result";
37
+ message: string;
38
+ };
39
+ /**
40
+ * Interpret the loopback callback (`?state=…&result=granted|denied`).
41
+ *
42
+ * State is verified constant-time FIRST, before `result` is even read, so a stray or
43
+ * forged callback can never grant consent — the same discipline as parseCallback, and
44
+ * for a higher-stakes decision: here the forged value would not be a code that still has
45
+ * to survive a PKCE exchange, it would be the answer itself.
46
+ *
47
+ * Anything that is not exactly "granted" reads as DENIED rather than as an error, so an
48
+ * unrecognized result can only ever fail closed.
49
+ */
50
+ export declare function parseConsentCallback(params: URLSearchParams, expectedState: string, stateMatches: (expected: string, actual: string | null) => boolean): ConsentCallback;
51
+ /** Thrown when the human never answers in the browser. */
52
+ export declare class ConsentTimeoutError extends Error {
53
+ constructor();
54
+ }
55
+ export interface ConsentBrowserOptions {
56
+ /** Console origin that serves /cli-dx-consent. */
57
+ consoleUrl: string;
58
+ /** Abort if the browser round trip does not complete in time. Default 5 min. */
59
+ timeoutMs?: number;
60
+ pickPort?: () => Promise<number>;
61
+ openBrowser?: (url: string) => void;
62
+ /** Mint the anti-CSRF state. Injected only so a test can pin it. */
63
+ mintState?: () => string;
64
+ onProgress?: (line: string) => void;
65
+ }
66
+ /**
67
+ * Open the console's consent page and wait for the human's answer.
68
+ *
69
+ * The mechanics are the SHARED loopback helper, not a second copy of it: listener up
70
+ * before the browser, `state` verified constant-time on every callback, a stray callback
71
+ * answered but not accepted, and a bounded wait. The only thing that differs from login
72
+ * is what comes back — a decision instead of an auth code.
73
+ *
74
+ * Nothing is written here. This function reports what the human said; persisting it is
75
+ * the command layer's job, so the "denied writes nothing" rule lives in one place.
76
+ */
77
+ export declare function runConsentInBrowser(opts: ConsentBrowserOptions): Promise<ConsentDecision>;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The ACCOUNT half of unattended-send authorization.
3
+ *
4
+ * `vincentt dx enable` used to require a TTY, which dead-ends the flow this feature is
5
+ * actually for: a creator drives the CLI through a coding agent, the agent has no
6
+ * terminal, and the command refuses. Consent therefore moves to the console, where a
7
+ * human can actually read the disclosure and click — the CLI opens a browser and waits.
8
+ *
9
+ * The grant is PER-ACCOUNT, not per-machine: one decision covers every machine the
10
+ * creator signs in on. That is a wider blast radius than the local flag, which is why
11
+ * the console page carries the full disclosure (including that an agent may compose a
12
+ * note the user has not read) and why the visibility line names the console as the
13
+ * source — a creator who wants it off needs to know that clearing a local flag is not
14
+ * enough.
15
+ *
16
+ * Every call is isolated behind ConsentBackend so the command layer is unit-testable
17
+ * with no network.
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ import { waitForLoopbackCallback } from "../login/loopback.js";
21
+ import { browserPage } from "../login/pkceLogin.js";
22
+ import { stateMatches } from "../login/pkce.js";
23
+ import { getFreePort } from "../preview/net.js";
24
+ import { authFailureMessage } from "../shared/authError.js";
25
+ import { clientHeaders } from "../shared/clientHeader.js";
26
+ import { readAccountConsent } from "./enablement.js";
27
+ async function safeText(res) {
28
+ try {
29
+ return (await res.text()).slice(0, 300);
30
+ }
31
+ catch {
32
+ return "";
33
+ }
34
+ }
35
+ /**
36
+ * Unwrap the `meEnvelope` GET /me and both /me/dx-consent verbs return.
37
+ *
38
+ * The envelope is `{ me: {...}, csrfToken }`, but a bare user object is tolerated for the
39
+ * same reason normalizeMe tolerates it: a contract wording change should not silently
40
+ * turn a granted account into a refusal. Note the tolerance is on the SHAPE ONLY — the
41
+ * consent VALUE is still read `=== true`, so a resilient unwrap never becomes a lenient
42
+ * authorization read.
43
+ */
44
+ function unwrapMe(raw) {
45
+ const envelope = (raw ?? {});
46
+ return envelope.me && typeof envelope.me === "object" ? envelope.me : envelope;
47
+ }
48
+ /** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
49
+ export function httpConsentBackend() {
50
+ const strip = (u) => u.replace(/\/$/, "");
51
+ return {
52
+ async read(apiUrl, pat, now) {
53
+ const res = await fetch(`${strip(apiUrl)}/me`, {
54
+ headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
55
+ });
56
+ if (!res.ok) {
57
+ throw new Error(authFailureMessage("Could not read your account", res.status, await safeText(res)));
58
+ }
59
+ return readAccountConsent(unwrapMe(await res.json()), now);
60
+ },
61
+ async grant(apiUrl, pat, now) {
62
+ const res = await fetch(`${strip(apiUrl)}/me/dx-consent`, {
63
+ method: "POST",
64
+ headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
65
+ });
66
+ if (!res.ok) {
67
+ throw new Error(authFailureMessage("Could not record consent", res.status, await safeText(res)));
68
+ }
69
+ return readAccountConsent(unwrapMe(await res.json()), now);
70
+ },
71
+ async revoke(apiUrl, pat) {
72
+ const res = await fetch(`${strip(apiUrl)}/me/dx-consent`, {
73
+ method: "DELETE",
74
+ headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
75
+ });
76
+ if (!res.ok) {
77
+ throw new Error(authFailureMessage("Could not revoke consent", res.status, await safeText(res)));
78
+ }
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * The console URL the browser opens for the consent decision:
84
+ * `<console>/cli-dx-consent?redirect_uri=<loopback>&state=…`.
85
+ *
86
+ * Mirrors buildCliAuthUrl deliberately, including the param NAMES — the console's page
87
+ * reads exactly `redirect_uri` and `state`, and it validates that redirect_uri is a
88
+ * 127.0.0.1 loopback. No PKCE material rides here: unlike login, nothing is being
89
+ * redeemed. The callback carries only a decision, and `state` is what binds that
90
+ * decision to this CLI invocation.
91
+ */
92
+ export function buildConsentUrl(consoleBase, redirectUri, state) {
93
+ const base = /^https?:\/\//i.test(consoleBase)
94
+ ? consoleBase
95
+ : `${/^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(consoleBase) ? "http" : "https"}://${consoleBase}`;
96
+ const u = new URL("/cli-dx-consent", base);
97
+ u.searchParams.set("redirect_uri", redirectUri);
98
+ u.searchParams.set("state", state);
99
+ return u.toString();
100
+ }
101
+ /**
102
+ * Interpret the loopback callback (`?state=…&result=granted|denied`).
103
+ *
104
+ * State is verified constant-time FIRST, before `result` is even read, so a stray or
105
+ * forged callback can never grant consent — the same discipline as parseCallback, and
106
+ * for a higher-stakes decision: here the forged value would not be a code that still has
107
+ * to survive a PKCE exchange, it would be the answer itself.
108
+ *
109
+ * Anything that is not exactly "granted" reads as DENIED rather than as an error, so an
110
+ * unrecognized result can only ever fail closed.
111
+ */
112
+ export function parseConsentCallback(params, expectedState, stateMatches) {
113
+ if (!stateMatches(expectedState, params.get("state"))) {
114
+ return {
115
+ ok: false,
116
+ reason: "state_mismatch",
117
+ message: "State mismatch — ignoring an unexpected callback.",
118
+ };
119
+ }
120
+ const result = params.get("result");
121
+ if (result === null) {
122
+ return {
123
+ ok: false,
124
+ reason: "missing_result",
125
+ message: "The browser returned no answer.",
126
+ };
127
+ }
128
+ return { ok: true, decision: result === "granted" ? "granted" : "denied" };
129
+ }
130
+ /** Thrown when the human never answers in the browser. */
131
+ export class ConsentTimeoutError extends Error {
132
+ constructor() {
133
+ super("Timed out waiting for an answer in the browser (5 minutes).");
134
+ this.name = "ConsentTimeoutError";
135
+ }
136
+ }
137
+ /**
138
+ * Open the console's consent page and wait for the human's answer.
139
+ *
140
+ * The mechanics are the SHARED loopback helper, not a second copy of it: listener up
141
+ * before the browser, `state` verified constant-time on every callback, a stray callback
142
+ * answered but not accepted, and a bounded wait. The only thing that differs from login
143
+ * is what comes back — a decision instead of an auth code.
144
+ *
145
+ * Nothing is written here. This function reports what the human said; persisting it is
146
+ * the command layer's job, so the "denied writes nothing" rule lives in one place.
147
+ */
148
+ export async function runConsentInBrowser(opts) {
149
+ // 24 bytes of CSPRNG, matching the login flow's state. This is the only thing binding
150
+ // the browser's answer to this invocation, so it is minted per call and never reused.
151
+ const state = (opts.mintState ?? defaultState)();
152
+ return waitForLoopbackCallback({
153
+ pickPort: opts.pickPort ?? getFreePort,
154
+ buildUrl: (redirectUri) => buildConsentUrl(opts.consoleUrl, redirectUri, state),
155
+ openBrowser: opts.openBrowser ?? defaultOpenBrowser,
156
+ timeoutMs: opts.timeoutMs ?? 5 * 60_000,
157
+ onTimeout: () => new ConsentTimeoutError(),
158
+ onProgress: opts.onProgress,
159
+ parse: (params) => {
160
+ const result = parseConsentCallback(params, state, stateMatches);
161
+ if (result.ok) {
162
+ const granted = result.decision === "granted";
163
+ return {
164
+ ok: true,
165
+ value: result.decision,
166
+ page: {
167
+ status: 200,
168
+ html: browserPage(granted, granted ? "Reports allowed" : "Not enabled", "Return to your terminal — you can close this tab."),
169
+ },
170
+ };
171
+ }
172
+ return {
173
+ ok: false,
174
+ // A state mismatch keeps waiting: it is a stray or forged callback, and letting
175
+ // it end the flow would let anyone who can reach the loopback port cancel a
176
+ // legitimate consent round trip.
177
+ retry: result.reason === "state_mismatch",
178
+ message: result.message,
179
+ page: {
180
+ status: 400,
181
+ html: browserPage(false, "Something went wrong", result.message),
182
+ },
183
+ };
184
+ },
185
+ });
186
+ }
187
+ function defaultState() {
188
+ return randomBytes(24)
189
+ .toString("base64")
190
+ .replace(/\+/g, "-")
191
+ .replace(/\//g, "_")
192
+ .replace(/=+$/, "");
193
+ }
194
+ function defaultOpenBrowser(url) {
195
+ // Lazy import keeps this module importable in tests that never open a browser.
196
+ void Promise.all([import("node:child_process"), import("../login/pkceLogin.js")]).then(([{ spawn }, { launchBrowser }]) => launchBrowser(url, spawn));
197
+ }
@@ -10,10 +10,22 @@
10
10
  * raise.
11
11
  */
12
12
  export declare const DX_UNATTENDED_SUNSET: number;
13
+ /**
14
+ * WHERE the grant was made. Consent can now be given in the console (per-ACCOUNT, so it
15
+ * covers every machine the creator signs in on) or at this terminal (per-MACHINE).
16
+ *
17
+ * The source is carried rather than inferred because the two have different blast radii
18
+ * and different places to revoke. A creator reading `ENABLED` on a machine they never
19
+ * ran `dx enable` on has no way to act on it without being told the grant lives in their
20
+ * account — they would go looking for a local flag that is not there.
21
+ */
22
+ export type EnablementSource = "console" | "machine";
13
23
  export interface Enablement {
14
24
  enabled: boolean;
15
25
  /** The raw ISO8601 grant time. Present only when `enabled` is true. */
16
26
  at?: string;
27
+ /** Where the grant came from. Present only when `enabled` is true. */
28
+ source?: EnablementSource;
17
29
  }
18
30
  /**
19
31
  * Decide enablement from an already-parsed config object. Pure, and STRICT:
@@ -26,6 +38,18 @@ export interface Enablement {
26
38
  * that reads a creator's "off" as "on" is inverted, not merely loose.
27
39
  */
28
40
  export declare function readEnablement(cfg: unknown, now: number): Enablement;
41
+ /**
42
+ * Decide enablement from the ACCOUNT's `/me` fields, with the same strictness as the
43
+ * local read.
44
+ *
45
+ * `=== true` is not defensive noise here either: this value crosses the wire, so it
46
+ * arrives as whatever JSON the server (or anything between) actually sent. A truthiness
47
+ * read would treat the string "false" — the exact shape a serializer bug or a hand-rolled
48
+ * proxy produces — as consent. The sunset applies identically: past it, an account grant
49
+ * stops being honored too, or "forgetting the sunset turns the feature off" would only be
50
+ * true of the local half.
51
+ */
52
+ export declare function readAccountConsent(raw: unknown, now: number): Enablement;
29
53
  /**
30
54
  * Read enablement from the real machine config.
31
55
  *
@@ -42,10 +66,20 @@ export declare function grantedOn(at: string): string;
42
66
  /**
43
67
  * The one visibility line, in the single pinned shape every human surface uses:
44
68
  *
45
- * Unattended send: ENABLED since 2026-04-01 (145 days)
69
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
70
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
46
71
  *
47
72
  * Returns undefined when not enabled. Nothing is printed in that case, on
48
73
  * purpose: a line that prints unconditionally is a line readers learn to skip,
49
74
  * which would cost exactly the attention the enabled line needs.
75
+ *
76
+ * The AGE stays exactly where it was — it remains the entire mitigation for having no
77
+ * expiry, and the source is appended AFTER it rather than woven in, so the number a
78
+ * reader is meant to notice is not pushed off the front of the line.
79
+ *
80
+ * The source clause answers the question the age raises: having seen "145 days", the
81
+ * creator's next move is to turn it off, and that is a different place depending on
82
+ * where it was granted. A line that says ENABLED without saying where sends someone
83
+ * looking for a local flag that may not exist.
50
84
  */
51
85
  export declare function enablementLine(e: Enablement, now: number): string | undefined;
@@ -1,11 +1,16 @@
1
1
  /**
2
2
  * Durable authorization for unattended `vincentt dx --send`.
3
3
  *
4
- * `--send` transmits only when the session is interactive OR this machine has
5
- * been explicitly enabled by a person, at a terminal, via `vincentt dx enable`.
6
- * The state is two fields in ~/.vincentt/config.json, set by `enable`, cleared by
7
- * `disable` and by `logout`, and it has NO expiry — it stays on until the creator
8
- * turns it off.
4
+ * `--send` transmits only when the session is interactive, OR this MACHINE has been
5
+ * enabled via `vincentt dx enable`, OR the creator's ACCOUNT carries the grant they
6
+ * gave in the console. `enable` collects that consent in the browser by default and
7
+ * at the terminal when there is one; either way a person answers, never the caller.
8
+ *
9
+ * The machine half is two fields in ~/.vincentt/config.json, set by `enable`, cleared
10
+ * by `disable` and by `logout`. The account half is read from `/me` and cleared by
11
+ * `disable` or in the console — NOT by `logout`, since signing out of one machine is
12
+ * not a decision about the account. Neither has an expiry: both stay on until the
13
+ * creator turns them off.
9
14
  *
10
15
  * Because there is no expiry, VISIBILITY is the entire mitigation, and the AGE is
11
16
  * the load-bearing half of it: `ENABLED` alone reads as configuration, while
@@ -50,7 +55,35 @@ export function readEnablement(cfg, now) {
50
55
  return DISABLED;
51
56
  if (now >= DX_UNATTENDED_SUNSET)
52
57
  return DISABLED;
53
- return { enabled: true, at: dxSendEnabledAt };
58
+ // The local flag is by definition a grant made on THIS machine — `dx enable` is the
59
+ // only writer, whether it took the browser path or the terminal one. An account grant
60
+ // never lands in this file; it is read from /me and reported separately.
61
+ return { enabled: true, at: dxSendEnabledAt, source: "machine" };
62
+ }
63
+ /**
64
+ * Decide enablement from the ACCOUNT's `/me` fields, with the same strictness as the
65
+ * local read.
66
+ *
67
+ * `=== true` is not defensive noise here either: this value crosses the wire, so it
68
+ * arrives as whatever JSON the server (or anything between) actually sent. A truthiness
69
+ * read would treat the string "false" — the exact shape a serializer bug or a hand-rolled
70
+ * proxy produces — as consent. The sunset applies identically: past it, an account grant
71
+ * stops being honored too, or "forgetting the sunset turns the feature off" would only be
72
+ * true of the local half.
73
+ */
74
+ export function readAccountConsent(raw, now) {
75
+ if (typeof raw !== "object" || raw === null)
76
+ return DISABLED;
77
+ const { dxReportsConsent, dxReportsConsentAt } = raw;
78
+ if (dxReportsConsent !== true)
79
+ return DISABLED;
80
+ if (typeof dxReportsConsentAt !== "string")
81
+ return DISABLED;
82
+ if (!Number.isFinite(Date.parse(dxReportsConsentAt)))
83
+ return DISABLED;
84
+ if (now >= DX_UNATTENDED_SUNSET)
85
+ return DISABLED;
86
+ return { enabled: true, at: dxReportsConsentAt, source: "console" };
54
87
  }
55
88
  /**
56
89
  * Read enablement from the real machine config.
@@ -83,15 +116,30 @@ export function grantedOn(at) {
83
116
  /**
84
117
  * The one visibility line, in the single pinned shape every human surface uses:
85
118
  *
86
- * Unattended send: ENABLED since 2026-04-01 (145 days)
119
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
120
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
87
121
  *
88
122
  * Returns undefined when not enabled. Nothing is printed in that case, on
89
123
  * purpose: a line that prints unconditionally is a line readers learn to skip,
90
124
  * which would cost exactly the attention the enabled line needs.
125
+ *
126
+ * The AGE stays exactly where it was — it remains the entire mitigation for having no
127
+ * expiry, and the source is appended AFTER it rather than woven in, so the number a
128
+ * reader is meant to notice is not pushed off the front of the line.
129
+ *
130
+ * The source clause answers the question the age raises: having seen "145 days", the
131
+ * creator's next move is to turn it off, and that is a different place depending on
132
+ * where it was granted. A line that says ENABLED without saying where sends someone
133
+ * looking for a local flag that may not exist.
91
134
  */
92
135
  export function enablementLine(e, now) {
93
136
  if (!e.enabled || e.at === undefined)
94
137
  return undefined;
95
138
  const days = ageInDays(e.at, now);
96
- return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})`;
139
+ const where = e.source === "console"
140
+ ? " · granted in the console"
141
+ : e.source === "machine"
142
+ ? " · granted on this machine"
143
+ : "";
144
+ return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})${where}`;
97
145
  }
@@ -0,0 +1,72 @@
1
+ /** Thrown when the wait elapses with no answer from the browser. */
2
+ export declare class LoopbackTimeoutError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Thrown when the callback is a terminal failure (a cancel, a malformed answer). */
6
+ export declare class LoopbackCallbackError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /**
10
+ * What the caller's parser decided about one callback.
11
+ *
12
+ * `retry` is the third state and the reason this is not a boolean: a state mismatch is
13
+ * a stray or forged callback, and the right response is to answer it and KEEP WAITING
14
+ * for the genuine one. Treating it as a failure would let anyone who can reach the
15
+ * loopback port cancel a legitimate consent flow.
16
+ */
17
+ export type CallbackDecision<T> = {
18
+ ok: true;
19
+ value: T;
20
+ page: LoopbackPage;
21
+ } | {
22
+ ok: false;
23
+ retry: boolean;
24
+ message: string;
25
+ page: LoopbackPage;
26
+ };
27
+ export interface LoopbackPage {
28
+ status: number;
29
+ html: string;
30
+ }
31
+ export interface LoopbackWaitOptions<T> {
32
+ /** Pick a free loopback port. Injected so tests never race a real ephemeral picker. */
33
+ pickPort: () => Promise<number>;
34
+ /** Build the console URL the browser opens, given the loopback redirect_uri. */
35
+ buildUrl: (redirectUri: string) => string;
36
+ /** Launch the browser. A no-op fake in tests; never fatal in production. */
37
+ openBrowser: (url: string) => void;
38
+ /** Interpret one callback's query params. Must verify `state` itself. */
39
+ parse: (params: URLSearchParams) => CallbackDecision<T>;
40
+ /** Abort if the round trip does not complete in time. */
41
+ timeoutMs: number;
42
+ /** Built when the wait times out, so each flow words its own transcript. */
43
+ onTimeout: () => Error;
44
+ /**
45
+ * Built when a callback is a TERMINAL failure. Injected for the same reason as
46
+ * onTimeout: each flow keeps its own error type, so callers can keep matching on
47
+ * theirs rather than on a shared one this extraction happened to introduce.
48
+ */
49
+ onCallbackError?: (message: string) => Error;
50
+ /** Narration (the "→ Opening…" / printed-URL / "Waiting…" lines). */
51
+ onProgress?: (line: string) => void;
52
+ /** The loopback path the console redirects to. Defaults to /callback. */
53
+ path?: string;
54
+ }
55
+ /**
56
+ * Run one loopback round trip and resolve with whatever `parse` extracted.
57
+ *
58
+ * Ordering is security-load-bearing and is the reason this function exists at all:
59
+ * 1. pick the port and START LISTENING,
60
+ * 2. only then open the browser + print the fallback URL,
61
+ * 3. verify `state` on every callback before reading anything else from it,
62
+ * 4. bound the wait.
63
+ */
64
+ export declare function waitForLoopbackCallback<T>(opts: LoopbackWaitOptions<T>): Promise<T>;
65
+ /**
66
+ * Verify `state` constant-time and hand back the remaining params.
67
+ *
68
+ * State is checked FIRST, always, before any other field is read, so a forged or stray
69
+ * callback can never drive a decision. Shared with pkce.parseCallback's discipline; kept
70
+ * here as a helper so a caller writing its own `parse` cannot forget it.
71
+ */
72
+ export declare function verifiedState(params: URLSearchParams, expected: string): boolean;
@@ -0,0 +1,119 @@
1
+ // The browser round-trip primitive: start a loopback listener on 127.0.0.1, open the
2
+ // browser at a console URL, and wait for the console to redirect back with a verified
3
+ // `state`.
4
+ //
5
+ // EXTRACTED from pkceLogin.ts rather than copied. `vincentt login` (PKCE) and
6
+ // `vincentt dx enable` (account consent) are two flows with the same security-critical
7
+ // skeleton, and the skeleton is exactly the part that must not drift between them: the
8
+ // listener comes up BEFORE the browser (a browser that redirects to a port nobody is
9
+ // listening on loses the answer), `state` is verified constant-time on every callback,
10
+ // a mismatch is answered but NOT accepted, and the wait is bounded. Two copies of that
11
+ // would mean a fix applied to one and not the other — and the un-fixed one is a consent
12
+ // gate.
13
+ //
14
+ // Every external boundary stays injected (port picker, listen, browser open, clock) so
15
+ // callers are unit-testable with no real socket.
16
+ import { createServer } from "node:http";
17
+ import { stateMatches } from "./pkce.js";
18
+ /** Thrown when the wait elapses with no answer from the browser. */
19
+ export class LoopbackTimeoutError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "LoopbackTimeoutError";
23
+ }
24
+ }
25
+ /** Thrown when the callback is a terminal failure (a cancel, a malformed answer). */
26
+ export class LoopbackCallbackError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "LoopbackCallbackError";
30
+ }
31
+ }
32
+ /**
33
+ * Run one loopback round trip and resolve with whatever `parse` extracted.
34
+ *
35
+ * Ordering is security-load-bearing and is the reason this function exists at all:
36
+ * 1. pick the port and START LISTENING,
37
+ * 2. only then open the browser + print the fallback URL,
38
+ * 3. verify `state` on every callback before reading anything else from it,
39
+ * 4. bound the wait.
40
+ */
41
+ export function waitForLoopbackCallback(opts) {
42
+ const callbackPath = opts.path ?? "/callback";
43
+ return new Promise((resolve, reject) => {
44
+ void (async () => {
45
+ let port;
46
+ try {
47
+ port = await opts.pickPort();
48
+ }
49
+ catch (err) {
50
+ reject(err);
51
+ return;
52
+ }
53
+ const redirectUri = `http://127.0.0.1:${port}${callbackPath}`;
54
+ const url = opts.buildUrl(redirectUri);
55
+ const progress = opts.onProgress ?? (() => { });
56
+ const server = createServer((req, res) => {
57
+ // A received request always carries a url; the base makes it absolute for parsing.
58
+ const parsed = new URL(req.url, `http://127.0.0.1:${port}`);
59
+ if (parsed.pathname !== callbackPath) {
60
+ res.writeHead(404, { "content-type": "text/plain" });
61
+ res.end("Not found");
62
+ return;
63
+ }
64
+ const decision = opts.parse(parsed.searchParams);
65
+ res.writeHead(decision.page.status, {
66
+ "content-type": "text/html; charset=utf-8",
67
+ });
68
+ res.end(decision.page.html);
69
+ if (decision.ok) {
70
+ server.close();
71
+ resolve(decision.value);
72
+ }
73
+ else if (!decision.retry) {
74
+ server.close();
75
+ reject(opts.onCallbackError?.(decision.message) ??
76
+ new LoopbackCallbackError(decision.message));
77
+ }
78
+ // retry === true: answered, still listening for the genuine callback.
79
+ });
80
+ server.on("error", reject);
81
+ server.listen(port, "127.0.0.1", () => {
82
+ for (const line of progressLines(url))
83
+ progress(line);
84
+ opts.openBrowser(url);
85
+ });
86
+ const timer = setTimeout(() => {
87
+ server.close();
88
+ reject(opts.onTimeout());
89
+ }, opts.timeoutMs);
90
+ timer.unref();
91
+ })();
92
+ });
93
+ }
94
+ /**
95
+ * The narration, including the printed URL.
96
+ *
97
+ * The URL is printed on EVERY run, not only when the open fails, because the CLI cannot
98
+ * tell whether a browser actually appeared — `open`/`xdg-open` exits 0 on a headless box
99
+ * with nothing to show. On the agent-driven flow this whole feature exists for, the
100
+ * printed URL is frequently the only way the human ever reaches the page.
101
+ */
102
+ function progressLines(url) {
103
+ return [
104
+ "→ Opening your browser…",
105
+ ` If it doesn't open, visit: ${url}`,
106
+ "",
107
+ " Waiting for you to answer in the browser…",
108
+ ];
109
+ }
110
+ /**
111
+ * Verify `state` constant-time and hand back the remaining params.
112
+ *
113
+ * State is checked FIRST, always, before any other field is read, so a forged or stray
114
+ * callback can never drive a decision. Shared with pkce.parseCallback's discipline; kept
115
+ * here as a helper so a caller writing its own `parse` cannot forget it.
116
+ */
117
+ export function verifiedState(params, expected) {
118
+ return stateMatches(expected, params.get("state"));
119
+ }
@@ -43,6 +43,7 @@ type SpawnLike = (cmd: string, args: string[], opts: {
43
43
  };
44
44
  /** Launch the browser via an injected spawn; a throw is swallowed (fallback = the printed URL). */
45
45
  export declare function launchBrowser(url: string, spawn: SpawnLike, platform?: string): void;
46
+ export declare function browserPage(ok: boolean, heading: string, body: string): string;
46
47
  /**
47
48
  * Run the loopback + PKCE exchange, persist the PAT, and confirm identity via /me.
48
49
  * Resolves with the confirmed identity + the config path; rejects with LoginTimeoutError
@@ -11,11 +11,11 @@
11
11
  // Every external boundary is injected (getFreePort, listen, openBrowser, backend, clock/
12
12
  // timeout, config write) so the whole flow is unit-testable against a stub backend + fake
13
13
  // browser + temp config dir, with no real socket or network.
14
- import { createServer } from "node:http";
15
14
  import { getFreePort } from "../preview/net.js";
16
15
  import { writeMachineConfig } from "../shared/config.js";
17
16
  import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
18
17
  import { httpAuthBackend } from "./authBackend.js";
18
+ import { waitForLoopbackCallback } from "./loopback.js";
19
19
  /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
20
20
  export class LoginTimeoutError extends Error {
21
21
  constructor() {
@@ -57,7 +57,10 @@ function defaultOpenBrowser(url) {
57
57
  // AuthLayout auth-card is mirrored here token-for-token: --color-sand page, white
58
58
  // --color-surface card (rounded-2xl, p-7), the terminal glyph on --color-primary-faint,
59
59
  // and the light/dark palette. A subtle green/red tints only the glyph on ok/failure.
60
- function browserPage(ok, heading, body) {
60
+ //
61
+ // Exported so `dx enable`'s consent round trip renders the SAME page rather than growing
62
+ // a second, drifting copy of the console's card.
63
+ export function browserPage(ok, heading, body) {
61
64
  // The terminal glyph the console uses on the Grant-CLI screen (icons.terminal).
62
65
  const glyph = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
63
66
  'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
@@ -102,51 +105,44 @@ export async function runPkceLogin(opts) {
102
105
  ((apiUrl, pat) => writeMachineConfig({ apiUrl, pat, consoleUrl: opts.consoleUrl }));
103
106
  const progress = opts.onProgress ?? (() => { });
104
107
  const material = makePkceMaterial();
105
- const port = await pickPort();
106
- const redirectUri = `http://127.0.0.1:${port}/callback`;
107
- const authUrl = buildCliAuthUrl(opts.consoleUrl, redirectUri, material);
108
- // (4) await the loopback callback; verify state; hand back the auth_code.
109
- const code = await new Promise((resolve, reject) => {
110
- const server = createServer((req, res) => {
111
- // A received request always carries a url; the base makes it absolute for parsing.
112
- const url = new URL(req.url, `http://127.0.0.1:${port}`);
113
- if (url.pathname !== "/callback") {
114
- res.writeHead(404, { "content-type": "text/plain" });
115
- res.end("Not found");
116
- return;
117
- }
118
- const result = parseCallback(url.searchParams, material.state);
119
- res.writeHead(result.ok ? 200 : 400, {
120
- "content-type": "text/html; charset=utf-8",
121
- });
122
- res.end(result.ok
123
- ? browserPage(true, "You're all set", "Return to your terminal — you can close this tab.")
124
- : browserPage(false, "Sign-in failed", result.message));
108
+ // (1)–(4): listener up first, browser second, state verified on every callback,
109
+ // bounded wait. The mechanics live in waitForLoopbackCallback, shared with
110
+ // `dx enable` so the two flows cannot drift apart.
111
+ const code = await waitForLoopbackCallback({
112
+ pickPort,
113
+ buildUrl: (redirectUri) => buildCliAuthUrl(opts.consoleUrl, redirectUri, material),
114
+ openBrowser,
115
+ timeoutMs: opts.timeoutMs ?? 5 * 60_000,
116
+ onTimeout: () => new LoginTimeoutError(),
117
+ onCallbackError: (message) => new LoginCallbackError(message),
118
+ onProgress: (line) => {
119
+ // The login transcript says "sign in" where the generic narration says
120
+ // "answer" — same lines, this flow's words.
121
+ progress(line
122
+ .replace("→ Opening your browser…", "→ Opening your browser to sign in…")
123
+ .replace(" Waiting for you to answer in the browser…", " Waiting for you to approve access in the browser…"));
124
+ },
125
+ parse: (params) => {
126
+ const result = parseCallback(params, material.state);
125
127
  if (result.ok) {
126
- server.close();
127
- resolve(result.code);
128
- }
129
- else if (result.reason !== "state_mismatch") {
130
- // A cancel is a real, terminal failure. A state mismatch is a stray/forged
131
- // callback — answer it but keep waiting for the genuine one (or the timeout).
132
- server.close();
133
- reject(new LoginCallbackError(result.message));
128
+ return {
129
+ ok: true,
130
+ value: result.code,
131
+ page: {
132
+ status: 200,
133
+ html: browserPage(true, "You're all set", "Return to your terminal — you can close this tab."),
134
+ },
135
+ };
134
136
  }
135
- });
136
- server.on("error", reject);
137
- // (1) listener up FIRST; only then (3) open the browser + print the fallback.
138
- server.listen(port, "127.0.0.1", () => {
139
- progress("→ Opening your browser to sign in…");
140
- progress(` If it doesn't open, visit: ${authUrl}`);
141
- progress("");
142
- progress(" Waiting for you to approve access in the browser…");
143
- openBrowser(authUrl);
144
- });
145
- const timer = setTimeout(() => {
146
- server.close();
147
- reject(new LoginTimeoutError());
148
- }, opts.timeoutMs ?? 5 * 60_000);
149
- timer.unref();
137
+ // A cancel is a real, terminal failure. A state mismatch is a stray/forged
138
+ // callback — answer it but keep waiting for the genuine one (or the timeout).
139
+ return {
140
+ ok: false,
141
+ retry: result.reason === "state_mismatch",
142
+ message: result.message,
143
+ page: { status: 400, html: browserPage(false, "Sign-in failed", result.message) },
144
+ };
145
+ },
150
146
  });
151
147
  // (5) redeem the code for the raw PAT; (6) persist then confirm identity.
152
148
  const pat = await backend.exchange(code, material.codeVerifier);
@@ -56,6 +56,14 @@ export declare function writeDxEnablement(next: {
56
56
  * other field by design, which would leave a disconnected machine still armed to
57
57
  * transmit for whoever signs in next — a creator who disconnects a machine has
58
58
  * disconnected it.
59
+ *
60
+ * The ACCOUNT-WIDE grant is deliberately NOT revoked here, and that asymmetry is the
61
+ * point: the local flag is a statement about this machine, so disconnecting the machine
62
+ * retracts it, while the account grant is a separate deliberate decision that lives in
63
+ * the console and covers machines this one knows nothing about. Signing out of a laptop
64
+ * is not a statement about a desktop. `vincentt dx disable` is the verb that revokes the
65
+ * account grant; `logout` only ever clears local state, which also keeps it free of a
66
+ * network call that could fail and strand a sign-out.
59
67
  */
60
68
  export declare function clearMachineToken(): Promise<{
61
69
  path: string;
@@ -112,6 +112,14 @@ export async function writeDxEnablement(next) {
112
112
  * other field by design, which would leave a disconnected machine still armed to
113
113
  * transmit for whoever signs in next — a creator who disconnects a machine has
114
114
  * disconnected it.
115
+ *
116
+ * The ACCOUNT-WIDE grant is deliberately NOT revoked here, and that asymmetry is the
117
+ * point: the local flag is a statement about this machine, so disconnecting the machine
118
+ * retracts it, while the account grant is a separate deliberate decision that lives in
119
+ * the console and covers machines this one knows nothing about. Signing out of a laptop
120
+ * is not a statement about a desktop. `vincentt dx disable` is the verb that revokes the
121
+ * account grant; `logout` only ever clears local state, which also keeps it free of a
122
+ * network call that could fail and strand a sign-out.
115
123
  */
116
124
  export async function clearMachineToken() {
117
125
  const current = await loadMachineConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",