@monster-orange/den 0.4.3 → 0.4.5
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 +348 -0
- package/README.md +12 -11
- package/docs/distributions.md +438 -91
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +131 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +64 -3
- package/src/shared/dev-metrics/emit.ts +31 -1
- 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/status.ts +134 -0
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- 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 +540 -33
- 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/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +89 -21
- package/src/ui/server/public/i18n/ja.js +88 -21
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +44 -21
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +10 -2
- package/src/ui/server/public/settings-registry.js +617 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +151 -33
- package/src/ui/server/public/topbar.js +170 -206
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +116 -4
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the owner's GitHub identity. `<state dir>/web-owner.json`, mode
|
|
3
|
+
* 0600: `{ v: 1, owner: "github:<numeric id>", login, linkedAt }`. The id
|
|
4
|
+
* is what sign-in compares; the login is for display only (a login can be
|
|
5
|
+
* renamed, the id can't). No token is ever stored: the device flow's token
|
|
6
|
+
* is dropped right after `GET /user`.
|
|
7
|
+
*
|
|
8
|
+
* Written by `<cli> web owner link` and Settings › Access; removed by
|
|
9
|
+
* `web owner unlink`. Re-read whenever it changes on disk.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from "crypto";
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { getStateDir } from "./paths.js";
|
|
15
|
+
|
|
16
|
+
export const WEB_OWNER_FILE = "web-owner.json";
|
|
17
|
+
|
|
18
|
+
export interface WebOwner {
|
|
19
|
+
/** `github:<numeric id>`. */
|
|
20
|
+
owner: `github:${number}`;
|
|
21
|
+
/** The GitHub login at link time, for display. */
|
|
22
|
+
login: string;
|
|
23
|
+
/** ISO time it was linked. */
|
|
24
|
+
linkedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function webOwnerPath(stateDir: string = getStateDir()): string {
|
|
28
|
+
return path.join(stateDir, WEB_OWNER_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
32
|
+
|
|
33
|
+
/** The linked owner, or null (none, or a file that isn't one). */
|
|
34
|
+
export function readWebOwner(
|
|
35
|
+
stateDir: string = getStateDir(),
|
|
36
|
+
): WebOwner | null {
|
|
37
|
+
let raw: unknown;
|
|
38
|
+
try {
|
|
39
|
+
raw = JSON.parse(fs.readFileSync(webOwnerPath(stateDir), "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const o = raw as Partial<WebOwner> | null;
|
|
44
|
+
if (
|
|
45
|
+
!o ||
|
|
46
|
+
typeof o.owner !== "string" ||
|
|
47
|
+
!/^github:[1-9][0-9]{0,15}$/.test(o.owner) ||
|
|
48
|
+
typeof o.login !== "string" ||
|
|
49
|
+
!LOGIN.test(o.login)
|
|
50
|
+
)
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
owner: o.owner,
|
|
54
|
+
login: o.login,
|
|
55
|
+
linkedAt: typeof o.linkedAt === "string" ? o.linkedAt : "",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Link `id` (`login` for display) as the owner: atomic, 0600. */
|
|
60
|
+
export function writeWebOwner(
|
|
61
|
+
identity: { id: number; login: string },
|
|
62
|
+
stateDir: string = getStateDir(),
|
|
63
|
+
now: Date = new Date(),
|
|
64
|
+
): WebOwner {
|
|
65
|
+
if (!Number.isSafeInteger(identity.id) || identity.id <= 0)
|
|
66
|
+
throw new Error("a GitHub account id is a positive integer");
|
|
67
|
+
if (!LOGIN.test(identity.login))
|
|
68
|
+
throw new Error("that is not a GitHub login");
|
|
69
|
+
const rec: WebOwner = {
|
|
70
|
+
owner: `github:${identity.id}`,
|
|
71
|
+
login: identity.login,
|
|
72
|
+
linkedAt: now.toISOString(),
|
|
73
|
+
};
|
|
74
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
75
|
+
const file = webOwnerPath(stateDir);
|
|
76
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
77
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ v: 1, ...rec })}\n`, {
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
});
|
|
80
|
+
fs.renameSync(tmp, file);
|
|
81
|
+
return rec;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Unlink the owner; the one it was, or null when none was linked. */
|
|
85
|
+
export function clearWebOwner(
|
|
86
|
+
stateDir: string = getStateDir(),
|
|
87
|
+
): WebOwner | null {
|
|
88
|
+
const was = readWebOwner(stateDir);
|
|
89
|
+
fs.rmSync(webOwnerPath(stateDir), { force: true });
|
|
90
|
+
return was;
|
|
91
|
+
}
|
|
@@ -21,6 +21,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
|
21
21
|
import * as fs from "fs";
|
|
22
22
|
import * as path from "path";
|
|
23
23
|
import * as yaml from "yaml";
|
|
24
|
+
import { parseMachineName } from "./cli-command.js";
|
|
24
25
|
import { getStateDir } from "./paths.js";
|
|
25
26
|
|
|
26
27
|
export const LOGIN_CODE_TTL_MS = 60_000;
|
|
@@ -98,6 +99,14 @@ export function checkLoginCode(
|
|
|
98
99
|
return { ok: true, nonce: m[2], expiresAt };
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** A login code's nonce, from its shape alone (unverified), or null. */
|
|
103
|
+
export function loginCodeNonce(code: string): string | null {
|
|
104
|
+
const m = /^v1\.([A-Za-z0-9_-]{22})\.[0-9a-z]{1,11}\.[A-Za-z0-9_-]{43}$/.exec(
|
|
105
|
+
code,
|
|
106
|
+
);
|
|
107
|
+
return m ? m[1] : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
101
110
|
/**
|
|
102
111
|
* Where a sign-in may send the browser: a same-origin relative path, else
|
|
103
112
|
* `/`. `//evil`, `/\evil`, `http://evil`, a control character or a path back
|
|
@@ -167,8 +176,30 @@ export interface WebAuthConfig {
|
|
|
167
176
|
* never the distribution's), and warned about at start.
|
|
168
177
|
*/
|
|
169
178
|
plainHttpInDistribution?: string;
|
|
179
|
+
/**
|
|
180
|
+
* `web.github_client_id` (SEC-2a): the GitHub App whose device flow signs
|
|
181
|
+
* the owner in. user.yml over the distribution config; unset: the MO Den
|
|
182
|
+
* app's (github-app.ts).
|
|
183
|
+
*/
|
|
184
|
+
githubClientId?: string;
|
|
185
|
+
/**
|
|
186
|
+
* `web.edge_unattested` (SEC-2a, user.yml only): what an edge request
|
|
187
|
+
* whose client Den can't place gets. `signin` (the default): it signs in;
|
|
188
|
+
* `local`: it is treated as this machine (0.4.4's allow).
|
|
189
|
+
*/
|
|
190
|
+
edgeUnattested: EdgeUnattested;
|
|
191
|
+
/** The distribution config's file when it sets `web.edge_unattested`: ignored there. */
|
|
192
|
+
edgeUnattestedInDistribution?: string;
|
|
193
|
+
/**
|
|
194
|
+
* `web.machine_name` (SEC-2a, user.yml only): the name the signed-out
|
|
195
|
+
* page gives this machine ("runs on <name>"). Unset: the short hostname.
|
|
196
|
+
*/
|
|
197
|
+
machineName?: string;
|
|
170
198
|
}
|
|
171
199
|
|
|
200
|
+
/** `web.edge_unattested`'s values. */
|
|
201
|
+
export type EdgeUnattested = "local" | "signin";
|
|
202
|
+
|
|
172
203
|
function readYaml(file: string): Record<string, unknown> {
|
|
173
204
|
try {
|
|
174
205
|
const v = yaml.parse(fs.readFileSync(file, "utf-8")) as unknown;
|
|
@@ -210,6 +241,12 @@ export function ignoredConfigWarning(cfg: WebAuthConfig): string | null {
|
|
|
210
241
|
`${cfg.ignored.join(", ")}: ignored. A browser session never expires; ` +
|
|
211
242
|
"it ends at sign-out or `web logout`. Remove the key to silence this.",
|
|
212
243
|
);
|
|
244
|
+
if (cfg.edgeUnattestedInDistribution)
|
|
245
|
+
lines.push(
|
|
246
|
+
`web.edge_unattested (${cfg.edgeUnattestedInDistribution}): ignored. ` +
|
|
247
|
+
"It is a choice for one machine, read only from config/local/user.yml; " +
|
|
248
|
+
"move it there on the machine that needs it, and remove it here.",
|
|
249
|
+
);
|
|
213
250
|
if (cfg.plainHttpInDistribution)
|
|
214
251
|
lines.push(
|
|
215
252
|
`web.allow_plain_http_remote (${cfg.plainHttpInDistribution}): ignored. ` +
|
|
@@ -241,15 +278,47 @@ export function readWebAuthConfig(
|
|
|
241
278
|
throw new Error(
|
|
242
279
|
`web.allow_plain_http_remote must be true or false, got ${JSON.stringify(plain)}`,
|
|
243
280
|
);
|
|
281
|
+
const unattested = user.edge_unattested;
|
|
282
|
+
if (
|
|
283
|
+
unattested !== undefined &&
|
|
284
|
+
unattested !== "local" &&
|
|
285
|
+
unattested !== "signin"
|
|
286
|
+
)
|
|
287
|
+
throw new Error(
|
|
288
|
+
`web.edge_unattested must be local or signin, got ${JSON.stringify(unattested)}`,
|
|
289
|
+
);
|
|
290
|
+
const clientId =
|
|
291
|
+
clientIdOf(user, "config/local/user.yml") ?? clientIdOf(team, configFile);
|
|
292
|
+
const machineName = parseMachineName(user.machine_name);
|
|
244
293
|
return {
|
|
294
|
+
...(machineName ? { machineName } : {}),
|
|
245
295
|
allowPlainHttpRemote: plain === true,
|
|
246
296
|
ignored,
|
|
297
|
+
edgeUnattested: unattested ?? "signin",
|
|
298
|
+
...(clientId !== undefined ? { githubClientId: clientId } : {}),
|
|
247
299
|
...(team.allow_plain_http_remote !== undefined
|
|
248
300
|
? { plainHttpInDistribution: configFile }
|
|
249
301
|
: {}),
|
|
302
|
+
...(team.edge_unattested !== undefined
|
|
303
|
+
? { edgeUnattestedInDistribution: configFile }
|
|
304
|
+
: {}),
|
|
250
305
|
};
|
|
251
306
|
}
|
|
252
307
|
|
|
308
|
+
/** `web.github_client_id` in one file: a GitHub App client id, or unset. */
|
|
309
|
+
function clientIdOf(
|
|
310
|
+
w: Record<string, unknown>,
|
|
311
|
+
file: string,
|
|
312
|
+
): string | undefined {
|
|
313
|
+
const v = w.github_client_id;
|
|
314
|
+
if (v === undefined || v === null) return undefined;
|
|
315
|
+
if (typeof v !== "string" || !/^[A-Za-z0-9._-]{0,64}$/.test(v.trim()))
|
|
316
|
+
throw new Error(
|
|
317
|
+
`web.github_client_id (${file}) must be a GitHub App client id (letters, digits, dots, dashes), got ${JSON.stringify(v)}`,
|
|
318
|
+
);
|
|
319
|
+
return v.trim();
|
|
320
|
+
}
|
|
321
|
+
|
|
253
322
|
// ── The session store ──────────────────────────────────────────────────────
|
|
254
323
|
|
|
255
324
|
export interface SessionRecord {
|
|
@@ -260,6 +329,14 @@ export interface SessionRecord {
|
|
|
260
329
|
csrf: string;
|
|
261
330
|
/** The Host it was issued on. */
|
|
262
331
|
host: string;
|
|
332
|
+
/** SEC-2a: how it signed in: a GitHub sign-in, or a one-time link (pairing). Unset: a link (before 0.4.5). */
|
|
333
|
+
via?: "github" | "link" | "code";
|
|
334
|
+
/** SEC-2a: the client's address when Den could attest it. */
|
|
335
|
+
client?: string;
|
|
336
|
+
/** SEC-2a: a GitHub sign-in's login (the chip's "@login"). */
|
|
337
|
+
login?: string;
|
|
338
|
+
/** SEC-2a: not yet greeted ("This device is signed in…", once). */
|
|
339
|
+
welcome?: true;
|
|
263
340
|
}
|
|
264
341
|
|
|
265
342
|
interface StoreFile {
|
|
@@ -384,7 +461,13 @@ export class WebSessionStore {
|
|
|
384
461
|
}
|
|
385
462
|
|
|
386
463
|
/** A new session: its id (the cookie's value) and record. */
|
|
387
|
-
create(meta: {
|
|
464
|
+
create(meta: {
|
|
465
|
+
userAgent?: string;
|
|
466
|
+
host?: string;
|
|
467
|
+
via?: "github" | "link" | "code";
|
|
468
|
+
client?: string;
|
|
469
|
+
login?: string;
|
|
470
|
+
}): {
|
|
388
471
|
id: string;
|
|
389
472
|
session: SessionView;
|
|
390
473
|
} {
|
|
@@ -400,6 +483,10 @@ export class WebSessionStore {
|
|
|
400
483
|
host: String(meta.host ?? "")
|
|
401
484
|
.toLowerCase()
|
|
402
485
|
.slice(0, 255),
|
|
486
|
+
...(meta.via ? { via: meta.via } : {}),
|
|
487
|
+
...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
|
|
488
|
+
...(meta.login ? { login: String(meta.login).slice(0, 64) } : {}),
|
|
489
|
+
welcome: true,
|
|
403
490
|
};
|
|
404
491
|
const data = structuredClone(this.load());
|
|
405
492
|
this.prune(data, now);
|
|
@@ -438,6 +525,19 @@ export class WebSessionStore {
|
|
|
438
525
|
}
|
|
439
526
|
}
|
|
440
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Whether this session is yet to be greeted, once: true the first time
|
|
530
|
+
* after its sign-in, and never again (the flag is cleared).
|
|
531
|
+
*/
|
|
532
|
+
takeWelcome(key: string): boolean {
|
|
533
|
+
const data = this.load();
|
|
534
|
+
if (!data.sessions[key]?.welcome) return false;
|
|
535
|
+
const next = structuredClone(data);
|
|
536
|
+
delete next.sessions[key].welcome;
|
|
537
|
+
this.trySave(next);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
|
|
441
541
|
/** Every session, newest first. */
|
|
442
542
|
list(): SessionView[] {
|
|
443
543
|
return Object.entries(this.load().sessions)
|
|
@@ -489,3 +589,37 @@ export class WebSessionStore {
|
|
|
489
589
|
export function sessionIdPrefix(key: string): string {
|
|
490
590
|
return key.slice(0, 8);
|
|
491
591
|
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* A linked device's name from its User-Agent (SEC-2a: "Chrome on macOS"),
|
|
595
|
+
* or "Unknown browser".
|
|
596
|
+
*/
|
|
597
|
+
export function deviceName(userAgent: string | undefined): string {
|
|
598
|
+
const ua = String(userAgent ?? "");
|
|
599
|
+
const browser = /Edg\//.test(ua)
|
|
600
|
+
? "Edge"
|
|
601
|
+
: /Firefox\//.test(ua)
|
|
602
|
+
? "Firefox"
|
|
603
|
+
: /(Chrome|CriOS)\//.test(ua)
|
|
604
|
+
? "Chrome"
|
|
605
|
+
: /Safari\//.test(ua)
|
|
606
|
+
? "Safari"
|
|
607
|
+
: /curl\//.test(ua)
|
|
608
|
+
? "curl"
|
|
609
|
+
: null;
|
|
610
|
+
const os = /iPhone|iPad|iPod/.test(ua)
|
|
611
|
+
? "iOS"
|
|
612
|
+
: /Android/.test(ua)
|
|
613
|
+
? "Android"
|
|
614
|
+
: /Mac OS X|Macintosh/.test(ua)
|
|
615
|
+
? "macOS"
|
|
616
|
+
: /Windows/.test(ua)
|
|
617
|
+
? "Windows"
|
|
618
|
+
: /CrOS/.test(ua)
|
|
619
|
+
? "ChromeOS"
|
|
620
|
+
: /Linux/.test(ua)
|
|
621
|
+
? "Linux"
|
|
622
|
+
: null;
|
|
623
|
+
if (browser && os) return `${browser} on ${os}`;
|
|
624
|
+
return browser ?? (os ? `A browser on ${os}` : "Unknown browser");
|
|
625
|
+
}
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -42,10 +42,22 @@ import * as path from "path";
|
|
|
42
42
|
import {
|
|
43
43
|
AGENTS_RESERVED_KEYS,
|
|
44
44
|
configuredRuntimeBin,
|
|
45
|
+
configuredRuntimeCatalog,
|
|
45
46
|
readAgentsConfig,
|
|
47
|
+
setUserRuntimeBin,
|
|
46
48
|
userConfigPath,
|
|
47
49
|
type ResolvedAgentsConfig,
|
|
48
50
|
} from "../../shared/den-config.js";
|
|
51
|
+
import {
|
|
52
|
+
setWorkspaceRuntimeBin,
|
|
53
|
+
workspaceBinKey,
|
|
54
|
+
workspaceRuntimeBin,
|
|
55
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
56
|
+
import {
|
|
57
|
+
BUILTIN_CATALOG,
|
|
58
|
+
type CatalogEntry,
|
|
59
|
+
} from "../../shared/agents/catalog.js";
|
|
60
|
+
import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
|
|
49
61
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
50
62
|
import { getDistRoot } from "../../shared/paths.js";
|
|
51
63
|
import { envKey } from "../../shared/product.js";
|
|
@@ -70,6 +82,7 @@ import {
|
|
|
70
82
|
getRuntime,
|
|
71
83
|
LaunchError,
|
|
72
84
|
runtimeIds,
|
|
85
|
+
syncConfiguredRuntimes,
|
|
73
86
|
type AgentMcp,
|
|
74
87
|
type LaunchInput,
|
|
75
88
|
type Runtime,
|
|
@@ -77,7 +90,10 @@ import {
|
|
|
77
90
|
import type { RuntimeDetect } from "../../shared/agents/runtime.js";
|
|
78
91
|
import {
|
|
79
92
|
binSettingKey,
|
|
93
|
+
ensureInteractivePath,
|
|
94
|
+
interactivePathAsked,
|
|
80
95
|
runtimeVersion,
|
|
96
|
+
type BinSetting,
|
|
81
97
|
type BinSource,
|
|
82
98
|
} from "../../shared/agents/runtime-search.js";
|
|
83
99
|
import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
@@ -116,6 +132,20 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
116
132
|
exitWatchMs?: number;
|
|
117
133
|
/** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
|
|
118
134
|
runtimeBin?: (id: string) => unknown;
|
|
135
|
+
/**
|
|
136
|
+
* Workspace `ws`'s `agents.runtimes.<id>.bin` (default: workspaces.yml's;
|
|
137
|
+
* undefined: unset). It wins over runtimeBin (AG-0.5).
|
|
138
|
+
*/
|
|
139
|
+
workspaceRuntimeBin?: (ws: string, id: string) => unknown;
|
|
140
|
+
/** user.yml's runtime catalog entries, checked (default: user.yml's). */
|
|
141
|
+
userCatalog?: (taken: readonly string[]) => CatalogEntry[];
|
|
142
|
+
/** Save a checked path (null clears it): global or the workspace's. */
|
|
143
|
+
saveRuntimeBin?: (
|
|
144
|
+
scope: "global" | "workspace",
|
|
145
|
+
ws: string | undefined,
|
|
146
|
+
id: string,
|
|
147
|
+
bin: string | null,
|
|
148
|
+
) => void;
|
|
119
149
|
/** The user.yml a person edits to set it (default: config/local/user.yml). */
|
|
120
150
|
userConfigFile?: () => string;
|
|
121
151
|
}
|
|
@@ -127,6 +157,9 @@ export interface AgentsResolved extends Resolved {
|
|
|
127
157
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
128
158
|
exitWatchMs: number;
|
|
129
159
|
runtimeBin: (id: string) => unknown;
|
|
160
|
+
workspaceRuntimeBin: (ws: string, id: string) => unknown;
|
|
161
|
+
userCatalog: (taken: readonly string[]) => CatalogEntry[];
|
|
162
|
+
saveRuntimeBin: NonNullable<AgentsDeps["saveRuntimeBin"]>;
|
|
130
163
|
userConfigFile: () => string;
|
|
131
164
|
}
|
|
132
165
|
|
|
@@ -217,6 +250,15 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
217
250
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
218
251
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
219
252
|
runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
|
|
253
|
+
workspaceRuntimeBin:
|
|
254
|
+
d.workspaceRuntimeBin ?? ((ws, id) => workspaceRuntimeBin(ws, id)),
|
|
255
|
+
userCatalog: d.userCatalog ?? ((taken) => configuredRuntimeCatalog(taken)),
|
|
256
|
+
saveRuntimeBin:
|
|
257
|
+
d.saveRuntimeBin ??
|
|
258
|
+
((scope, ws, id, bin) =>
|
|
259
|
+
scope === "workspace" && ws
|
|
260
|
+
? setWorkspaceRuntimeBin(ws, id, bin)
|
|
261
|
+
: setUserRuntimeBin(id, bin)),
|
|
220
262
|
userConfigFile: d.userConfigFile ?? userConfigPath,
|
|
221
263
|
};
|
|
222
264
|
}
|
|
@@ -415,44 +457,137 @@ export interface RuntimeChoice {
|
|
|
415
457
|
id: string;
|
|
416
458
|
/** Its name for people ("Claude Code"). */
|
|
417
459
|
label?: string;
|
|
460
|
+
/** Who makes it ("Anthropic"): a catalog runtime (AG-0.5). */
|
|
461
|
+
vendor?: string;
|
|
462
|
+
/**
|
|
463
|
+
* false: it runs as its own CLI, without Den's tools yet ("Den tools:
|
|
464
|
+
* coming"). Absent for a runtime that isn't from the catalog.
|
|
465
|
+
*/
|
|
466
|
+
denTools?: boolean;
|
|
418
467
|
available: boolean;
|
|
419
468
|
/** Why it is unavailable. */
|
|
420
469
|
why?: string;
|
|
421
470
|
version?: string;
|
|
422
471
|
/** The CLI it runs (AG-0.2). */
|
|
423
472
|
path?: string;
|
|
424
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* Where that path came from: workspace (its workspaces.yml entry),
|
|
475
|
+
* setting (user.yml), login-shell, PATH, well-known, interactive-shell.
|
|
476
|
+
*/
|
|
425
477
|
source?: BinSource;
|
|
426
478
|
/** The directories searched, in order (a runtime with a CLI to find). */
|
|
427
479
|
searched?: string[];
|
|
428
480
|
/** The user.yml key that sets its path (a runtime with a CLI to find). */
|
|
429
481
|
setting?: string;
|
|
482
|
+
/** The workspace's key for it, when listed for a workspace. */
|
|
483
|
+
workspaceSetting?: string;
|
|
430
484
|
/** It declares resume. */
|
|
431
485
|
resume: boolean;
|
|
432
486
|
default: boolean;
|
|
433
487
|
}
|
|
434
488
|
|
|
435
489
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
|
|
490
|
+
* The path set for runtime `id` (AG-0.5): the workspace's (when `ws` is
|
|
491
|
+
* given and it sets one), else user.yml's; null when neither does.
|
|
492
|
+
*/
|
|
493
|
+
export function runtimeSetting(
|
|
494
|
+
d: AgentsResolved,
|
|
495
|
+
id: string,
|
|
496
|
+
ws?: string,
|
|
497
|
+
): BinSetting | null {
|
|
498
|
+
if (ws) {
|
|
499
|
+
const v = d.workspaceRuntimeBin(ws, id);
|
|
500
|
+
if (v !== undefined && v !== null)
|
|
501
|
+
return { value: v, source: "workspace", key: workspaceBinKey(ws, id) };
|
|
502
|
+
}
|
|
503
|
+
const g = d.runtimeBin(id);
|
|
504
|
+
return g === undefined || g === null ? null : { value: g, source: "setting" };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Whether runtime `rt` can run here (AG-0.1, AG-0.5): a path set for the
|
|
509
|
+
* workspace, else user.yml's (`agents.runtimes.<id>.bin`), else the
|
|
510
|
+
* search. Runs nothing.
|
|
511
|
+
*/
|
|
512
|
+
export function detectRuntime(
|
|
513
|
+
d: AgentsResolved,
|
|
514
|
+
rt: Runtime,
|
|
515
|
+
ws?: string,
|
|
516
|
+
): RuntimeDetect {
|
|
517
|
+
return rt.detect(d.env, { bin: runtimeSetting(d, rt.id, ws) });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The catalog entries the distribution and user.yml add, registered as
|
|
522
|
+
* runtimes (AG-0.5). A malformed entry is a refusal naming its key.
|
|
523
|
+
*/
|
|
524
|
+
export function syncCatalog(d: AgentsResolved): void {
|
|
525
|
+
const cfg = agentsConfig(d);
|
|
526
|
+
let extra: CatalogEntry[];
|
|
527
|
+
try {
|
|
528
|
+
extra = [
|
|
529
|
+
...cfg.runtimeCatalog,
|
|
530
|
+
...d.userCatalog([
|
|
531
|
+
...BUILTIN_CATALOG.map((e) => e.id),
|
|
532
|
+
...cfg.runtimeCatalog.map((e) => e.id),
|
|
533
|
+
]),
|
|
534
|
+
];
|
|
535
|
+
} catch (e) {
|
|
536
|
+
throw refused((e as Error).message, [], 422);
|
|
537
|
+
}
|
|
538
|
+
syncConfiguredRuntimes(extra);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* When a catalog runtime is still missing (nothing set, not found on the
|
|
543
|
+
* login PATH, PATH or its install directories), ask the interactive shell
|
|
544
|
+
* for its PATH: once per process, then kept (AG-0.5).
|
|
545
|
+
*/
|
|
546
|
+
async function fallbackSearch(
|
|
547
|
+
d: AgentsResolved,
|
|
548
|
+
rts: Runtime[],
|
|
549
|
+
ws?: string,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
if (interactivePathAsked()) return;
|
|
552
|
+
const missing = rts.some(
|
|
553
|
+
(rt) =>
|
|
554
|
+
rt.catalog &&
|
|
555
|
+
!runtimeSetting(d, rt.id, ws) &&
|
|
556
|
+
!detectRuntime(d, rt, ws).available,
|
|
557
|
+
);
|
|
558
|
+
if (missing) await ensureInteractivePath({ env: d.env });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The catalog's installed runtimes first, then the ones not found, then
|
|
563
|
+
* the rest (generic: no CLI of its own to find).
|
|
439
564
|
*/
|
|
440
|
-
|
|
441
|
-
|
|
565
|
+
function rank(r: RuntimeChoice): number {
|
|
566
|
+
if (r.denTools === undefined) return 2;
|
|
567
|
+
return r.available ? 0 : 1;
|
|
442
568
|
}
|
|
443
569
|
|
|
444
570
|
/**
|
|
445
|
-
* Every registered runtime, and whether it can run here
|
|
446
|
-
*
|
|
447
|
-
*
|
|
571
|
+
* Every registered runtime, and whether it can run here (for an agent of
|
|
572
|
+
* `ws`, when given: its workspace's path wins), with the path it runs and
|
|
573
|
+
* its version: its version command (`claude --version`), run once under a
|
|
574
|
+
* timeout and kept, else what its install says. Installed ones first.
|
|
448
575
|
*/
|
|
449
576
|
export async function listRuntimes(
|
|
450
577
|
d: AgentsResolved,
|
|
578
|
+
ws?: string,
|
|
451
579
|
): Promise<RuntimeChoice[]> {
|
|
452
|
-
|
|
453
|
-
|
|
580
|
+
syncCatalog(d);
|
|
581
|
+
const ids = runtimeIds();
|
|
582
|
+
await fallbackSearch(
|
|
583
|
+
d,
|
|
584
|
+
ids.map((id) => getRuntime(id)!),
|
|
585
|
+
ws,
|
|
586
|
+
);
|
|
587
|
+
const rows = await Promise.all(
|
|
588
|
+
ids.map(async (id) => {
|
|
454
589
|
const rt = getRuntime(id)!;
|
|
455
|
-
const det = detectRuntime(d, rt);
|
|
590
|
+
const det = detectRuntime(d, rt, ws);
|
|
456
591
|
const asked =
|
|
457
592
|
det.available && det.path && rt.find?.versionArgs
|
|
458
593
|
? await runtimeVersion(det.path, rt.find.versionArgs, d.env)
|
|
@@ -461,24 +596,72 @@ export async function listRuntimes(
|
|
|
461
596
|
return {
|
|
462
597
|
id,
|
|
463
598
|
...(rt.label ? { label: rt.label } : {}),
|
|
599
|
+
...(rt.vendor ? { vendor: rt.vendor } : {}),
|
|
600
|
+
...(rt.catalog ? { denTools: rt.denTools === true } : {}),
|
|
464
601
|
available: det.available,
|
|
465
602
|
...(det.available ? {} : { why: det.why ?? "unavailable" }),
|
|
466
603
|
...(version ? { version } : {}),
|
|
467
604
|
...(det.path ? { path: det.path } : {}),
|
|
468
605
|
...(det.source ? { source: det.source } : {}),
|
|
469
606
|
...(rt.find
|
|
470
|
-
? {
|
|
607
|
+
? {
|
|
608
|
+
searched: det.searched ?? [],
|
|
609
|
+
setting: binSettingKey(id),
|
|
610
|
+
...(ws ? { workspaceSetting: workspaceBinKey(ws, id) } : {}),
|
|
611
|
+
}
|
|
471
612
|
: {}),
|
|
472
613
|
resume: Boolean(rt.resume),
|
|
473
614
|
default: id === DEFAULT_RUNTIME_ID,
|
|
474
615
|
};
|
|
475
616
|
}),
|
|
476
617
|
);
|
|
618
|
+
return rows
|
|
619
|
+
.map((r, i) => ({ r, i }))
|
|
620
|
+
.sort((a, b) => rank(a.r) - rank(b.r) || a.i - b.i)
|
|
621
|
+
.map((x) => x.r);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Set (or, with null, clear) runtime `id`'s path, global or for workspace
|
|
626
|
+
* `ws` (AG-0.5): its version command runs first, and a path that isn't
|
|
627
|
+
* that runtime is refused. Returns its row as listed after.
|
|
628
|
+
*/
|
|
629
|
+
export async function setRuntimePath(
|
|
630
|
+
d: AgentsResolved,
|
|
631
|
+
req: {
|
|
632
|
+
id: string;
|
|
633
|
+
scope: "global" | "workspace";
|
|
634
|
+
workspace?: string;
|
|
635
|
+
bin: string | null;
|
|
636
|
+
},
|
|
637
|
+
): Promise<RuntimeChoice> {
|
|
638
|
+
syncCatalog(d);
|
|
639
|
+
const rt = runtimeFor(req.id);
|
|
640
|
+
if (!rt.catalog)
|
|
641
|
+
throw usage(`${req.id} has no CLI to find: its path can't be set`);
|
|
642
|
+
let ws: string | undefined;
|
|
643
|
+
if (req.scope === "workspace") {
|
|
644
|
+
ws = checkWorkspaceName("runtime path", req.workspace);
|
|
645
|
+
if (!d.workspaceExists(ws))
|
|
646
|
+
throw usage(`workspace "${ws}" does not exist`, 404);
|
|
647
|
+
}
|
|
648
|
+
if (req.bin !== null) {
|
|
649
|
+
const c = await checkRuntimeBin(rt.catalog, req.bin, d.env);
|
|
650
|
+
if (!c.ok) throw refused(c.why, [], 422);
|
|
651
|
+
d.saveRuntimeBin(req.scope, ws, rt.id, c.path);
|
|
652
|
+
} else d.saveRuntimeBin(req.scope, ws, rt.id, null);
|
|
653
|
+
const rows = await listRuntimes(d, ws);
|
|
654
|
+
return rows.find((r) => r.id === rt.id)!;
|
|
477
655
|
}
|
|
478
656
|
|
|
479
657
|
/** Refused unless `rt` can run here; its detect result when it can. */
|
|
480
|
-
function availableOrRefuse(
|
|
481
|
-
|
|
658
|
+
async function availableOrRefuse(
|
|
659
|
+
d: AgentsResolved,
|
|
660
|
+
rt: Runtime,
|
|
661
|
+
ws?: string,
|
|
662
|
+
): Promise<RuntimeDetect> {
|
|
663
|
+
await fallbackSearch(d, [rt], ws);
|
|
664
|
+
const det = detectRuntime(d, rt, ws);
|
|
482
665
|
if (!det.available)
|
|
483
666
|
throw refused(
|
|
484
667
|
`${rt.id} is unavailable: ${det.why ?? "unknown"}`,
|
|
@@ -538,9 +721,15 @@ interface Launch {
|
|
|
538
721
|
binDir?: string;
|
|
539
722
|
}
|
|
540
723
|
|
|
541
|
-
/**
|
|
724
|
+
/**
|
|
725
|
+
* A CLI's directory when the session's PATH wouldn't have it (a path set
|
|
726
|
+
* for it, or found by the interactive shell), for its own tools.
|
|
727
|
+
*/
|
|
542
728
|
function binDirOf(det: RuntimeDetect): { binDir?: string } {
|
|
543
|
-
return det.source === "setting"
|
|
729
|
+
return (det.source === "setting" ||
|
|
730
|
+
det.source === "workspace" ||
|
|
731
|
+
det.source === "interactive-shell") &&
|
|
732
|
+
det.path
|
|
544
733
|
? { binDir: path.dirname(det.path) }
|
|
545
734
|
: {};
|
|
546
735
|
}
|
|
@@ -766,8 +955,9 @@ export async function startAgent(
|
|
|
766
955
|
);
|
|
767
956
|
const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
|
|
768
957
|
checkId(where, id);
|
|
958
|
+
syncCatalog(d);
|
|
769
959
|
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
770
|
-
const det = availableOrRefuse(d, rt);
|
|
960
|
+
const det = await availableOrRefuse(d, rt, ws);
|
|
771
961
|
const model = req.model ?? persona.model;
|
|
772
962
|
|
|
773
963
|
const prev = readRecord(d.stateDir(), id);
|
|
@@ -871,6 +1061,7 @@ export async function resumeAgent(
|
|
|
871
1061
|
ws?: string,
|
|
872
1062
|
): Promise<Resumed> {
|
|
873
1063
|
const rec = agentRecord(d, id, ws);
|
|
1064
|
+
syncCatalog(d);
|
|
874
1065
|
const rt = runtimeFor(rec.runtime);
|
|
875
1066
|
if (!rt.resume)
|
|
876
1067
|
throw refused(`runtime ${rt.id} does not declare resume`, [
|
|
@@ -880,7 +1071,7 @@ export async function resumeAgent(
|
|
|
880
1071
|
const persona = personas(d, cfg).get(rec.persona);
|
|
881
1072
|
if (!persona)
|
|
882
1073
|
throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
|
|
883
|
-
const det = availableOrRefuse(d, rt);
|
|
1074
|
+
const det = await availableOrRefuse(d, rt, rec.workspace);
|
|
884
1075
|
ptyOrRefuse(d);
|
|
885
1076
|
return withClient(d, async (c) => {
|
|
886
1077
|
const old = sessionOf(await c.list(), rec);
|