@monster-orange/den 0.4.4 → 0.4.6
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/CHANGELOG.md +243 -0
- package/README.md +24 -9
- package/docs/distributions.md +320 -26
- package/docs/reference.md +8 -3
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +59 -2
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/port-pick.ts +60 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -1
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +513 -33
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +102 -2
- package/src/ui/server/ui-assets.ts +2 -0
package/src/ui/mcp/connect.ts
CHANGED
|
@@ -46,6 +46,13 @@ export type ConnectAction =
|
|
|
46
46
|
name: string;
|
|
47
47
|
entry: Record<string, unknown>;
|
|
48
48
|
}
|
|
49
|
+
| {
|
|
50
|
+
/** One `[mcp_servers.<name>]` table in a TOML config (Grok CLI). */
|
|
51
|
+
kind: "toml";
|
|
52
|
+
file: string;
|
|
53
|
+
name: string;
|
|
54
|
+
entry: Record<string, unknown>;
|
|
55
|
+
}
|
|
49
56
|
| {
|
|
50
57
|
kind: "exec";
|
|
51
58
|
/** Run first, failure ignored: makes the add idempotent. */
|
|
@@ -209,12 +216,49 @@ export const MCP_CLIENTS: readonly McpClient[] = [
|
|
|
209
216
|
s,
|
|
210
217
|
),
|
|
211
218
|
},
|
|
219
|
+
{
|
|
220
|
+
// Grok CLI (xAI). Its MCP servers live in ~/.grok/config.toml (user)
|
|
221
|
+
// or .grok/config.toml (project), one [mcp_servers.<name>] table each,
|
|
222
|
+
// with command, args and env keys; `grok mcp add` documents no env flag,
|
|
223
|
+
// so Den writes the table itself, PATH and all.
|
|
224
|
+
// https://docs.x.ai/build/features/mcp-servers
|
|
225
|
+
id: "grok",
|
|
226
|
+
title: "Grok CLI",
|
|
227
|
+
scopes: ["user", "project"],
|
|
228
|
+
detect: (e) => e.which("grok") || e.exists(path.join(e.home, ".grok")),
|
|
229
|
+
plan: (s, scope, e) => ({
|
|
230
|
+
kind: "toml",
|
|
231
|
+
file: path.join(
|
|
232
|
+
scope === "project" ? e.projectDir : e.home,
|
|
233
|
+
".grok",
|
|
234
|
+
"config.toml",
|
|
235
|
+
),
|
|
236
|
+
name: s.name,
|
|
237
|
+
entry: stdioEntry(s),
|
|
238
|
+
}),
|
|
239
|
+
},
|
|
212
240
|
];
|
|
213
241
|
|
|
214
242
|
export function findClient(id: string): McpClient | undefined {
|
|
215
243
|
return MCP_CLIENTS.find((c) => c.id === id);
|
|
216
244
|
}
|
|
217
245
|
|
|
246
|
+
/**
|
|
247
|
+
* `mcp connect --runtime <runtime>` (ON-2): the agent runtime's own name
|
|
248
|
+
* (AG-0.5's catalog: claude, codex, gemini, grok) to its MCP client id.
|
|
249
|
+
*/
|
|
250
|
+
export const RUNTIME_CLIENTS: Readonly<Record<string, string>> = {
|
|
251
|
+
claude: "claude-code",
|
|
252
|
+
codex: "codex",
|
|
253
|
+
gemini: "gemini",
|
|
254
|
+
grok: "grok",
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export function clientForRuntime(runtime: string): McpClient | undefined {
|
|
258
|
+
const id = RUNTIME_CLIENTS[runtime];
|
|
259
|
+
return id ? findClient(id) : undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
218
262
|
/** The PATH a launched server needs: node's own directory first. */
|
|
219
263
|
export function serverPath(nodeBinDir: string, platform: NodeJS.Platform) {
|
|
220
264
|
const base =
|
|
@@ -304,3 +348,93 @@ export function genericSnippet(spec: ServerSpec): string {
|
|
|
304
348
|
2,
|
|
305
349
|
);
|
|
306
350
|
}
|
|
351
|
+
|
|
352
|
+
// ── TOML configs (Grok CLI) ─────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/** A TOML basic string: JSON's escaping is a subset TOML reads the same. */
|
|
355
|
+
const tomlString = (v: string): string => JSON.stringify(v);
|
|
356
|
+
|
|
357
|
+
function tomlValue(v: unknown): string {
|
|
358
|
+
if (typeof v === "string") return tomlString(v);
|
|
359
|
+
if (Array.isArray(v)) return `[${v.map(tomlValue).join(", ")}]`;
|
|
360
|
+
if (v && typeof v === "object")
|
|
361
|
+
return `{ ${Object.entries(v as Record<string, unknown>)
|
|
362
|
+
.map(
|
|
363
|
+
([k, x]) =>
|
|
364
|
+
`${/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlString(k)} = ${tomlValue(x)}`,
|
|
365
|
+
)
|
|
366
|
+
.join(", ")} }`;
|
|
367
|
+
return String(v);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The `[mcp_servers.<name>]` table for `entry`, newline-terminated. */
|
|
371
|
+
export function tomlTable(
|
|
372
|
+
name: string,
|
|
373
|
+
entry: Record<string, unknown>,
|
|
374
|
+
): string {
|
|
375
|
+
return [
|
|
376
|
+
`[mcp_servers.${name}]`,
|
|
377
|
+
...Object.entries(entry).map(([k, v]) => `${k} = ${tomlValue(v)}`),
|
|
378
|
+
"",
|
|
379
|
+
].join("\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Whether a header line opens `name`'s table or one of its sub-tables. */
|
|
383
|
+
function ownsHeader(line: string, name: string): boolean {
|
|
384
|
+
const m =
|
|
385
|
+
/^\s*\[\s*mcp_servers\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\s*(\]|\.)/.exec(line);
|
|
386
|
+
return !!m && (m[1] ?? m[2]) === name;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** `text` split into [before, name's table, after] (the table may be ""). */
|
|
390
|
+
function splitTable(text: string, name: string): [string, string, string] {
|
|
391
|
+
const lines = text.split("\n");
|
|
392
|
+
const start = lines.findIndex((l) => ownsHeader(l, name));
|
|
393
|
+
if (start === -1) return [text, "", ""];
|
|
394
|
+
let end = start + 1;
|
|
395
|
+
while (
|
|
396
|
+
end < lines.length &&
|
|
397
|
+
!(/^\s*\[/.test(lines[end]) && !ownsHeader(lines[end], name))
|
|
398
|
+
)
|
|
399
|
+
end++;
|
|
400
|
+
return [
|
|
401
|
+
lines.slice(0, start).join("\n"),
|
|
402
|
+
lines.slice(start, end).join("\n"),
|
|
403
|
+
lines.slice(end).join("\n"),
|
|
404
|
+
];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* `text` (a TOML config, or null) with `name`'s `[mcp_servers.<name>]`
|
|
409
|
+
* table replaced by `entry` (or removed, for null). Everything else in the
|
|
410
|
+
* file is kept as written.
|
|
411
|
+
*/
|
|
412
|
+
export function mergeTomlConfig(
|
|
413
|
+
text: string | null,
|
|
414
|
+
name: string,
|
|
415
|
+
entry: Record<string, unknown> | null,
|
|
416
|
+
): string {
|
|
417
|
+
const [before, , after] = splitTable(text ?? "", name);
|
|
418
|
+
const head = before.replace(/\n*$/, "");
|
|
419
|
+
const tail = after.replace(/^\n*/, "").replace(/\n*$/, "");
|
|
420
|
+
const parts = [
|
|
421
|
+
head,
|
|
422
|
+
entry ? tomlTable(name, entry).trimEnd() : "",
|
|
423
|
+
tail,
|
|
424
|
+
].filter(Boolean);
|
|
425
|
+
return parts.length ? `${parts.join("\n\n")}\n` : "";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Whether a TOML config already has `name`'s table, as `entry` writes it. */
|
|
429
|
+
export function tomlHasEntry(
|
|
430
|
+
text: string | null,
|
|
431
|
+
name: string,
|
|
432
|
+
entry: Record<string, unknown>,
|
|
433
|
+
): "connected" | "different" | "absent" {
|
|
434
|
+
if (!text) return "absent";
|
|
435
|
+
const [, table] = splitTable(text, name);
|
|
436
|
+
if (!table) return "absent";
|
|
437
|
+
return table.trim() === tomlTable(name, entry).trim()
|
|
438
|
+
? "connected"
|
|
439
|
+
: "different";
|
|
440
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the device flows the server runs for the browser. The page asks
|
|
3
|
+
* for one (Sign in with GitHub on the signed-out page, or Link your GitHub
|
|
4
|
+
* account in Settings › Access); the server starts it with GitHub, polls
|
|
5
|
+
* GitHub itself at GitHub's interval, and the page polls the server for the
|
|
6
|
+
* outcome by the flow's id (a random handle only the page that started it
|
|
7
|
+
* holds).
|
|
8
|
+
*
|
|
9
|
+
* - The device code never leaves the server; the user code goes to that
|
|
10
|
+
* page only. The token lives for one `GET /user` call and is dropped.
|
|
11
|
+
* - At most `max` flows (default 3) are pending at once: another start is
|
|
12
|
+
* refused (`busy`) until one ends. A pending flow ends at GitHub's
|
|
13
|
+
* expiry; a finished one is forgotten a minute later, or when the page
|
|
14
|
+
* claims it.
|
|
15
|
+
* - What an identity means is the caller's (`decide`): for a sign-in,
|
|
16
|
+
* whether it is the owner; for a link, storing it as the owner.
|
|
17
|
+
*
|
|
18
|
+
* Clock and timers are injected, so a test drives every step.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes } from "crypto";
|
|
21
|
+
import { GITHUB_DEVICE_PAGE } from "../../shared/github-app.js";
|
|
22
|
+
import type {
|
|
23
|
+
GithubDeviceClient,
|
|
24
|
+
GithubIdentity,
|
|
25
|
+
} from "../../shared/github-device.js";
|
|
26
|
+
|
|
27
|
+
export type FlowPurpose = "signin" | "link";
|
|
28
|
+
export type FlowState =
|
|
29
|
+
| "pending"
|
|
30
|
+
| "ok"
|
|
31
|
+
| "refused"
|
|
32
|
+
| "expired"
|
|
33
|
+
| "denied"
|
|
34
|
+
| "error";
|
|
35
|
+
|
|
36
|
+
/** What the page is told about a flow (never the device code). */
|
|
37
|
+
export interface FlowView {
|
|
38
|
+
id: string;
|
|
39
|
+
purpose: FlowPurpose;
|
|
40
|
+
state: FlowState;
|
|
41
|
+
userCode: string;
|
|
42
|
+
verificationUri: string;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
/** ok or refused: the GitHub login that approved it. */
|
|
45
|
+
login?: string;
|
|
46
|
+
/** error: what went wrong (no secret in it). */
|
|
47
|
+
message?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Flow extends FlowView {
|
|
51
|
+
deviceCode: string;
|
|
52
|
+
intervalMs: number;
|
|
53
|
+
errors: number;
|
|
54
|
+
/** ok: who approved it (the id, for the caller). */
|
|
55
|
+
identity?: GithubIdentity;
|
|
56
|
+
/** When a finished flow is forgotten. */
|
|
57
|
+
forgetAt?: number;
|
|
58
|
+
cancel?: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type Schedule = (fn: () => void, ms: number) => () => void;
|
|
62
|
+
|
|
63
|
+
const defaultSchedule: Schedule = (fn, ms) => {
|
|
64
|
+
const t = setTimeout(fn, ms);
|
|
65
|
+
t.unref?.();
|
|
66
|
+
return () => clearTimeout(t);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** How long a finished flow waits for its page to claim it. */
|
|
70
|
+
export const FINISHED_KEEP_MS = 60_000;
|
|
71
|
+
/** The most pending flows at once (SEC-2a: "rate-limit concurrent device flows"). */
|
|
72
|
+
export const MAX_PENDING_FLOWS = 3;
|
|
73
|
+
const MAX_ERRORS = 5;
|
|
74
|
+
|
|
75
|
+
export class DeviceFlows {
|
|
76
|
+
private readonly flows = new Map<string, Flow>();
|
|
77
|
+
private readonly client: GithubDeviceClient;
|
|
78
|
+
private readonly clientId: () => string;
|
|
79
|
+
private readonly now: () => number;
|
|
80
|
+
private readonly schedule: Schedule;
|
|
81
|
+
private readonly max: number;
|
|
82
|
+
private readonly minIntervalMs: number;
|
|
83
|
+
private readonly onExpiringToken?: () => void;
|
|
84
|
+
private readonly decide: (
|
|
85
|
+
purpose: FlowPurpose,
|
|
86
|
+
identity: GithubIdentity,
|
|
87
|
+
) => "ok" | "refused";
|
|
88
|
+
|
|
89
|
+
constructor(opts: {
|
|
90
|
+
client: GithubDeviceClient;
|
|
91
|
+
clientId: () => string;
|
|
92
|
+
decide: (
|
|
93
|
+
purpose: FlowPurpose,
|
|
94
|
+
identity: GithubIdentity,
|
|
95
|
+
) => "ok" | "refused";
|
|
96
|
+
now?: () => number;
|
|
97
|
+
schedule?: Schedule;
|
|
98
|
+
max?: number;
|
|
99
|
+
/** The floor between polls (GitHub's interval is never shortened). */
|
|
100
|
+
minIntervalMs?: number;
|
|
101
|
+
/**
|
|
102
|
+
* A token answer carried expires_in or a refresh_token: the app has
|
|
103
|
+
* token expiry on (a misconfiguration). Called with no token.
|
|
104
|
+
*/
|
|
105
|
+
onExpiringToken?: () => void;
|
|
106
|
+
}) {
|
|
107
|
+
this.onExpiringToken = opts.onExpiringToken;
|
|
108
|
+
this.client = opts.client;
|
|
109
|
+
this.clientId = opts.clientId;
|
|
110
|
+
this.decide = opts.decide;
|
|
111
|
+
this.now = opts.now ?? Date.now;
|
|
112
|
+
this.schedule = opts.schedule ?? defaultSchedule;
|
|
113
|
+
this.max = opts.max ?? MAX_PENDING_FLOWS;
|
|
114
|
+
this.minIntervalMs = opts.minIntervalMs ?? 1000;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pending flows now. */
|
|
118
|
+
pending(): number {
|
|
119
|
+
this.sweep();
|
|
120
|
+
return [...this.flows.values()].filter((f) => f.state === "pending").length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Start a flow with GitHub. `busy` past the cap; `no_client_id` when none
|
|
125
|
+
* is configured; a GitHub failure throws (its message names no secret).
|
|
126
|
+
*/
|
|
127
|
+
async start(
|
|
128
|
+
purpose: FlowPurpose,
|
|
129
|
+
): Promise<FlowView | { error: "busy" | "no_client_id" }> {
|
|
130
|
+
const clientId = this.clientId();
|
|
131
|
+
if (!clientId) return { error: "no_client_id" };
|
|
132
|
+
if (this.pending() >= this.max) return { error: "busy" };
|
|
133
|
+
// Held as pending while GitHub answers, so a burst can't pass the cap.
|
|
134
|
+
const id = randomBytes(24).toString("base64url");
|
|
135
|
+
const placeholder: Flow = {
|
|
136
|
+
id,
|
|
137
|
+
purpose,
|
|
138
|
+
state: "pending",
|
|
139
|
+
userCode: "",
|
|
140
|
+
verificationUri: GITHUB_DEVICE_PAGE,
|
|
141
|
+
expiresAt: this.now() + 60_000,
|
|
142
|
+
deviceCode: "",
|
|
143
|
+
intervalMs: this.minIntervalMs,
|
|
144
|
+
errors: 0,
|
|
145
|
+
};
|
|
146
|
+
this.flows.set(id, placeholder);
|
|
147
|
+
let code;
|
|
148
|
+
try {
|
|
149
|
+
code = await this.client.start(clientId);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
this.flows.delete(id);
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
const flow: Flow = {
|
|
155
|
+
...placeholder,
|
|
156
|
+
userCode: code.userCode,
|
|
157
|
+
deviceCode: code.deviceCode,
|
|
158
|
+
expiresAt: this.now() + code.expiresIn * 1000,
|
|
159
|
+
intervalMs: Math.max(code.interval * 1000, this.minIntervalMs),
|
|
160
|
+
};
|
|
161
|
+
this.flows.set(id, flow);
|
|
162
|
+
this.arm(flow);
|
|
163
|
+
return view(flow);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A flow's view, or null when there is none (or it was forgotten). */
|
|
167
|
+
get(id: string, purpose?: FlowPurpose): FlowView | null {
|
|
168
|
+
this.sweep();
|
|
169
|
+
const f = this.flows.get(id);
|
|
170
|
+
if (!f || (purpose && f.purpose !== purpose)) return null;
|
|
171
|
+
return view(f);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The identity of a finished, approved flow, once: the flow is forgotten,
|
|
176
|
+
* so a second claim gets null (a sign-in makes one session).
|
|
177
|
+
*/
|
|
178
|
+
claim(id: string, purpose: FlowPurpose): GithubIdentity | null {
|
|
179
|
+
this.sweep();
|
|
180
|
+
const f = this.flows.get(id);
|
|
181
|
+
if (!f || f.purpose !== purpose || f.state !== "ok" || !f.identity)
|
|
182
|
+
return null;
|
|
183
|
+
this.flows.delete(id);
|
|
184
|
+
return f.identity;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Stop every flow (the server is stopping). */
|
|
188
|
+
stopAll(): void {
|
|
189
|
+
for (const f of this.flows.values()) f.cancel?.();
|
|
190
|
+
this.flows.clear();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private arm(f: Flow): void {
|
|
194
|
+
f.cancel = this.schedule(() => void this.tick(f.id), f.intervalMs);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private finish(
|
|
198
|
+
f: Flow,
|
|
199
|
+
state: Exclude<FlowState, "pending">,
|
|
200
|
+
extra: Partial<Flow> = {},
|
|
201
|
+
): void {
|
|
202
|
+
Object.assign(f, extra, {
|
|
203
|
+
state,
|
|
204
|
+
deviceCode: "",
|
|
205
|
+
forgetAt: this.now() + FINISHED_KEEP_MS,
|
|
206
|
+
});
|
|
207
|
+
f.cancel?.();
|
|
208
|
+
f.cancel = undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async tick(id: string): Promise<void> {
|
|
212
|
+
const f = this.flows.get(id);
|
|
213
|
+
if (!f || f.state !== "pending") return;
|
|
214
|
+
if (this.now() >= f.expiresAt) return this.finish(f, "expired");
|
|
215
|
+
const r = await this.client.poll(this.clientId(), f.deviceCode);
|
|
216
|
+
if (this.flows.get(id) !== f || f.state !== "pending") return;
|
|
217
|
+
switch (r.kind) {
|
|
218
|
+
case "pending":
|
|
219
|
+
return this.arm(f);
|
|
220
|
+
case "slow_down":
|
|
221
|
+
f.intervalMs = Math.max(
|
|
222
|
+
r.interval !== undefined ? r.interval * 1000 : f.intervalMs + 5000,
|
|
223
|
+
this.minIntervalMs,
|
|
224
|
+
);
|
|
225
|
+
return this.arm(f);
|
|
226
|
+
case "expired":
|
|
227
|
+
return this.finish(f, "expired");
|
|
228
|
+
case "denied":
|
|
229
|
+
return this.finish(f, "denied");
|
|
230
|
+
case "error":
|
|
231
|
+
if (++f.errors >= MAX_ERRORS)
|
|
232
|
+
return this.finish(f, "error", { message: r.message });
|
|
233
|
+
return this.arm(f);
|
|
234
|
+
case "token": {
|
|
235
|
+
if (r.expiring) this.onExpiringToken?.();
|
|
236
|
+
let identity: GithubIdentity;
|
|
237
|
+
try {
|
|
238
|
+
// The token is used for this one call and dropped with `r`.
|
|
239
|
+
identity = await this.client.user(r.token);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return this.finish(f, "error", { message: (e as Error).message });
|
|
242
|
+
}
|
|
243
|
+
const outcome = this.decide(f.purpose, identity);
|
|
244
|
+
return this.finish(f, outcome, {
|
|
245
|
+
login: identity.login,
|
|
246
|
+
...(outcome === "ok" ? { identity } : {}),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Expire pending flows past their time; forget old finished ones. */
|
|
253
|
+
private sweep(): void {
|
|
254
|
+
const now = this.now();
|
|
255
|
+
for (const [id, f] of this.flows) {
|
|
256
|
+
if (f.state === "pending" && f.deviceCode && now >= f.expiresAt)
|
|
257
|
+
this.finish(f, "expired");
|
|
258
|
+
if (f.forgetAt !== undefined && now >= f.forgetAt) this.flows.delete(id);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function view(f: Flow): FlowView {
|
|
264
|
+
return {
|
|
265
|
+
id: f.id,
|
|
266
|
+
purpose: f.purpose,
|
|
267
|
+
state: f.state,
|
|
268
|
+
userCode: f.userCode,
|
|
269
|
+
verificationUri: f.verificationUri,
|
|
270
|
+
expiresAt: f.expiresAt,
|
|
271
|
+
...(f.login !== undefined ? { login: f.login } : {}),
|
|
272
|
+
...(f.message !== undefined ? { message: f.message } : {}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The edge counts as local (0.4.3)
|
|
3
|
-
*
|
|
2
|
+
* The edge counts as local (0.4.3) when it shows the client is this
|
|
3
|
+
* machine; since 0.4.5 any other client signs in (unless
|
|
4
|
+
* web.edge_unattested: local). When Den can tell the edge is
|
|
4
5
|
* reachable beyond this machine it says so, once in web.log and in a
|
|
5
6
|
* banner on every page (the shell, dismissible per browser).
|
|
6
7
|
*
|
|
@@ -16,8 +17,19 @@ import { execFile } from "child_process";
|
|
|
16
17
|
import { promises as dns } from "dns";
|
|
17
18
|
import { isLoopbackAddress } from "./request-guard.js";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The warning, by `web.edge_unattested` (SEC-2a): with `signin` (the
|
|
22
|
+
* default) a browser the edge can't place as this machine signs in first;
|
|
23
|
+
* with `local` it is let in, as in 0.4.3.
|
|
24
|
+
*/
|
|
25
|
+
export function edgeExposedMessage(unattested: "local" | "signin"): string {
|
|
26
|
+
return unattested === "local"
|
|
27
|
+
? "Your edge is reachable from other machines, and web.edge_unattested is local: anyone it can't place can use terminals. Remove the key to have them sign in."
|
|
28
|
+
: "Your edge is reachable from other machines. A browser it can't place as this one signs in first (Sign in with GitHub, or a pairing link).";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 0.4.5's default wording (web.edge_unattested: signin). */
|
|
32
|
+
export const EDGE_EXPOSED_MESSAGE = edgeExposedMessage("signin");
|
|
21
33
|
|
|
22
34
|
export interface ExposureDeps {
|
|
23
35
|
/** Every address a hostname resolves to (none when it doesn't). */
|