@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.
- package/CHANGELOG.md +273 -0
- package/README.md +9 -9
- package/docs/distributions.md +313 -50
- 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 +53 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +543 -0
- package/src/shared/agents/runtime.ts +43 -1
- package/src/shared/agents/runtimes.ts +53 -4
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +92 -2
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -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/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/ui/cli/agent-ops.ts +298 -19
- package/src/ui/cli/agents-command.ts +129 -4
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- 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 +123 -26
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +171 -25
- package/src/ui/server/public/agent-start.js +241 -0
- package/src/ui/server/public/agents-page.js +246 -45
- package/src/ui/server/public/agents-section.js +7 -21
- package/src/ui/server/public/agents.html +55 -1
- 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 +26 -13
- package/src/ui/server/public/i18n/ja.js +25 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +62 -22
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +226 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +67 -22
- package/src/ui/server/public/topbar.js +217 -158
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +123 -8
- package/src/ui/server/route-classes.ts +14 -2
- 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
|
@@ -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 {
|
|
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;
|
package/src/shared/den-config.ts
CHANGED
|
@@ -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 = [
|
|
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 =
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
+
}
|