@monster-orange/den 0.4.3 → 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 (58) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +215 -65
  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 +14 -18
  8. package/src/shared/agents/runtime-search.ts +142 -15
  9. package/src/shared/agents/runtime.ts +18 -1
  10. package/src/shared/agents/runtimes.ts +23 -1
  11. package/src/shared/auth/sudo-vault.ts +16 -0
  12. package/src/shared/den-config.ts +57 -3
  13. package/src/shared/edge/status.ts +134 -0
  14. package/src/shared/environment-manager/health.ts +82 -8
  15. package/src/shared/environment-manager/workspaces.ts +54 -0
  16. package/src/shared/health/not-checked.ts +42 -0
  17. package/src/shared/health/rollup.ts +4 -0
  18. package/src/shared/process/spawn-streaming.ts +8 -3
  19. package/src/shared/sessions/client.ts +5 -1
  20. package/src/shared/sessions/host.ts +29 -1
  21. package/src/shared/sessions/protocol.ts +6 -1
  22. package/src/shared/sessions/types.ts +6 -0
  23. package/src/shared/stacks/status.ts +24 -0
  24. package/src/shared/strategies/registry.ts +5 -0
  25. package/src/shared/types/index.ts +6 -0
  26. package/src/ui/cli/agent-ops.ts +210 -19
  27. package/src/ui/cli/agents-command.ts +50 -21
  28. package/src/ui/cli/edge-command.ts +16 -3
  29. package/src/ui/cli/health-display.ts +4 -1
  30. package/src/ui/mcp/scope.ts +23 -3
  31. package/src/ui/mcp/server.ts +31 -7
  32. package/src/ui/mcp/tools.ts +327 -20
  33. package/src/ui/mcp/views.ts +450 -0
  34. package/src/ui/server/agent-routes.ts +118 -28
  35. package/src/ui/server/index.ts +77 -6
  36. package/src/ui/server/public/agent-start.js +46 -7
  37. package/src/ui/server/public/agents-page.js +232 -97
  38. package/src/ui/server/public/agents-section.js +3 -5
  39. package/src/ui/server/public/agents.html +12 -28
  40. package/src/ui/server/public/graph-model.js +2 -0
  41. package/src/ui/server/public/header.js +2 -3
  42. package/src/ui/server/public/health-rollup.js +20 -8
  43. package/src/ui/server/public/i18n/es.js +15 -13
  44. package/src/ui/server/public/i18n/ja.js +14 -13
  45. package/src/ui/server/public/index.html +17 -4
  46. package/src/ui/server/public/local-dev.html +28 -17
  47. package/src/ui/server/public/services.html +21 -5
  48. package/src/ui/server/public/settings-page.js +3 -2
  49. package/src/ui/server/public/settings-registry.js +225 -0
  50. package/src/ui/server/public/settings.html +90 -1
  51. package/src/ui/server/public/shell.js +28 -23
  52. package/src/ui/server/public/topbar.js +166 -206
  53. package/src/ui/server/readonly-routes.ts +29 -5
  54. package/src/ui/server/request-guard.ts +1 -1
  55. package/src/ui/server/route-classes.ts +14 -2
  56. package/src/ui/server/session-routes.ts +9 -1
  57. package/src/ui/server/sudo-store.ts +170 -6
  58. package/src/ui/server/workspace-routes.ts +10 -3
@@ -13,8 +13,10 @@ import {
13
13
  getStrategy,
14
14
  registerStrategy,
15
15
  strategyIds,
16
+ unregisterStrategy,
16
17
  type StrategyEntry,
17
18
  } from "../strategies/registry.js";
19
+ import type { CatalogEntry } from "./catalog.js";
18
20
  import type { Persona } from "./personas.js";
19
21
  import type { BinSource, RuntimeFind } from "./runtime-search.js";
20
22
 
@@ -39,7 +41,8 @@ export interface RuntimeDetect {
39
41
  export interface DetectOptions {
40
42
  /**
41
43
  * `agents.runtimes.<id>.bin` as configured in user.yml (undefined:
42
- * read it there; null: none).
44
+ * read it there; null: none), or a BinSetting (runtime-search.ts) that
45
+ * says where it was set (the workspace's, or global).
43
46
  */
44
47
  bin?: unknown;
45
48
  }
@@ -107,6 +110,15 @@ export interface Runtime {
107
110
  id: string;
108
111
  /** Its name for people ("Claude Code"), in the Start agent form. */
109
112
  label?: string;
113
+ /** Who makes it ("Anthropic"): a catalog runtime's vendor (AG-0.5). */
114
+ vendor?: string;
115
+ /** Its catalog entry (catalog.ts), when it is one. */
116
+ catalog?: CatalogEntry;
117
+ /**
118
+ * Whether it runs with Den's tools (its MCP config and guards). false:
119
+ * it runs as its own CLI, tagged "Den tools: coming" (AG-0.5).
120
+ */
121
+ denTools?: boolean;
110
122
  /**
111
123
  * How its CLI is found and its version asked (AG-0.1): the search's
112
124
  * binary names and install directories, per runtime, so a catalog can
@@ -143,6 +155,11 @@ export class LaunchError extends Error {
143
155
  }
144
156
  }
145
157
 
158
+ /** Remove a runtime (a configured catalog entry that is gone). */
159
+ export function unregisterRuntime(id: string): void {
160
+ unregisterStrategy(RUNTIME_KIND, id);
161
+ }
162
+
146
163
  /** Register a runtime as strategy kind `runtime` (doctor lists it). */
147
164
  export function registerRuntime(rt: Runtime): void {
148
165
  const entry: StrategyEntry<typeof RUNTIME_KIND, Runtime> = {
@@ -1,7 +1,12 @@
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
12
  import {
@@ -9,6 +14,7 @@ import {
9
14
  getRuntime,
10
15
  registerRuntime,
11
16
  runtimeIds,
17
+ unregisterRuntime,
12
18
  type Runtime,
13
19
  } from "./runtime.js";
14
20
  import { sessionPath, type RuntimeFind } from "./runtime-search.js";
@@ -16,8 +22,24 @@ import { ACTOR_TOKEN_ENV } from "../actor-token.js";
16
22
  import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
17
23
 
18
24
  registerRuntime(denClaude);
25
+ for (const e of BUILTIN_CATALOG)
26
+ if (e.id !== denClaude.id) registerRuntime(catalogRuntime(e));
19
27
  registerRuntime(generic);
20
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
+
21
43
  export {
22
44
  allScrubEnv,
23
45
  DEFAULT_RUNTIME_ID,
@@ -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";
@@ -385,7 +386,11 @@ export interface UserConfig {
385
386
  * install directories). Per machine, so user.yml only: the distribution's
386
387
  * config never sets a path. See shared/agents/runtime-search.ts.
387
388
  */
388
- agents?: { runtimes?: Record<string, { bin?: unknown } | null> };
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
+ };
389
394
  }
390
395
 
391
396
  const ALL_ENVIRONMENTS: Environment[] = [
@@ -436,6 +441,40 @@ export function configuredRuntimeBin(
436
441
  }
437
442
  }
438
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
+
439
478
  /** Read the per-engineer user config (config/local/user.yml). Returns {} if absent. */
440
479
  export function readUserConfig(): UserConfig {
441
480
  try {
@@ -790,6 +829,11 @@ export interface AgentsConfig {
790
829
  * persona replaces one of the same id.
791
830
  */
792
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[];
793
837
  /**
794
838
  * Any other key is an agent id: `agents.<id>.grants` widens that agent's
795
839
  * MCP scope to other workspaces (AG spec §5b). Config only: an agent
@@ -801,7 +845,11 @@ export interface AgentsConfig {
801
845
  export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
802
846
 
803
847
  /** The keys of `agents:` that are not an agent id. */
804
- 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
+ ];
805
853
 
806
854
  /** One agent's own block, `agents.<id>`. */
807
855
  export interface AgentGrantsConfig {
@@ -816,6 +864,8 @@ export interface ResolvedAgentsConfig {
816
864
  personaPacks: string[];
817
865
  /** `agents.<id>` blocks, by agent id. */
818
866
  agents: Record<string, AgentGrantsConfig>;
867
+ /** The distribution's runtime catalog entries (AG-0.5), checked. */
868
+ runtimeCatalog: CatalogEntry[];
819
869
  }
820
870
 
821
871
  /** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
@@ -838,6 +888,10 @@ export function readAgentsConfig(
838
888
  maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
839
889
  personaPacks: [],
840
890
  agents: {},
891
+ runtimeCatalog: validateCatalog(
892
+ raw.runtime_catalog,
893
+ "agents.runtime_catalog",
894
+ ),
841
895
  };
842
896
  const max = raw.max_concurrent;
843
897
  if (max !== undefined) {
@@ -866,7 +920,7 @@ export function readAgentsConfig(
866
920
  const where = `agents.${id}`;
867
921
  if (!AGENT_KEY_RE.test(id) || !isMapping(block))
868
922
  throw new Error(
869
- `${where}: unknown key (agents: takes max_concurrent, persona_packs, ` +
923
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, ` +
870
924
  `or an agent id mapping to { grants: [...] })`,
871
925
  );
872
926
  for (const k of Object.keys(block))
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * MCP-2.4: why a service has no health verdict, in words. A row with `ok:
3
+ * null` and no reason reads as "broken" to an agent (it fell back to
4
+ * `docker ps`); every unchecked row says which of these it is. One rule for
5
+ * the web health row (/api/health) and the MCP `status` tool, which derives
6
+ * it from a row an older web server served without one.
7
+ */
8
+ import { product } from "../product.js";
9
+ import type { Runs } from "../manifest/placement.js";
10
+
11
+ export interface NotCheckedInput {
12
+ /** The env being viewed. */
13
+ env: string;
14
+ runs: Runs | undefined;
15
+ /** A remote service's target (its env), when it has one. */
16
+ target?: { env?: string } | null;
17
+ /** false when the repo has no manifest at all. */
18
+ manifest?: boolean;
19
+ /** The conventional probe tried (and not answered 2xx), if any. */
20
+ conventional?: string | null;
21
+ }
22
+
23
+ export function notCheckedReason(o: NotCheckedInput): string {
24
+ if (o.manifest === false)
25
+ return `not checked: the repo has no ${product().manifestYml}`;
26
+ if (o.runs === "remote") {
27
+ const where = o.target?.env ?? o.env;
28
+ return (
29
+ `not checked: remote (runs in ${where}), and the manifest declares ` +
30
+ `no health check or target.url for ${where}`
31
+ );
32
+ }
33
+ if (o.runs === "none" || o.runs === undefined)
34
+ return (
35
+ `not checked: not deployed in ${o.env} (the manifest has no ` +
36
+ `commands and no health check for ${o.env})`
37
+ );
38
+ return (
39
+ `not checked: no health check in the manifest for ${o.env}` +
40
+ (o.conventional ? `, and ${o.conventional} did not answer 2xx` : "")
41
+ );
42
+ }
@@ -17,6 +17,8 @@ export interface HealthSignal {
17
17
  isEntrypoint?: boolean;
18
18
  /** Not answered yet this cycle: the repo stays unknown until it has. */
19
19
  awaiting?: boolean;
20
+ /** The check answered with a redirect (ok null): counted as a redirect. */
21
+ redirect?: boolean;
20
22
  }
21
23
 
22
24
  export interface RepoRollup {
@@ -29,6 +31,8 @@ export interface RepoRollup {
29
31
  unchecked: number;
30
32
  /** Checks that haven't answered yet; any holds the state at unknown. */
31
33
  awaiting: number;
34
+ /** Checks that answered with a redirect (not in `unchecked`); absent at 0. */
35
+ redirected?: number;
32
36
  }
33
37
 
34
38
  export const STATE_RANK: Record<RepoState, number> = rollup.STATE_RANK;