@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.
- package/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/docs/distributions.md +215 -65
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +57 -3
- 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 +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/index.ts +77 -6
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/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 +15 -13
- package/src/ui/server/public/i18n/ja.js +14 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +28 -17
- 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 +225 -0
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +28 -23
- package/src/ui/server/public/topbar.js +166 -206
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +1 -1
- 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/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;
|
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";
|
|
@@ -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?: {
|
|
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 = [
|
|
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 =
|
|
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
|
+
}
|
|
@@ -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;
|