@monster-orange/den 0.4.2 → 0.4.4

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +313 -50
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +53 -12
  8. package/src/shared/agents/generic.ts +1 -0
  9. package/src/shared/agents/runtime-search.ts +543 -0
  10. package/src/shared/agents/runtime.ts +43 -1
  11. package/src/shared/agents/runtimes.ts +53 -4
  12. package/src/shared/auth/sudo-vault.ts +16 -0
  13. package/src/shared/den-config.ts +92 -2
  14. package/src/shared/edge/render.ts +31 -0
  15. package/src/shared/edge/status.ts +134 -0
  16. package/src/shared/environment-manager/health.ts +82 -8
  17. package/src/shared/environment-manager/workspaces.ts +54 -0
  18. package/src/shared/health/not-checked.ts +42 -0
  19. package/src/shared/health/rollup.ts +4 -0
  20. package/src/shared/process/spawn-streaming.ts +8 -3
  21. package/src/shared/sessions/client.ts +5 -1
  22. package/src/shared/sessions/host.ts +29 -1
  23. package/src/shared/sessions/protocol.ts +6 -1
  24. package/src/shared/sessions/types.ts +6 -0
  25. package/src/shared/stacks/status.ts +24 -0
  26. package/src/shared/strategies/registry.ts +5 -0
  27. package/src/shared/types/index.ts +6 -0
  28. package/src/ui/cli/agent-ops.ts +298 -19
  29. package/src/ui/cli/agents-command.ts +129 -4
  30. package/src/ui/cli/edge-command.ts +16 -3
  31. package/src/ui/cli/health-display.ts +4 -1
  32. package/src/ui/cli/sessions-command.ts +9 -4
  33. package/src/ui/cli/web-command.ts +53 -8
  34. package/src/ui/mcp/scope.ts +23 -3
  35. package/src/ui/mcp/server.ts +31 -7
  36. package/src/ui/mcp/tools.ts +327 -20
  37. package/src/ui/mcp/views.ts +450 -0
  38. package/src/ui/server/agent-routes.ts +123 -26
  39. package/src/ui/server/edge-exposure.ts +143 -0
  40. package/src/ui/server/index.ts +171 -25
  41. package/src/ui/server/public/agent-start.js +241 -0
  42. package/src/ui/server/public/agents-page.js +246 -45
  43. package/src/ui/server/public/agents-section.js +7 -21
  44. package/src/ui/server/public/agents.html +55 -1
  45. package/src/ui/server/public/graph-model.js +2 -0
  46. package/src/ui/server/public/header.js +2 -3
  47. package/src/ui/server/public/health-rollup.js +20 -8
  48. package/src/ui/server/public/i18n/es.js +26 -13
  49. package/src/ui/server/public/i18n/ja.js +25 -13
  50. package/src/ui/server/public/index.html +17 -4
  51. package/src/ui/server/public/local-dev.html +62 -22
  52. package/src/ui/server/public/services.html +21 -5
  53. package/src/ui/server/public/settings-page.js +3 -2
  54. package/src/ui/server/public/settings-registry.js +226 -1
  55. package/src/ui/server/public/settings.html +90 -1
  56. package/src/ui/server/public/shell.js +67 -22
  57. package/src/ui/server/public/topbar.js +217 -158
  58. package/src/ui/server/readonly-routes.ts +29 -5
  59. package/src/ui/server/request-guard.ts +123 -8
  60. package/src/ui/server/route-classes.ts +14 -2
  61. package/src/ui/server/session-routes.ts +9 -1
  62. package/src/ui/server/sudo-store.ts +170 -6
  63. package/src/ui/server/ui-assets.ts +2 -0
  64. package/src/ui/server/workspace-routes.ts +10 -3
@@ -1,16 +1,45 @@
1
1
  /**
2
2
  * Den's built-in runtimes, registered as strategy kind `runtime`: import
3
- * this module to have them. den-claude first: it is the default.
3
+ * this module to have them. den-claude first: it is the default. Then the
4
+ * rest of the runtime catalog (AG-0.5: codex, gemini, grok), then generic.
5
+ * Entries a distribution or user.yml adds are registered by
6
+ * syncConfiguredRuntimes, after the built-ins.
4
7
  */
8
+ import { BUILTIN_CATALOG, type CatalogEntry } from "./catalog.js";
9
+ import { catalogRuntime } from "./catalog-runtime.js";
5
10
  import { denClaude } from "./den-claude.js";
6
11
  import { generic } from "./generic.js";
7
- import { allScrubEnv, registerRuntime, type Runtime } from "./runtime.js";
12
+ import {
13
+ allScrubEnv,
14
+ getRuntime,
15
+ registerRuntime,
16
+ runtimeIds,
17
+ unregisterRuntime,
18
+ type Runtime,
19
+ } from "./runtime.js";
20
+ import { sessionPath, type RuntimeFind } from "./runtime-search.js";
8
21
  import { ACTOR_TOKEN_ENV } from "../actor-token.js";
9
22
  import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
10
23
 
11
24
  registerRuntime(denClaude);
25
+ for (const e of BUILTIN_CATALOG)
26
+ if (e.id !== denClaude.id) registerRuntime(catalogRuntime(e));
12
27
  registerRuntime(generic);
13
28
 
29
+ let configured: string[] = [];
30
+
31
+ /**
32
+ * Register the catalog entries a distribution and user.yml add (already
33
+ * checked: catalog.ts validateCatalog), replacing the last set: an entry
34
+ * taken out of the config is no runtime any more.
35
+ */
36
+ export function syncConfiguredRuntimes(entries: readonly CatalogEntry[]): void {
37
+ const ids = entries.map((e) => e.id);
38
+ for (const id of configured) if (!ids.includes(id)) unregisterRuntime(id);
39
+ for (const e of entries) registerRuntime(catalogRuntime(e));
40
+ configured = ids;
41
+ }
42
+
14
43
  export {
15
44
  allScrubEnv,
16
45
  DEFAULT_RUNTIME_ID,
@@ -25,9 +54,27 @@ export {
25
54
  type Runtime,
26
55
  } from "./runtime.js";
27
56
 
57
+ /** Every registered runtime's find (its well-known directories). */
58
+ export function allRuntimeFinds(): (RuntimeFind | undefined)[] {
59
+ return runtimeIds().map((id) => getRuntime(id)?.find);
60
+ }
61
+
62
+ /**
63
+ * The caller's env with the PATH a session gets (AG-0.1): the login
64
+ * shell's PATH, the caller's, then every runtime's install directories
65
+ * that exist, and `extraDirs` (a configured CLI's directory).
66
+ */
67
+ export function withSessionPath(
68
+ caller: NodeJS.ProcessEnv,
69
+ extraDirs: readonly string[] = [],
70
+ ): NodeJS.ProcessEnv {
71
+ const PATH = sessionPath(caller, allRuntimeFinds(), extraDirs);
72
+ return PATH === caller.PATH ? caller : { ...caller, PATH };
73
+ }
74
+
28
75
  /**
29
76
  * An agent's session env, as `agents start` builds it and `doctor --probe`
30
- * checks it: the allowlist, the runtime's passthrough and launch env, Den's
77
+ * checks it: the allowlist (its PATH the search's, withSessionPath), the runtime's passthrough and launch env, Den's
31
78
  * markers (DEN_AGENT=agent:<id>, and SEC-1c's DEN_ACTOR_TOKEN when one was
32
79
  * minted), then every runtime's scrubEnv and any person credential.
33
80
  */
@@ -38,9 +85,11 @@ export function agentSessionEnv(
38
85
  agentId: string,
39
86
  /** SEC-1c: the agent's actor token (`agents start` mints it; the probe has none). */
40
87
  actorToken?: string,
88
+ /** Added to its PATH: the directory of the CLI it runs. */
89
+ extraDirs: readonly string[] = [],
41
90
  ): Record<string, string> {
42
91
  return buildSessionEnv({
43
- caller,
92
+ caller: withSessionPath(caller, extraDirs),
44
93
  passthrough: rt.envPassthrough,
45
94
  launch,
46
95
  // The seam for a persona's or runtime's declared env: none yet.
@@ -125,6 +125,22 @@ export async function sudoVaultStatus(): Promise<SudoVaultStatus> {
125
125
  return out(true, await hasSudoInVault());
126
126
  }
127
127
 
128
+ /**
129
+ * Where a saved password would be kept, for Settings' "saved in <label>"
130
+ * (LK-0): read from the config alone, nothing spawned (a page view never
131
+ * wakes the vault).
132
+ */
133
+ export function sudoVaultWhere(): { configured: boolean; label: string } {
134
+ try {
135
+ const st = secretsStatus(readSecretsConfig(), process.env, {
136
+ detect: false,
137
+ });
138
+ return { configured: !!sudoRef(), label: st.label };
139
+ } catch {
140
+ return { configured: false, label: "" };
141
+ }
142
+ }
143
+
128
144
  /** Name of the active backend, for UI copy ("Save to Doppler"). */
129
145
  export function secretsBackendLabel(): string {
130
146
  return secretsStatus().label;
@@ -18,6 +18,7 @@ import type { SecretsConfig } from "./secrets/index.js";
18
18
  import type { OrgConfig } from "./org.js";
19
19
  import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
20
20
  import { ConfigError, isConfigError } from "./config-error.js";
21
+ import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
21
22
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
22
23
 
23
24
  import { product } from "./product.js";
@@ -379,6 +380,17 @@ export interface UserConfig {
379
380
  * src/shared/environment-manager/scope.ts.
380
381
  */
381
382
  view?: { scope?: string };
383
+ /**
384
+ * Agents on this machine (AG-0.1): `runtimes.<id>.bin`, the absolute path
385
+ * of a runtime's CLI, which wins over the search (login shell PATH, PATH,
386
+ * install directories). Per machine, so user.yml only: the distribution's
387
+ * config never sets a path. See shared/agents/runtime-search.ts.
388
+ */
389
+ agents?: {
390
+ runtimes?: Record<string, { bin?: unknown } | null>;
391
+ /** More runtime catalog entries (AG-0.5, agents/catalog.ts). */
392
+ runtime_catalog?: unknown;
393
+ };
382
394
  }
383
395
 
384
396
  const ALL_ENVIRONMENTS: Environment[] = [
@@ -400,6 +412,69 @@ const DEFAULT_CONFIG: DenConfig = {
400
412
  environments: ALL_ENVIRONMENTS,
401
413
  };
402
414
 
415
+ /** The per-engineer user config's path (config/local/user.yml), absolute. */
416
+ export function userConfigPath(): string {
417
+ return USER_CONFIG_PATH;
418
+ }
419
+
420
+ /**
421
+ * `agents.runtimes.<id>.bin` from user.yml, as written (checked where it is
422
+ * used: runtime-search.ts checkBinSetting; a block that is no mapping is
423
+ * `{ why }`); undefined when unset or when user.yml can't be read.
424
+ */
425
+ export function configuredRuntimeBin(
426
+ id: string,
427
+ read: () => UserConfig = readUserConfig,
428
+ ): unknown {
429
+ try {
430
+ const rts = read().agents?.runtimes;
431
+ if (!rts || typeof rts !== "object") return undefined;
432
+ const block = (rts as Record<string, unknown>)[id];
433
+ if (block === undefined || block === null) return undefined;
434
+ if (typeof block !== "object" || Array.isArray(block))
435
+ return {
436
+ why: `agents.runtimes.${id} must be a mapping ({ bin: <absolute path> })`,
437
+ };
438
+ return (block as { bin?: unknown }).bin;
439
+ } catch {
440
+ return undefined;
441
+ }
442
+ }
443
+
444
+ /**
445
+ * user.yml's `agents.runtime_catalog`, checked (agents/catalog.ts), after
446
+ * the built-in entries and the distribution's (`taken`). Throws naming
447
+ * the key when malformed.
448
+ */
449
+ export function configuredRuntimeCatalog(
450
+ taken: readonly string[],
451
+ read: () => UserConfig = readUserConfig,
452
+ ): CatalogEntry[] {
453
+ return validateCatalog(
454
+ read().agents?.runtime_catalog,
455
+ `agents.runtime_catalog (${path.basename(USER_CONFIG_PATH)})`,
456
+ taken,
457
+ );
458
+ }
459
+
460
+ /**
461
+ * Set (a path) or clear (null) `agents.runtimes.<id>.bin` in user.yml,
462
+ * keeping everything else there. The caller checks the path first.
463
+ */
464
+ export function setUserRuntimeBin(id: string, bin: string | null): void {
465
+ const cfg = readUserConfig();
466
+ const agents = { ...(cfg.agents ?? {}) };
467
+ const runtimes = { ...(agents.runtimes ?? {}) };
468
+ if (bin === null) delete runtimes[id];
469
+ else runtimes[id] = { ...(runtimes[id] ?? {}), bin };
470
+ if (Object.keys(runtimes).length) agents.runtimes = runtimes;
471
+ else delete agents.runtimes;
472
+ const next: UserConfig = { ...cfg };
473
+ if (Object.keys(agents).length) next.agents = agents;
474
+ else delete next.agents;
475
+ writeUserConfig(next);
476
+ }
477
+
403
478
  /** Read the per-engineer user config (config/local/user.yml). Returns {} if absent. */
404
479
  export function readUserConfig(): UserConfig {
405
480
  try {
@@ -754,6 +829,11 @@ export interface AgentsConfig {
754
829
  * persona replaces one of the same id.
755
830
  */
756
831
  persona_packs?: string[];
832
+ /**
833
+ * More runtime catalog entries (AG-0.5, agents/catalog.ts): a vendor CLI
834
+ * Den doesn't ship, with the same fields and checks as a built-in one.
835
+ */
836
+ runtime_catalog?: unknown[];
757
837
  /**
758
838
  * Any other key is an agent id: `agents.<id>.grants` widens that agent's
759
839
  * MCP scope to other workspaces (AG spec §5b). Config only: an agent
@@ -765,7 +845,11 @@ export interface AgentsConfig {
765
845
  export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
766
846
 
767
847
  /** The keys of `agents:` that are not an agent id. */
768
- export const AGENTS_RESERVED_KEYS = ["max_concurrent", "persona_packs"];
848
+ export const AGENTS_RESERVED_KEYS = [
849
+ "max_concurrent",
850
+ "persona_packs",
851
+ "runtime_catalog",
852
+ ];
769
853
 
770
854
  /** One agent's own block, `agents.<id>`. */
771
855
  export interface AgentGrantsConfig {
@@ -780,6 +864,8 @@ export interface ResolvedAgentsConfig {
780
864
  personaPacks: string[];
781
865
  /** `agents.<id>` blocks, by agent id. */
782
866
  agents: Record<string, AgentGrantsConfig>;
867
+ /** The distribution's runtime catalog entries (AG-0.5), checked. */
868
+ runtimeCatalog: CatalogEntry[];
783
869
  }
784
870
 
785
871
  /** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
@@ -802,6 +888,10 @@ export function readAgentsConfig(
802
888
  maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
803
889
  personaPacks: [],
804
890
  agents: {},
891
+ runtimeCatalog: validateCatalog(
892
+ raw.runtime_catalog,
893
+ "agents.runtime_catalog",
894
+ ),
805
895
  };
806
896
  const max = raw.max_concurrent;
807
897
  if (max !== undefined) {
@@ -830,7 +920,7 @@ export function readAgentsConfig(
830
920
  const where = `agents.${id}`;
831
921
  if (!AGENT_KEY_RE.test(id) || !isMapping(block))
832
922
  throw new Error(
833
- `${where}: unknown key (agents: takes max_concurrent, persona_packs, ` +
923
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, ` +
834
924
  `or an agent id mapping to { grants: [...] })`,
835
925
  );
836
926
  for (const k of Object.keys(block))
@@ -70,6 +70,37 @@ export function routeUrl(
70
70
  return `https://${routeHost(name, domain)}${p === 443 ? "" : `:${p}`}`;
71
71
  }
72
72
 
73
+ /**
74
+ * The edge routes to a web server on one of `ports` (an upstream on the
75
+ * docker host or loopback), under every edge domain: `{ host, url }`, the
76
+ * hostname (lowercase) and the URL a browser uses there.
77
+ */
78
+ export function webRouteHosts(
79
+ cfg: EdgeConfig,
80
+ ports: ReadonlySet<number>,
81
+ ): { host: string; url: string }[] {
82
+ return Object.entries(cfg.routes)
83
+ .filter(([, upstream]) => {
84
+ try {
85
+ const u = new URL(upstream);
86
+ return (
87
+ ports.has(Number(u.port || (u.protocol === "https:" ? 443 : 80))) &&
88
+ ["host.docker.internal", "localhost", "127.0.0.1"].includes(
89
+ u.hostname,
90
+ )
91
+ );
92
+ } catch {
93
+ return false;
94
+ }
95
+ })
96
+ .flatMap(([name]) =>
97
+ edgeDomains(cfg).map((d) => ({
98
+ host: routeHost(name, d).toLowerCase(),
99
+ url: routeUrl(name, cfg, d),
100
+ })),
101
+ );
102
+ }
103
+
73
104
  /** Names that go on the org's certificate. */
74
105
  export function certNames(cfg: EdgeConfig): string[] {
75
106
  const zones = [...cfg.workspaces, ...(cfg.subzones ?? [])];
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
+ import * as tls from "tls";
8
9
  import { certNames, edgeDomains, edgeNames, routeUrl } from "./render.js";
9
10
  import {
10
11
  includedRouters,
@@ -28,7 +29,18 @@ export interface EdgeStatus {
28
29
  routes: { name: string; url: string; upstream: string }[];
29
30
  /** Names on the issued cert, or the planned ones when none is issued yet. */
30
31
  certNames: string[];
32
+ /**
33
+ * Whether Den minted the edge's cert itself (mkcert: its names stamp is
34
+ * there). False under `tls: files` (the distribution supplies the cert)
35
+ * and `tls: none`, whether or not HTTPS serves: see `https`.
36
+ */
37
+ certIssuedByDen: boolean;
38
+ /** @deprecated certIssuedByDen (MCP-2.5); kept for one release. */
31
39
  certIssued: boolean;
40
+ /** MCP-2.5: the TLS strategy in words, and whether HTTPS is serving. */
41
+ https: EdgeHttps;
42
+ /** Fields kept for one release under an old name, and what replaces them. */
43
+ deprecated: { certIssued: string };
32
44
  /** Whether the CA is trusted on this machine (null: can't tell, or none). */
33
45
  caTrusted: boolean | null;
34
46
  /**
@@ -59,6 +71,28 @@ export interface EdgeStatus {
59
71
  stackErrors: IncludedRouters["errors"];
60
72
  }
61
73
 
74
+ export interface EdgeHttps {
75
+ strategy: EdgeConfig["tls"]["strategy"];
76
+ /**
77
+ * Who supplies the cert: "den" (mkcert, minted here), "distribution"
78
+ * (`tls: files`, or the legacy `issue_cert: false`), or "none" (http only).
79
+ */
80
+ certs: "den" | "distribution" | "none";
81
+ /**
82
+ * Whether the edge answers a TLS handshake on its https port: true, false,
83
+ * or null when it was not probed (`edge status` from the CLI, or a proxy
84
+ * in front owns the port).
85
+ */
86
+ serving: boolean | null;
87
+ /** How `serving` was decided, in words. */
88
+ detail: string;
89
+ }
90
+
91
+ export const CERT_ISSUED_DEPRECATION =
92
+ "certIssued is renamed certIssuedByDen (0.4.4) and goes in the release " +
93
+ "after: it says whether Den minted the cert itself, so it is false under " +
94
+ "tls strategy files while HTTPS serves. Read https.serving for that.";
95
+
62
96
  export interface EdgeStatusDeps {
63
97
  containerState: (name: string) => string;
64
98
  caTrusted: (cfg: EdgeConfig) => boolean | null;
@@ -113,7 +147,23 @@ export function edgeStatus(
113
147
  domains: edgeDomains(cfg),
114
148
  routes: edgeRoutes(cfg),
115
149
  certNames: issued ?? (cfg.tls.strategy === "none" ? [] : certNames(cfg)),
150
+ certIssuedByDen: issued !== null,
116
151
  certIssued: issued !== null,
152
+ https: {
153
+ strategy: cfg.tls.strategy,
154
+ certs:
155
+ cfg.tls.strategy === "mkcert"
156
+ ? "den"
157
+ : cfg.tls.strategy === "files"
158
+ ? "distribution"
159
+ : "none",
160
+ serving: cfg.tls.strategy === "none" ? false : null,
161
+ detail:
162
+ cfg.tls.strategy === "none"
163
+ ? "tls: none: the edge serves http only"
164
+ : "not probed",
165
+ },
166
+ deprecated: { certIssued: CERT_ISSUED_DEPRECATION },
117
167
  caTrusted: cfg.tls.strategy === "none" ? null : deps.caTrusted(cfg),
118
168
  tls: {
119
169
  strategy: cfg.tls.strategy,
@@ -132,3 +182,87 @@ export function edgeStatus(
132
182
  stackErrors: stk.errors,
133
183
  };
134
184
  }
185
+
186
+ /** One TLS handshake: whether something on host:port speaks TLS. */
187
+ export type TlsHandshake = (o: {
188
+ host: string;
189
+ port: number;
190
+ servername: string;
191
+ timeoutMs: number;
192
+ }) => Promise<{ ok: boolean; error?: string }>;
193
+
194
+ /**
195
+ * Connect, complete a TLS handshake, close. Trust is not checked here (that
196
+ * is `caTrusted`): only whether HTTPS is being served at all.
197
+ */
198
+ export const tlsHandshake: TlsHandshake = ({
199
+ host,
200
+ port,
201
+ servername,
202
+ timeoutMs,
203
+ }) =>
204
+ new Promise((resolve) => {
205
+ let settled = false;
206
+ const done = (v: { ok: boolean; error?: string }) => {
207
+ if (settled) return;
208
+ settled = true;
209
+ clearTimeout(timer);
210
+ socket.destroy();
211
+ resolve(v);
212
+ };
213
+ const socket = tls.connect({
214
+ host,
215
+ port,
216
+ servername,
217
+ rejectUnauthorized: false,
218
+ });
219
+ const timer = setTimeout(
220
+ () => done({ ok: false, error: `no TLS answer in ${timeoutMs}ms` }),
221
+ timeoutMs,
222
+ );
223
+ socket.once("secureConnect", () => done({ ok: true }));
224
+ socket.once("error", (e: NodeJS.ErrnoException) =>
225
+ done({ ok: false, error: e.code ?? e.message }),
226
+ );
227
+ socket.once("close", () =>
228
+ done({ ok: false, error: "closed before a TLS handshake" }),
229
+ );
230
+ });
231
+
232
+ /**
233
+ * `st` with `https.serving` probed (MCP-2.5): a TLS handshake on this
234
+ * machine's published https port, named as one of the edge's hosts. Not
235
+ * probed when a proxy in front owns the port (no published ports), and
236
+ * false without a handshake when the edge container is not running.
237
+ */
238
+ export async function withHttpsServing(
239
+ st: EdgeStatus,
240
+ cfg: EdgeConfig,
241
+ handshake: TlsHandshake = tlsHandshake,
242
+ ): Promise<EdgeStatus> {
243
+ if (cfg.tls.strategy === "none") return st;
244
+ const set = (serving: boolean | null, detail: string): EdgeStatus => ({
245
+ ...st,
246
+ https: { ...st.https, serving, detail },
247
+ });
248
+ if (!st.ports)
249
+ return set(
250
+ null,
251
+ "not probed: a proxy in front of the edge owns its ports (edge.publish: false)",
252
+ );
253
+ if (!st.up) return set(false, `the edge container is ${st.state}`);
254
+ const port = st.ports.https;
255
+ const servername = `${Object.keys(cfg.routes)[0] ?? "edge"}.${cfg.domain}`;
256
+ const r = await handshake({
257
+ host: "127.0.0.1",
258
+ port,
259
+ servername,
260
+ timeoutMs: 1500,
261
+ });
262
+ return r.ok
263
+ ? set(true, `TLS handshake answered on 127.0.0.1:${port} (${servername})`)
264
+ : set(
265
+ false,
266
+ `no TLS on 127.0.0.1:${port} (${servername}): ${r.error ?? "failed"}`,
267
+ );
268
+ }
@@ -63,6 +63,14 @@ export interface PingResult {
63
63
  pending?: boolean;
64
64
  status?: number;
65
65
  error?: string;
66
+ /**
67
+ * MCP-2.4: the check answered with a redirect (3xx, `status`): where it
68
+ * points, as a path for the same origin, else origin + path (the query
69
+ * and fragment are dropped: they can carry tokens). A redirect is not
70
+ * down and not up (ok null): the service answered, and the check was
71
+ * sent elsewhere.
72
+ */
73
+ redirect?: string;
66
74
  }
67
75
 
68
76
  export interface ServiceHealthResult {
@@ -255,6 +263,41 @@ function statusOk(status: number, body404?: string): boolean {
255
263
  return status < 300 || (status >= 400 && status < 500);
256
264
  }
257
265
 
266
+ /** A 3xx: the check was sent elsewhere (redirect: "manual"). */
267
+ export function isRedirect(status: number | undefined): boolean {
268
+ return status !== undefined && status >= 300 && status < 400;
269
+ }
270
+
271
+ /**
272
+ * Where a redirect points, for a health row: a path when it stays on the
273
+ * checked URL's origin, else origin + path. Never the query or fragment
274
+ * (a login redirect's `?next=` or `state=` can carry a token).
275
+ */
276
+ export function redirectTarget(
277
+ location: string | null | undefined,
278
+ from: string,
279
+ ): string {
280
+ if (!location) return "(no Location header)";
281
+ try {
282
+ const to = new URL(location, from);
283
+ const base = new URL(from);
284
+ return to.origin === base.origin
285
+ ? to.pathname
286
+ : `${to.origin}${to.pathname}`;
287
+ } catch {
288
+ return "(unreadable Location header)";
289
+ }
290
+ }
291
+
292
+ /** The PingResult for a 3xx: not down, not up, and where it points. */
293
+ function redirectResult(
294
+ status: number,
295
+ location: string | null | undefined,
296
+ from: string,
297
+ ): PingResult {
298
+ return { ok: null, status, redirect: redirectTarget(location, from) };
299
+ }
300
+
258
301
  /** The PingResult for `status`, naming an edge 404 as "no route". */
259
302
  function statusResult(status: number, body404?: string): PingResult {
260
303
  const ok = statusOk(status, body404);
@@ -309,6 +352,12 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
309
352
  },
310
353
  (res) => {
311
354
  const status = res.statusCode ?? 0;
355
+ if (isRedirect(status)) {
356
+ res.resume(); // discard the body
357
+ const loc = res.headers.location;
358
+ done(redirectResult(status, loc, parsed.toString()));
359
+ return;
360
+ }
312
361
  if (status !== 404) {
313
362
  res.resume(); // discard the body
314
363
  done({ ok: statusOk(status), status });
@@ -374,14 +423,21 @@ async function pingOnce(
374
423
  });
375
424
  clearTimeout(timer);
376
425
  // 2xx = healthy. 4xx = healthy (service is up; auth/route 4xx still proves it's responding).
377
- // 3xx = unhealthy: with redirect:"manual", a redirect means "go verify somewhere else"
378
- // and we haven't confirmed anything. Guards against hostname-to-AWS-ALB resolution
379
- // (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
380
- // on a different port — the previous `< 500` check showed those as green.
426
+ // 3xx = a redirect (MCP-2.4): ok null, with its status and target. With
427
+ // redirect:"manual" a redirect means "go verify somewhere else", so it
428
+ // is never green: that guards against hostname-to-AWS-ALB resolution
429
+ // (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that
430
+ // actually run on a different port (the old `< 500` check showed those
431
+ // green). It is not down either: something answered, and a login
432
+ // redirect (302 → /login) is how a healthy app answers a bare GET.
381
433
  // 5xx = unhealthy (service errored).
382
434
  // A 404 may be the edge's own "no route" (see EDGE_NO_ROUTE_BODY): read
383
435
  // a little of it. Otherwise discard the body: only the status matters,
384
436
  // and unconsumed bodies pin sockets in the long-lived web server.
437
+ if (isRedirect(res.status)) {
438
+ void res.body?.cancel().catch(() => {});
439
+ return redirectResult(res.status, res.headers?.get("location"), url);
440
+ }
385
441
  if (res.status === 404) {
386
442
  let body = "";
387
443
  try {
@@ -421,12 +477,17 @@ export async function pingHttp(
421
477
  // never a localhost fallback (it is not on this machine).
422
478
  if (expect !== undefined) {
423
479
  const r = await pingOnce(url, timeoutMs);
424
- return r.status === undefined
425
- ? r
426
- : { ok: expectMatches(r.status, expect), status: r.status };
480
+ if (r.status === undefined) return r;
481
+ if (expectMatches(r.status, expect)) return { ok: true, status: r.status };
482
+ // A redirect the manifest does not list as up is still a redirect, not
483
+ // down (MCP-2.4): a production app answering 302 → /login is serving.
484
+ if (r.redirect !== undefined) return r;
485
+ return { ok: false, status: r.status };
427
486
  }
428
487
  const result = await pingOnce(url, timeoutMs);
429
- if (!result.ok) {
488
+ // A redirect answered: no localhost fallback (that is for a name that
489
+ // could not be reached at all).
490
+ if (result.ok === false) {
430
491
  // Fall back to localhost for Docker-style hostnames regardless of how the
431
492
  // first ping failed. Timeouts on the primary are legitimate candidates for
432
493
  // retry: on macOS, VPN/`/etc/hosts` can resolve a Docker hostname to an
@@ -689,8 +750,17 @@ export function containerTarget(
689
750
  : { project: h.project, service: h.service };
690
751
  }
691
752
 
753
+ /** A health row whose check answered with a redirect (see checkService). */
754
+ export function isRedirectDetail(detail: string | undefined): boolean {
755
+ return /^redirect \d{3} → /.test(detail ?? "");
756
+ }
757
+
692
758
  /** A ping result as a ServiceHealthResult health state. */
693
759
  export function healthState(r: PingResult): ServiceHealthResult["health"] {
760
+ // A redirect is a verdict of its own (MCP-2.4): not down, and not a check
761
+ // still waiting to answer. "unknown", like an unchecked service, so a
762
+ // caller waiting for health proceeds instead of waiting it out.
763
+ if (r.ok === null && r.redirect !== undefined) return "unknown";
694
764
  return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
695
765
  }
696
766
 
@@ -814,6 +884,9 @@ export async function checkServiceHealth(
814
884
  ...placed,
815
885
  health: healthState(result),
816
886
  healthDetail:
887
+ (result.redirect !== undefined
888
+ ? `redirect ${result.status} → ${result.redirect}`
889
+ : undefined) ??
817
890
  result.error ??
818
891
  (!result.ok && result.status !== undefined
819
892
  ? `http ${result.status}`
@@ -899,6 +972,7 @@ export function rollupRepoHealth(
899
972
  service: r.subsystem,
900
973
  ok: r.health === "ok" ? true : r.health === "error" ? false : null,
901
974
  isEntrypoint: !!entrypoint && r.subsystem === entrypoint,
975
+ redirect: isRedirectDetail(r.healthDetail),
902
976
  })),
903
977
  );
904
978
  const lead =
@@ -877,3 +877,57 @@ export function generateVSCodeWorkspace(
877
877
  );
878
878
  return outPath;
879
879
  }
880
+
881
+ /** The key of workspace `ws`'s runtime path, as a refusal names it. */
882
+ export const workspaceBinKey = (ws: string, id: string) =>
883
+ `workspaces.${ws}.agents.runtimes.${id}.bin`;
884
+
885
+ /**
886
+ * `<ws>.agents.runtimes.<id>.bin` in config/local/workspaces.yml (AG-0.5),
887
+ * as written (checked where it is used); undefined when unset or when the
888
+ * file can't be read. A block that is no mapping is `{ why }`.
889
+ */
890
+ export function workspaceRuntimeBin(
891
+ ws: string,
892
+ id: string,
893
+ read: () => WorkspacesFile = readWorkspacesFile,
894
+ ): unknown {
895
+ try {
896
+ const rts = read().workspaces[ws]?.agents?.runtimes;
897
+ if (!rts || typeof rts !== "object") return undefined;
898
+ const block = (rts as Record<string, unknown>)[id];
899
+ if (block === undefined || block === null) return undefined;
900
+ if (typeof block !== "object" || Array.isArray(block))
901
+ return {
902
+ why: `${workspaceBinKey(ws, id).replace(/\.bin$/, "")} must be a mapping ({ bin: <absolute path> })`,
903
+ };
904
+ return (block as { bin?: unknown }).bin;
905
+ } catch {
906
+ return undefined;
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Set (a path) or clear (null) workspace `ws`'s
912
+ * `agents.runtimes.<id>.bin`. The caller checks the path first.
913
+ */
914
+ export function setWorkspaceRuntimeBin(
915
+ ws: string,
916
+ id: string,
917
+ bin: string | null,
918
+ ): void {
919
+ const file = readWorkspacesFile();
920
+ const cfg = file.workspaces[ws];
921
+ if (!cfg) throw new Error(`workspace "${ws}" does not exist`);
922
+ const agents = { ...(cfg.agents ?? {}) };
923
+ const runtimes = { ...(agents.runtimes ?? {}) };
924
+ if (bin === null) delete runtimes[id];
925
+ else runtimes[id] = { ...(runtimes[id] ?? {}), bin };
926
+ if (Object.keys(runtimes).length) agents.runtimes = runtimes;
927
+ else delete agents.runtimes;
928
+ const next = { ...cfg };
929
+ if (Object.keys(agents).length) next.agents = agents;
930
+ else delete next.agents;
931
+ file.workspaces[ws] = next;
932
+ writeWorkspacesFile(file);
933
+ }