@monster-orange/den 0.4.0 → 0.4.2
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 +457 -0
- package/README.md +88 -158
- package/docs/distributions.md +216 -16
- package/docs/reference.md +177 -0
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/den-config.ts +52 -3
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +16 -2
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +56 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +97 -615
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +127 -11
- package/src/ui/cli/sessions-command.ts +49 -5
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +542 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +324 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +821 -66
- package/src/ui/server/public/agents-page.js +654 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
package/src/shared/den-config.ts
CHANGED
|
@@ -244,10 +244,16 @@ export interface DenConfig {
|
|
|
244
244
|
*/
|
|
245
245
|
mcp?: McpConfig;
|
|
246
246
|
/**
|
|
247
|
-
* Agents: `max_concurrent` caps the
|
|
248
|
-
* distribution (default 4). See
|
|
247
|
+
* Agents: `max_concurrent` caps the agents running at once in this
|
|
248
|
+
* distribution (default 4); plain sessions never count. See
|
|
249
|
+
* readAgentsConfig.
|
|
249
250
|
*/
|
|
250
251
|
agents?: AgentsConfig;
|
|
252
|
+
/**
|
|
253
|
+
* Plain sessions (`<cli> sessions start`): `max_concurrent` caps those
|
|
254
|
+
* running at once (default 8); agents never count. See readSessionsConfig.
|
|
255
|
+
*/
|
|
256
|
+
sessions?: SessionsConfig;
|
|
251
257
|
/**
|
|
252
258
|
* The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
|
|
253
259
|
* the launchd service `<cli> service` installs. See readWebServiceConfig.
|
|
@@ -558,6 +564,7 @@ export function readDenConfig(): DenConfig {
|
|
|
558
564
|
security: parsed?.security,
|
|
559
565
|
mcp: parsed?.mcp,
|
|
560
566
|
agents: parsed?.agents,
|
|
567
|
+
sessions: parsed?.sessions,
|
|
561
568
|
web: parsed?.web,
|
|
562
569
|
test_accounts: parsed?.test_accounts,
|
|
563
570
|
defaults: parsed?.defaults,
|
|
@@ -739,7 +746,7 @@ export function readMcpConfig(
|
|
|
739
746
|
|
|
740
747
|
/** `agents:` in the org config. */
|
|
741
748
|
export interface AgentsConfig {
|
|
742
|
-
/**
|
|
749
|
+
/** Agents that may run at once (default 4); plain sessions never count. */
|
|
743
750
|
max_concurrent?: number;
|
|
744
751
|
/**
|
|
745
752
|
* Persona packs (AG-2a): directories of persona files, relative to the
|
|
@@ -845,6 +852,48 @@ export function readAgentsConfig(
|
|
|
845
852
|
return out;
|
|
846
853
|
}
|
|
847
854
|
|
|
855
|
+
/** `sessions:` in the org config: plain sessions (`<cli> sessions start`). */
|
|
856
|
+
export interface SessionsConfig {
|
|
857
|
+
/** Plain sessions that may run at once (default 8); agents never count. */
|
|
858
|
+
max_concurrent?: number;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export const DEFAULT_SESSIONS_MAX_CONCURRENT = 8;
|
|
862
|
+
|
|
863
|
+
/** `sessions:` with its defaults applied. */
|
|
864
|
+
export interface ResolvedSessionsConfig {
|
|
865
|
+
maxConcurrent: number;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The effective sessions config. Anything malformed throws, naming the key,
|
|
870
|
+
* as readAgentsConfig does: a typo must not lift the cap.
|
|
871
|
+
*/
|
|
872
|
+
export function readSessionsConfig(
|
|
873
|
+
config: DenConfig = readDenConfig(),
|
|
874
|
+
): ResolvedSessionsConfig {
|
|
875
|
+
const raw = (config.sessions ?? {}) as unknown;
|
|
876
|
+
if (!isMapping(raw))
|
|
877
|
+
throw new Error(`sessions: must be a mapping (got ${JSON.stringify(raw)})`);
|
|
878
|
+
for (const k of Object.keys(raw))
|
|
879
|
+
if (k !== "max_concurrent")
|
|
880
|
+
throw new Error(
|
|
881
|
+
`sessions.${k}: unknown key (sessions: takes max_concurrent)`,
|
|
882
|
+
);
|
|
883
|
+
const out: ResolvedSessionsConfig = {
|
|
884
|
+
maxConcurrent: DEFAULT_SESSIONS_MAX_CONCURRENT,
|
|
885
|
+
};
|
|
886
|
+
const max = raw.max_concurrent;
|
|
887
|
+
if (max !== undefined) {
|
|
888
|
+
if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
|
|
889
|
+
throw new Error(
|
|
890
|
+
`sessions.max_concurrent: must be a whole number of at least 1 (got ${JSON.stringify(max)})`,
|
|
891
|
+
);
|
|
892
|
+
out.maxConcurrent = max;
|
|
893
|
+
}
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
|
|
848
897
|
// ── Web UI service (`<cli> service`) ──────────────────────────────────────────
|
|
849
898
|
|
|
850
899
|
/** `web:` in the org config. */
|
|
@@ -82,6 +82,28 @@ export function scopeView(scope?: ViewScope): ScopeView {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* WS-6.4: what health covers, whatever is in view: every active workspace,
|
|
87
|
+
* focused first. Scope only filters what is displayed (inViewMarker); it
|
|
88
|
+
* never narrows what is polled, so a workspace outside it still shows its
|
|
89
|
+
* real health on the Workspaces page.
|
|
90
|
+
*/
|
|
91
|
+
export function pollView(): ScopeView {
|
|
92
|
+
return scopeView(ALL_ACTIVE);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a health row is in view: one of the workspaces that define it
|
|
97
|
+
* (`workspaces`, its owners) is in `view`. A row without owners counts as
|
|
98
|
+
* in view.
|
|
99
|
+
*/
|
|
100
|
+
export function inViewMarker(
|
|
101
|
+
view: ScopeView,
|
|
102
|
+
): (owners: readonly string[] | undefined) => boolean {
|
|
103
|
+
const names = new Set(view.workspaces.map((w) => w.name));
|
|
104
|
+
return (owners) => !owners?.length || owners.some((o) => names.has(o));
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
/** A cache key for what is in view (the scope's workspaces, in order). */
|
|
86
108
|
export function scopeKey(view: ScopeView): string {
|
|
87
109
|
return view.workspaces.map((w) => w.name).join(",");
|
|
@@ -400,6 +400,70 @@ export function findDaemonByAddress(address: string): DaemonRecord | undefined {
|
|
|
400
400
|
return _daemonRegistry.get(address);
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
/**
|
|
404
|
+
* The other spelling of a single-service repo's daemon: "<repo>" and
|
|
405
|
+
* "<repo>/<svc>" name the same process (0.4.1-dev.5, a consumer's dev.4
|
|
406
|
+
* finding: `up <repo>` recorded "<repo>", `restart <repo>/<svc>` found no
|
|
407
|
+
* record, adopted the running process instead of restarting it, and left
|
|
408
|
+
* two records on one process group). Null for a repo with several
|
|
409
|
+
* services: each of its services is its own daemon.
|
|
410
|
+
*/
|
|
411
|
+
function singleServiceAlias(
|
|
412
|
+
resolved: Pick<ResolvedService, "address" | "repoName" | "subsystem"> & {
|
|
413
|
+
repoPath: string;
|
|
414
|
+
},
|
|
415
|
+
): string | null {
|
|
416
|
+
const keys = Object.keys(readManifest(resolved.repoPath)?.services ?? {});
|
|
417
|
+
if (keys.length !== 1 || keys[0] !== resolved.subsystem) return null;
|
|
418
|
+
const full = `${resolved.repoName}/${resolved.subsystem}`;
|
|
419
|
+
if (resolved.address === resolved.repoName) return full;
|
|
420
|
+
if (resolved.address === full) return resolved.repoName;
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Bring a single-service repo's record to the address this command uses,
|
|
426
|
+
* so every lookup by `resolved.address` finds it whichever spelling wrote
|
|
427
|
+
* it. A record only under the other spelling moves; a duplicate (two
|
|
428
|
+
* records on one process group, the dev.4 bug's leftover, or one of them
|
|
429
|
+
* dead or without a group) is merged into one, owners and containers
|
|
430
|
+
* unioned. Two records on two different live groups are left alone.
|
|
431
|
+
*/
|
|
432
|
+
export function reconcileDaemonAddress(resolved: ResolvedService): void {
|
|
433
|
+
const alias = singleServiceAlias(resolved);
|
|
434
|
+
if (!alias) return;
|
|
435
|
+
loadDaemonsFromFile();
|
|
436
|
+
const other = _daemonRegistry.get(alias);
|
|
437
|
+
if (!other || other.repoPath !== resolved.repoPath) return;
|
|
438
|
+
const own = _daemonRegistry.get(resolved.address);
|
|
439
|
+
if (own) {
|
|
440
|
+
const sameGroup =
|
|
441
|
+
own.pgid === undefined ||
|
|
442
|
+
other.pgid === undefined ||
|
|
443
|
+
own.pgid === other.pgid ||
|
|
444
|
+
isDaemonVerifiablyDead(other);
|
|
445
|
+
if (!sameGroup) return;
|
|
446
|
+
}
|
|
447
|
+
// The record carrying a process group is the one `down` can stop.
|
|
448
|
+
const keep =
|
|
449
|
+
own && (own.pgid !== undefined || other.pgid === undefined) ? own : other;
|
|
450
|
+
const drop = keep === own ? other : own;
|
|
451
|
+
const merged: DaemonRecord = {
|
|
452
|
+
...keep,
|
|
453
|
+
address: resolved.address,
|
|
454
|
+
workspaces: [
|
|
455
|
+
...new Set([...(keep.workspaces ?? []), ...(drop?.workspaces ?? [])]),
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
const containers = [
|
|
459
|
+
...new Set([...(keep.containers ?? []), ...(drop?.containers ?? [])]),
|
|
460
|
+
];
|
|
461
|
+
if (containers.length > 0) merged.containers = containers;
|
|
462
|
+
_daemonRegistry.delete(alias);
|
|
463
|
+
_daemonRegistry.set(resolved.address, merged);
|
|
464
|
+
saveDaemonsFile(getDaemonsFile());
|
|
465
|
+
}
|
|
466
|
+
|
|
403
467
|
/** Add a workspace owner to a running daemon (share / attach). */
|
|
404
468
|
export function attachDaemonOwner(address: string, workspace: string): void {
|
|
405
469
|
loadDaemonsFromFile();
|
|
@@ -1344,6 +1408,12 @@ export interface RunCommandOptions {
|
|
|
1344
1408
|
explicit?: boolean;
|
|
1345
1409
|
/** For `down`: what it found to stop (see DownOutcome). */
|
|
1346
1410
|
onDownOutcome?: (outcome: DownOutcome) => void;
|
|
1411
|
+
/**
|
|
1412
|
+
* Internal: this `up` is a restart's up step ("up"), or the up step of
|
|
1413
|
+
* the restart a restart ran after adopting a process ("after-adopt").
|
|
1414
|
+
* A restart never ends on an adoption: what it adopts it restarts.
|
|
1415
|
+
*/
|
|
1416
|
+
restartStep?: "up" | "after-adopt";
|
|
1347
1417
|
}
|
|
1348
1418
|
|
|
1349
1419
|
/**
|
|
@@ -1506,6 +1576,7 @@ function settleDaemonRecord(
|
|
|
1506
1576
|
record: DaemonRecord | undefined;
|
|
1507
1577
|
outcome: "adopted" | "dropped" | "busy" | null;
|
|
1508
1578
|
} {
|
|
1579
|
+
reconcileDaemonAddress(resolved);
|
|
1509
1580
|
const record = findDaemonByAddress(resolved.address);
|
|
1510
1581
|
if (!record || !isLegacyRecord(record)) return { record, outcome: null };
|
|
1511
1582
|
const outcome = settleLegacyRecord(record, resolved, workspace, onLine);
|
|
@@ -1674,7 +1745,12 @@ function restartAsDownThenUp(
|
|
|
1674
1745
|
options.onClose(code);
|
|
1675
1746
|
return;
|
|
1676
1747
|
}
|
|
1677
|
-
const up = runServiceCommand({
|
|
1748
|
+
const up = runServiceCommand({
|
|
1749
|
+
...options,
|
|
1750
|
+
command: "up",
|
|
1751
|
+
restartStep:
|
|
1752
|
+
options.restartStep === "after-adopt" ? "after-adopt" : "up",
|
|
1753
|
+
});
|
|
1678
1754
|
tail = up.proc;
|
|
1679
1755
|
if (stopTail) {
|
|
1680
1756
|
try {
|
|
@@ -2052,6 +2128,9 @@ export function runServiceCommand(
|
|
|
2052
2128
|
}
|
|
2053
2129
|
|
|
2054
2130
|
const { repoPath, subsystem } = resolved;
|
|
2131
|
+
// A single-service repo's record answers to "<repo>" and "<repo>/<svc>":
|
|
2132
|
+
// every decision below reads it under this command's spelling.
|
|
2133
|
+
reconcileDaemonAddress(resolved);
|
|
2055
2134
|
|
|
2056
2135
|
const explicit = options.explicit ?? true;
|
|
2057
2136
|
const reportDown = (outcome: DownOutcome): void =>
|
|
@@ -2217,6 +2296,16 @@ export function runServiceCommand(
|
|
|
2217
2296
|
`${resolved.address} not started`,
|
|
2218
2297
|
);
|
|
2219
2298
|
if (holder === "adopted") {
|
|
2299
|
+
// A restart's up step (its down found nothing recorded): the
|
|
2300
|
+
// adopted process is the old instance, so it is restarted now,
|
|
2301
|
+
// never left running as if restarted (0.4.1-dev.5).
|
|
2302
|
+
if (options.restartStep === "up") {
|
|
2303
|
+
onLine(`restarting the adopted process for ${resolved.address}`);
|
|
2304
|
+
return restartAsDownThenUp(
|
|
2305
|
+
{ ...options, restartStep: "after-adopt" },
|
|
2306
|
+
getServiceLogPath(resolved.repoName, resolved.subsystem),
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2220
2309
|
closeAsync(0);
|
|
2221
2310
|
return dummy();
|
|
2222
2311
|
}
|
|
@@ -2355,7 +2444,10 @@ export function runServiceCommand(
|
|
|
2355
2444
|
resolved.repoName,
|
|
2356
2445
|
workspace,
|
|
2357
2446
|
);
|
|
2358
|
-
|
|
2447
|
+
// Own properties only (SEC-1 §6b.2): `constructor` is no command.
|
|
2448
|
+
let cmdDef = Object.prototype.hasOwnProperty.call(svcCommands, command)
|
|
2449
|
+
? svcCommands[command]
|
|
2450
|
+
: undefined;
|
|
2359
2451
|
const requestedCmdDef = cmdDef;
|
|
2360
2452
|
|
|
2361
2453
|
// `restart` fallback: synthesise `make down && make up` when no explicit
|
package/src/shared/profile.ts
CHANGED
|
@@ -70,8 +70,16 @@ const expandHome = (p: string): string =>
|
|
|
70
70
|
? path.join(os.homedir(), p.slice(2))
|
|
71
71
|
: p;
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* A profile from a `product:` block; unset fields derive from the name. A
|
|
75
|
+
* relative `state_dir` resolves against `baseDir`: the directory holding
|
|
76
|
+
* den.yml (the distribution root), never the cwd, so an agent's CLI or MCP
|
|
77
|
+
* server started elsewhere reads the same state.
|
|
78
|
+
*/
|
|
79
|
+
export function profileFrom(
|
|
80
|
+
raw: RawProduct = {},
|
|
81
|
+
baseDir: string = process.cwd(),
|
|
82
|
+
): ProductProfile {
|
|
75
83
|
const name = str(raw.name) ?? "den";
|
|
76
84
|
if (!SLUG.test(name))
|
|
77
85
|
throw new Error(
|
|
@@ -91,7 +99,10 @@ export function profileFrom(raw: RawProduct = {}): ProductProfile {
|
|
|
91
99
|
envPrefix,
|
|
92
100
|
configFile: str(raw.config) ?? `config/${name}.yml`,
|
|
93
101
|
manifestFiles: json === manifest ? [manifest] : [manifest, json],
|
|
94
|
-
stateDir: path.resolve(
|
|
102
|
+
stateDir: path.resolve(
|
|
103
|
+
baseDir,
|
|
104
|
+
expandHome(str(raw.state_dir) ?? `~/.${name}`),
|
|
105
|
+
),
|
|
95
106
|
servicesDir: str(raw.services_dir) ?? `.${name}-services`,
|
|
96
107
|
workspacesDir: str(raw.workspaces_dir) ?? `${name}-workspaces`,
|
|
97
108
|
};
|
|
@@ -106,7 +117,7 @@ export function readProfileFile(file: string): ProductProfile {
|
|
|
106
117
|
if ((e as NodeJS.ErrnoException).code === "ENOENT") return profileFrom();
|
|
107
118
|
throw new Error(`${file}: ${(e as Error).message}`);
|
|
108
119
|
}
|
|
109
|
-
return profileFrom(raw?.product ?? {});
|
|
120
|
+
return profileFrom(raw?.product ?? {}, path.dirname(path.resolve(file)));
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
let cached: { key: string; profile: ProductProfile } | null = null;
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
AttachHandlers,
|
|
17
17
|
Attachment,
|
|
18
18
|
SessionInfo,
|
|
19
|
+
SessionPool,
|
|
19
20
|
SessionSignal,
|
|
20
21
|
SessionSpec,
|
|
21
22
|
} from "./types.js";
|
|
@@ -125,18 +126,31 @@ export class HostClient extends EventEmitter {
|
|
|
125
126
|
spec: SessionSpec,
|
|
126
127
|
opts: {
|
|
127
128
|
limit?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Which running sessions `limit` counts: agents (agents.max_concurrent)
|
|
131
|
+
* or plain ones (sessions.max_concurrent). Without it, all of them.
|
|
132
|
+
*/
|
|
133
|
+
pool?: SessionPool;
|
|
128
134
|
/** `--keep-env` names, for the host's audit line (never values). */
|
|
129
135
|
keptEnv?: { name: string; credentialShaped: boolean }[];
|
|
130
136
|
/** Who asked, for that line. */
|
|
131
137
|
actor?: string;
|
|
138
|
+
/**
|
|
139
|
+
* `--keep-env` names Den refuses to forward (SEC-1 §5: a person's
|
|
140
|
+
* credential): the host records the refusal and starts nothing.
|
|
141
|
+
*/
|
|
142
|
+
refusedEnv?: string[];
|
|
132
143
|
} = {},
|
|
133
144
|
): Promise<SessionInfo> {
|
|
134
145
|
const r = await this.request("start", {
|
|
135
146
|
spec,
|
|
136
147
|
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
137
|
-
...(opts.
|
|
138
|
-
|
|
148
|
+
...(opts.pool !== undefined ? { pool: opts.pool } : {}),
|
|
149
|
+
...(opts.keptEnv?.length || opts.refusedEnv?.length
|
|
150
|
+
? { actor: opts.actor ?? "unknown" }
|
|
139
151
|
: {}),
|
|
152
|
+
...(opts.keptEnv?.length ? { keptEnv: opts.keptEnv } : {}),
|
|
153
|
+
...(opts.refusedEnv?.length ? { refusedEnv: opts.refusedEnv } : {}),
|
|
140
154
|
});
|
|
141
155
|
return r.info as SessionInfo;
|
|
142
156
|
}
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
Attachment,
|
|
22
22
|
SessionBackend,
|
|
23
23
|
SessionInfo,
|
|
24
|
+
SessionPool,
|
|
24
25
|
SessionRef,
|
|
25
26
|
SessionSignal,
|
|
26
27
|
SessionSpec,
|
|
@@ -32,8 +33,10 @@ export { BACKEND_ID as DEN_PTY_ID };
|
|
|
32
33
|
export interface DenPtyOptions {
|
|
33
34
|
/** A client of the running host (ensureHost). */
|
|
34
35
|
client: HostClient;
|
|
35
|
-
/** Applied to every start
|
|
36
|
+
/** Applied to every start, counting `pool`'s running sessions. */
|
|
36
37
|
limit?: number;
|
|
38
|
+
/** agents (agents.max_concurrent) or sessions (sessions.max_concurrent). */
|
|
39
|
+
pool?: SessionPool;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export class DenPtyBackend implements SessionBackend {
|
|
@@ -49,7 +52,12 @@ export class DenPtyBackend implements SessionBackend {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
async start(spec: SessionSpec): Promise<SessionRef> {
|
|
52
|
-
return (
|
|
55
|
+
return (
|
|
56
|
+
await this.opts.client.start(spec, {
|
|
57
|
+
limit: this.opts.limit,
|
|
58
|
+
...(this.opts.pool ? { pool: this.opts.pool } : {}),
|
|
59
|
+
})
|
|
60
|
+
).ref;
|
|
53
61
|
}
|
|
54
62
|
list(): Promise<SessionInfo[]> {
|
|
55
63
|
return this.opts.client.list();
|
|
@@ -10,14 +10,21 @@
|
|
|
10
10
|
* 4. `launch`: the runtime's launch env (LaunchOutput.env)
|
|
11
11
|
* 5. `extra`: the persona's or runtime's declared env (none can declare
|
|
12
12
|
* one yet)
|
|
13
|
-
* 6. `den`: Den's own markers (DEN_AGENT
|
|
13
|
+
* 6. `den`: Den's own markers (DEN_AGENT, and SEC-1c's DEN_ACTOR_TOKEN
|
|
14
|
+
* for an agent: the only way an actor token reaches a session; one
|
|
15
|
+
* from anywhere above is dropped first)
|
|
14
16
|
* 7. then `scrub` removes every name it matches, whatever put it back:
|
|
15
17
|
* each runtime's scrubEnv (den-claude: CLAUDECODE, CLAUDE_CODE_*).
|
|
18
|
+
* 8. and always (SEC-1 §5): a person's credential, DEN_PERSON_TOKEN or
|
|
19
|
+
* any value that is a person token or names web.secret
|
|
20
|
+
* (actor-token.ts isPersonCredentialEnv), whatever put it there.
|
|
16
21
|
*
|
|
17
22
|
* The session backend knows none of this: it is handed the result. Nothing
|
|
18
23
|
* here logs or returns a value other than the env itself.
|
|
19
24
|
*/
|
|
20
25
|
|
|
26
|
+
import { ACTOR_TOKEN_ENV, isPersonCredentialEnv } from "../actor-token.js";
|
|
27
|
+
|
|
21
28
|
export const DEFAULT_TERM = "xterm-256color";
|
|
22
29
|
|
|
23
30
|
/** Set in an agent's env: `agent:<id>`, so Den's CLI run inside knows. */
|
|
@@ -72,8 +79,11 @@ export function buildSessionEnv(i: SessionEnvInput): Record<string, string> {
|
|
|
72
79
|
for (const [k, v] of Object.entries(i.caller))
|
|
73
80
|
if (v !== undefined && (inSessionBase(k) || named.has(k))) env[k] = v;
|
|
74
81
|
if (!env.TERM) env.TERM = DEFAULT_TERM;
|
|
75
|
-
Object.assign(env, i.launch ?? {}, i.extra ?? {}
|
|
82
|
+
Object.assign(env, i.launch ?? {}, i.extra ?? {});
|
|
83
|
+
delete env[ACTOR_TOKEN_ENV];
|
|
84
|
+
Object.assign(env, i.den ?? {});
|
|
76
85
|
const scrub = i.scrub ?? [];
|
|
77
|
-
for (const k of Object.
|
|
86
|
+
for (const [k, v] of Object.entries(env))
|
|
87
|
+
if (matchesScrub(k, scrub) || isPersonCredentialEnv(k, v)) delete env[k];
|
|
78
88
|
return env;
|
|
79
89
|
}
|
|
@@ -29,7 +29,12 @@ import {
|
|
|
29
29
|
type Request,
|
|
30
30
|
} from "./protocol.js";
|
|
31
31
|
import type { PtyLoad, PtyProcess } from "./pty.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
SESSION_POOLS,
|
|
34
|
+
SESSION_SIGNALS,
|
|
35
|
+
type SessionInfo,
|
|
36
|
+
type SessionPool,
|
|
37
|
+
} from "./types.js";
|
|
33
38
|
|
|
34
39
|
export const BACKEND_ID = "den-pty";
|
|
35
40
|
|
|
@@ -58,6 +63,8 @@ interface Session {
|
|
|
58
63
|
log: ScrollbackLog;
|
|
59
64
|
/** aid → its connection. */
|
|
60
65
|
attachments: Map<string, Conn>;
|
|
66
|
+
/** The cap it counts against (start's `pool`), when one was named. */
|
|
67
|
+
pool?: SessionPool;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
class Conn {
|
|
@@ -82,6 +89,17 @@ class Refusal extends Error {
|
|
|
82
89
|
const isInt = (n: unknown, min: number, max: number): n is number =>
|
|
83
90
|
typeof n === "number" && Number.isInteger(n) && n >= min && n <= max;
|
|
84
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The cap refusal: "2 agents are running and agents.max_concurrent is 2".
|
|
94
|
+
* With no pool (an older client's limit) every session counts, and the key
|
|
95
|
+
* is agents.max_concurrent, as it was.
|
|
96
|
+
*/
|
|
97
|
+
function capMessage(live: number, limit: number, pool?: unknown): string {
|
|
98
|
+
const noun = pool === "agents" ? "agent" : "session";
|
|
99
|
+
const key = pool === "sessions" ? "sessions" : "agents";
|
|
100
|
+
return `${live} ${noun}${live === 1 ? " is" : "s are"} running and ${key}.max_concurrent is ${limit}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
function pidAlive(pid: number): boolean {
|
|
86
104
|
try {
|
|
87
105
|
process.kill(pid, 0);
|
|
@@ -126,6 +144,17 @@ function keptEnvAudit(v: unknown): string {
|
|
|
126
144
|
.join(", ");
|
|
127
145
|
}
|
|
128
146
|
|
|
147
|
+
/** A start's refused `--keep-env` names, printable: "A, B", or "". */
|
|
148
|
+
function refusedEnvAudit(v: unknown): string {
|
|
149
|
+
if (!Array.isArray(v)) return "";
|
|
150
|
+
return v
|
|
151
|
+
.filter(
|
|
152
|
+
(k): k is string =>
|
|
153
|
+
typeof k === "string" && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(k),
|
|
154
|
+
)
|
|
155
|
+
.join(", ");
|
|
156
|
+
}
|
|
157
|
+
|
|
129
158
|
function printableActor(v: unknown): string {
|
|
130
159
|
return (
|
|
131
160
|
String(v ?? "unknown")
|
|
@@ -477,6 +506,18 @@ export class SessionHost {
|
|
|
477
506
|
const { name, cwd, argv, env } = spec;
|
|
478
507
|
if (typeof name !== "string" || !name)
|
|
479
508
|
throw new Refusal("bad-request", "start needs a name");
|
|
509
|
+
// SEC-1 §5: a --keep-env the caller refused (a person's credential) is
|
|
510
|
+
// audited here, names only, and nothing starts.
|
|
511
|
+
const refusedEnv = refusedEnvAudit(req.refusedEnv);
|
|
512
|
+
if (refusedEnv || Array.isArray(req.refusedEnv)) {
|
|
513
|
+
this.log(
|
|
514
|
+
`session ${printableActor(name)} refused: --keep-env ${refusedEnv || "?"} (a person's credential is never forwarded) · by ${printableActor(req.actor)}`,
|
|
515
|
+
);
|
|
516
|
+
throw new Refusal(
|
|
517
|
+
"bad-request",
|
|
518
|
+
`--keep-env ${refusedEnv || "?"}: a person's credential is never forwarded to a session`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
480
521
|
if (
|
|
481
522
|
!Array.isArray(argv) ||
|
|
482
523
|
!argv.length ||
|
|
@@ -505,20 +546,26 @@ export class SessionHost {
|
|
|
505
546
|
);
|
|
506
547
|
if (same.some((s) => !s.info.exited))
|
|
507
548
|
throw new Refusal("exists", `${name} is already running`);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
).
|
|
549
|
+
// A cap counts its own pool's running sessions: agents.max_concurrent
|
|
550
|
+
// the agents, sessions.max_concurrent the plain ones. A limit with no
|
|
551
|
+
// pool (an older client) counts every running session, as before.
|
|
552
|
+
const pool = req.pool;
|
|
553
|
+
if (pool !== undefined && !SESSION_POOLS.includes(pool as SessionPool))
|
|
554
|
+
throw new Refusal(
|
|
555
|
+
"bad-request",
|
|
556
|
+
`pool must be ${SESSION_POOLS.join(" or ")}`,
|
|
557
|
+
);
|
|
511
558
|
if (req.limit !== undefined) {
|
|
512
559
|
if (!isInt(req.limit, 1, 100_000))
|
|
513
560
|
throw new Refusal(
|
|
514
561
|
"bad-request",
|
|
515
562
|
"limit must be a whole number of at least 1",
|
|
516
563
|
);
|
|
564
|
+
const live = [...this.sessions.values()].filter(
|
|
565
|
+
(s) => !s.info.exited && (pool === undefined || s.pool === pool),
|
|
566
|
+
).length;
|
|
517
567
|
if (live >= req.limit)
|
|
518
|
-
throw new Refusal(
|
|
519
|
-
"cap",
|
|
520
|
-
`${live} session${live === 1 ? " is" : "s are"} running and agents.max_concurrent is ${req.limit}`,
|
|
521
|
-
);
|
|
568
|
+
throw new Refusal("cap", capMessage(live, req.limit, pool));
|
|
522
569
|
}
|
|
523
570
|
const load = this.opts.pty();
|
|
524
571
|
if (!load.available) throw new Refusal("unavailable", load.reason);
|
|
@@ -559,6 +606,7 @@ export class SessionHost {
|
|
|
559
606
|
},
|
|
560
607
|
),
|
|
561
608
|
attachments: new Map(),
|
|
609
|
+
...(pool !== undefined ? { pool: pool as SessionPool } : {}),
|
|
562
610
|
};
|
|
563
611
|
this.sessions.set(id, s);
|
|
564
612
|
this.persist(s);
|
|
@@ -32,6 +32,14 @@ export interface SessionInfo {
|
|
|
32
32
|
prompted?: boolean;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The cap a start counts against: `agents` (agents.max_concurrent) or
|
|
37
|
+
* `sessions` (sessions.max_concurrent, the plain ones), each counting only
|
|
38
|
+
* its own running sessions.
|
|
39
|
+
*/
|
|
40
|
+
export const SESSION_POOLS = ["agents", "sessions"] as const;
|
|
41
|
+
export type SessionPool = (typeof SESSION_POOLS)[number];
|
|
42
|
+
|
|
35
43
|
/** What to run. `env` is the whole environment: nothing is inherited. */
|
|
36
44
|
export interface SessionSpec {
|
|
37
45
|
name: string;
|