@vincentt-xr/cli 0.13.0 → 0.14.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/dist/cli/index.js +41 -19
- package/dist/dx/command.d.ts +79 -15
- package/dist/dx/command.js +173 -22
- package/dist/dx/consent.d.ts +77 -0
- package/dist/dx/consent.js +197 -0
- package/dist/dx/enablement.d.ts +35 -1
- package/dist/dx/enablement.js +56 -8
- package/dist/login/loopback.d.ts +72 -0
- package/dist/login/loopback.js +119 -0
- package/dist/login/pkceLogin.d.ts +1 -0
- package/dist/login/pkceLogin.js +41 -45
- package/dist/shared/config.d.ts +8 -0
- package/dist/shared/config.js +8 -0
- package/package.json +1 -1
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`
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
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:
|
|
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) ------------------------------------------------------------
|
package/dist/dx/command.d.ts
CHANGED
|
@@ -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
|
|
52
|
-
*
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
61
|
-
* all: this file holds the PAT, and a refusal that round-trips the config through
|
|
62
|
-
*
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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`.
|
package/dist/dx/command.js
CHANGED
|
@@ -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
|
|
135
|
-
*
|
|
134
|
+
* `vincentt dx enable` — grant durable permission to send DX reports without asking
|
|
135
|
+
* first.
|
|
136
136
|
*
|
|
137
|
-
*
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
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("
|
|
158
|
-
deps.print("
|
|
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
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
-
|
|
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
|
|
@@ -260,14 +412,13 @@ export async function dxSendCommand(deps, options) {
|
|
|
260
412
|
deps.print("machine, or run `vincentt dx --send` in your own terminal.");
|
|
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
|
+
}
|
package/dist/dx/enablement.d.ts
CHANGED
|
@@ -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;
|
package/dist/dx/enablement.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/login/pkceLogin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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);
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/config.js
CHANGED
|
@@ -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