@monster-orange/den 0.4.0 → 0.4.1
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 +409 -0
- package/docs/distributions.md +204 -14
- 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/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 +9 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +23 -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 +96 -614
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +94 -10
- package/src/ui/cli/sessions-command.ts +32 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +814 -66
- package/src/ui/server/public/agents-page.js +648 -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
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* workspace; --strict-mcp-config keeps any other MCP server out, so the
|
|
10
10
|
* agent acts only through Den (§1).
|
|
11
11
|
* - The role prompt is appended to Claude Code's own system prompt.
|
|
12
|
+
* - Den's state dir is denied to the agent (SEC-1 §6c.1): a settings file
|
|
13
|
+
* (--settings, 0600) whose `permissions.deny` lists Read and Bash rules
|
|
14
|
+
* (any command naming the path) for `<state>/web.secret` and
|
|
15
|
+
* `<state>/sessions/**`. It binds a
|
|
16
|
+
* cooperating agent only: the agent runs as the same OS user, so nothing
|
|
17
|
+
* stops one that ignores its permission config from reading them.
|
|
12
18
|
* - Each launch picks a fresh conversation id (--session-id); resume
|
|
13
19
|
* continues it with --resume <id>, in the same cwd, with the same MCP.
|
|
14
20
|
* - Permission prompts stay in the terminal (approvals: "terminal"):
|
|
@@ -98,6 +104,81 @@ function detect(env: NodeJS.ProcessEnv = process.env): RuntimeDetect {
|
|
|
98
104
|
return version ? { available: true, version } : { available: true };
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Claude Code `permissions.deny` rules that keep a cooperating agent out of
|
|
109
|
+
* Den's credentials (SEC-1 §6c.1): web.secret and the session host's
|
|
110
|
+
* records.
|
|
111
|
+
*
|
|
112
|
+
* - Read: absolute paths (`//…` in Claude Code's rule syntax).
|
|
113
|
+
* - Bash, in Claude Code's documented wildcard form: `*` matches any run of
|
|
114
|
+
* characters, at any position, and a rule matches a command (each part
|
|
115
|
+
* of a compound one) whole. `Bash(*<path>*)` denies any command that names
|
|
116
|
+
* the path, whatever the program (cat, head, cp, base64) and whether it is
|
|
117
|
+
* quoted ("…" or '…' still contain it). The legacy prefix form,
|
|
118
|
+
* `Bash(cat <path>:*)`, didn't match a bare `cat <path>` on Claude Code
|
|
119
|
+
* 2.1.x (it asked for approval instead of denying: a dogfood finding), so
|
|
120
|
+
* it is kept only for older versions, after the wildcard rules.
|
|
121
|
+
* - The readers in BASH_READERS (cat, head, tail, …) by name as well. This
|
|
122
|
+
* binds a cooperating agent only, and no list names every reader: the
|
|
123
|
+
* boundary is the server's token rules (SEC-1 §5), never these.
|
|
124
|
+
* - The same path as a shell writes it from `home`: `~/…` and `$HOME/…`,
|
|
125
|
+
* `${HOME}/…`, and, when the path has a space, with each space
|
|
126
|
+
* backslash-escaped.
|
|
127
|
+
*/
|
|
128
|
+
export function stateDirDenyRules(stateDir: string, home?: string): string[] {
|
|
129
|
+
const abs = path.resolve(stateDir);
|
|
130
|
+
const secret = path.join(abs, "web.secret");
|
|
131
|
+
const sessions = path.join(abs, "sessions");
|
|
132
|
+
const spellings = (p: string): string[] => {
|
|
133
|
+
const out = [p];
|
|
134
|
+
if (home) {
|
|
135
|
+
const h = path.resolve(home);
|
|
136
|
+
if (p.startsWith(h + path.sep)) {
|
|
137
|
+
const rel = p.slice(h.length + 1);
|
|
138
|
+
out.push(`~/${rel}`, `$HOME/${rel}`, "${HOME}/" + rel);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const x of [...out])
|
|
142
|
+
if (x.includes(" ")) out.push(x.replace(/ /g, "\\ "));
|
|
143
|
+
return [...new Set(out)];
|
|
144
|
+
};
|
|
145
|
+
const targets = [
|
|
146
|
+
...spellings(secret).map((p) => `${p}*`),
|
|
147
|
+
...spellings(sessions).map((p) => `${p}/*`),
|
|
148
|
+
];
|
|
149
|
+
return [
|
|
150
|
+
`Read(/${secret})`,
|
|
151
|
+
`Read(/${sessions}/**)`,
|
|
152
|
+
// Any command naming the path, whatever the program.
|
|
153
|
+
...targets.map((t) => `Bash(*${t})`),
|
|
154
|
+
// The common readers by name too, should a version match a leading
|
|
155
|
+
// wildcard differently. A deny list can't name every reader: the
|
|
156
|
+
// boundary is the server's token rules (SEC-1 §5), not these.
|
|
157
|
+
...BASH_READERS.flatMap((r) => targets.map((t) => `Bash(${r} *${t})`)),
|
|
158
|
+
`Bash(cat ${secret}:*)`,
|
|
159
|
+
`Bash(cat ${sessions}/:*)`,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The readers named in the Bash deny rules (stateDirDenyRules). */
|
|
164
|
+
export const BASH_READERS = [
|
|
165
|
+
"cat",
|
|
166
|
+
"head",
|
|
167
|
+
"tail",
|
|
168
|
+
"less",
|
|
169
|
+
"more",
|
|
170
|
+
"xxd",
|
|
171
|
+
"od",
|
|
172
|
+
"strings",
|
|
173
|
+
"base64",
|
|
174
|
+
"cp",
|
|
175
|
+
] as const;
|
|
176
|
+
|
|
177
|
+
/** The settings file den-claude passes with --settings. */
|
|
178
|
+
export function settingsPath(input: Pick<LaunchInput, "filesDir">): string {
|
|
179
|
+
return path.join(input.filesDir, "settings.json");
|
|
180
|
+
}
|
|
181
|
+
|
|
101
182
|
/** The flags every run shares: Den's MCP only, the model, the role prompt. */
|
|
102
183
|
function common(input: LaunchInput): {
|
|
103
184
|
argv: string[];
|
|
@@ -106,11 +187,13 @@ function common(input: LaunchInput): {
|
|
|
106
187
|
} {
|
|
107
188
|
const file = mcpConfigPath(input);
|
|
108
189
|
const mode = permissionMode(input);
|
|
190
|
+
const settings = input.stateDir ? settingsPath(input) : null;
|
|
109
191
|
return {
|
|
110
192
|
argv: [
|
|
111
193
|
"--mcp-config",
|
|
112
194
|
file,
|
|
113
195
|
"--strict-mcp-config",
|
|
196
|
+
...(settings ? ["--settings", settings] : []),
|
|
114
197
|
"--permission-mode",
|
|
115
198
|
mode,
|
|
116
199
|
...(input.model ? ["--model", input.model] : []),
|
|
@@ -122,7 +205,22 @@ function common(input: LaunchInput): {
|
|
|
122
205
|
input.mcp.name,
|
|
123
206
|
),
|
|
124
207
|
],
|
|
125
|
-
files: {
|
|
208
|
+
files: {
|
|
209
|
+
[file]: mcpConfigJson(input.mcp),
|
|
210
|
+
...(settings && input.stateDir
|
|
211
|
+
? {
|
|
212
|
+
[settings]: `${JSON.stringify(
|
|
213
|
+
{
|
|
214
|
+
permissions: {
|
|
215
|
+
deny: stateDirDenyRules(input.stateDir, input.env?.HOME),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
null,
|
|
219
|
+
2,
|
|
220
|
+
)}\n`,
|
|
221
|
+
}
|
|
222
|
+
: {}),
|
|
223
|
+
},
|
|
126
224
|
warnings:
|
|
127
225
|
mode === "bypassPermissions"
|
|
128
226
|
? [
|
|
@@ -51,6 +51,13 @@ export interface LaunchInput {
|
|
|
51
51
|
mcp: AgentMcp;
|
|
52
52
|
/** A directory of this agent's own (0700) for the files a runtime writes. */
|
|
53
53
|
filesDir: string;
|
|
54
|
+
/**
|
|
55
|
+
* Den's own state dir (web.secret, the session records). A runtime adds
|
|
56
|
+
* deny rules for it to the agent's own permission config (SEC-1 §6c.1):
|
|
57
|
+
* that binds a cooperating agent only, since an agent runs as the same OS
|
|
58
|
+
* user and could read the files itself.
|
|
59
|
+
*/
|
|
60
|
+
stateDir?: string;
|
|
54
61
|
/**
|
|
55
62
|
* The caller's environment, for finding the runtime's CLI on PATH. The
|
|
56
63
|
* agent does not get it: it starts from an allowlist (sessions/env.ts),
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { denClaude } from "./den-claude.js";
|
|
6
6
|
import { generic } from "./generic.js";
|
|
7
7
|
import { allScrubEnv, registerRuntime, type Runtime } from "./runtime.js";
|
|
8
|
+
import { ACTOR_TOKEN_ENV } from "../actor-token.js";
|
|
8
9
|
import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
|
|
9
10
|
|
|
10
11
|
registerRuntime(denClaude);
|
|
@@ -27,13 +28,16 @@ export {
|
|
|
27
28
|
/**
|
|
28
29
|
* An agent's session env, as `agents start` builds it and `doctor --probe`
|
|
29
30
|
* checks it: the allowlist, the runtime's passthrough and launch env, Den's
|
|
30
|
-
*
|
|
31
|
+
* markers (DEN_AGENT=agent:<id>, and SEC-1c's DEN_ACTOR_TOKEN when one was
|
|
32
|
+
* minted), then every runtime's scrubEnv and any person credential.
|
|
31
33
|
*/
|
|
32
34
|
export function agentSessionEnv(
|
|
33
35
|
caller: NodeJS.ProcessEnv,
|
|
34
36
|
rt: Pick<Runtime, "envPassthrough">,
|
|
35
37
|
launch: Record<string, string>,
|
|
36
38
|
agentId: string,
|
|
39
|
+
/** SEC-1c: the agent's actor token (`agents start` mints it; the probe has none). */
|
|
40
|
+
actorToken?: string,
|
|
37
41
|
): Record<string, string> {
|
|
38
42
|
return buildSessionEnv({
|
|
39
43
|
caller,
|
|
@@ -41,7 +45,10 @@ export function agentSessionEnv(
|
|
|
41
45
|
launch,
|
|
42
46
|
// The seam for a persona's or runtime's declared env: none yet.
|
|
43
47
|
extra: {},
|
|
44
|
-
den: {
|
|
48
|
+
den: {
|
|
49
|
+
[AGENT_ACTOR_ENV]: `agent:${agentId}`,
|
|
50
|
+
...(actorToken ? { [ACTOR_TOKEN_ENV]: actorToken } : {}),
|
|
51
|
+
},
|
|
45
52
|
scrub: allScrubEnv(),
|
|
46
53
|
});
|
|
47
54
|
}
|
|
@@ -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;
|
|
@@ -129,14 +129,21 @@ export class HostClient extends EventEmitter {
|
|
|
129
129
|
keptEnv?: { name: string; credentialShaped: boolean }[];
|
|
130
130
|
/** Who asked, for that line. */
|
|
131
131
|
actor?: string;
|
|
132
|
+
/**
|
|
133
|
+
* `--keep-env` names Den refuses to forward (SEC-1 §5: a person's
|
|
134
|
+
* credential): the host records the refusal and starts nothing.
|
|
135
|
+
*/
|
|
136
|
+
refusedEnv?: string[];
|
|
132
137
|
} = {},
|
|
133
138
|
): Promise<SessionInfo> {
|
|
134
139
|
const r = await this.request("start", {
|
|
135
140
|
spec,
|
|
136
141
|
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
137
|
-
...(opts.keptEnv?.length
|
|
138
|
-
? {
|
|
142
|
+
...(opts.keptEnv?.length || opts.refusedEnv?.length
|
|
143
|
+
? { actor: opts.actor ?? "unknown" }
|
|
139
144
|
: {}),
|
|
145
|
+
...(opts.keptEnv?.length ? { keptEnv: opts.keptEnv } : {}),
|
|
146
|
+
...(opts.refusedEnv?.length ? { refusedEnv: opts.refusedEnv } : {}),
|
|
140
147
|
});
|
|
141
148
|
return r.info as SessionInfo;
|
|
142
149
|
}
|
|
@@ -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
|
}
|
|
@@ -126,6 +126,17 @@ function keptEnvAudit(v: unknown): string {
|
|
|
126
126
|
.join(", ");
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** A start's refused `--keep-env` names, printable: "A, B", or "". */
|
|
130
|
+
function refusedEnvAudit(v: unknown): string {
|
|
131
|
+
if (!Array.isArray(v)) return "";
|
|
132
|
+
return v
|
|
133
|
+
.filter(
|
|
134
|
+
(k): k is string =>
|
|
135
|
+
typeof k === "string" && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(k),
|
|
136
|
+
)
|
|
137
|
+
.join(", ");
|
|
138
|
+
}
|
|
139
|
+
|
|
129
140
|
function printableActor(v: unknown): string {
|
|
130
141
|
return (
|
|
131
142
|
String(v ?? "unknown")
|
|
@@ -477,6 +488,18 @@ export class SessionHost {
|
|
|
477
488
|
const { name, cwd, argv, env } = spec;
|
|
478
489
|
if (typeof name !== "string" || !name)
|
|
479
490
|
throw new Refusal("bad-request", "start needs a name");
|
|
491
|
+
// SEC-1 §5: a --keep-env the caller refused (a person's credential) is
|
|
492
|
+
// audited here, names only, and nothing starts.
|
|
493
|
+
const refusedEnv = refusedEnvAudit(req.refusedEnv);
|
|
494
|
+
if (refusedEnv || Array.isArray(req.refusedEnv)) {
|
|
495
|
+
this.log(
|
|
496
|
+
`session ${printableActor(name)} refused: --keep-env ${refusedEnv || "?"} (a person's credential is never forwarded) · by ${printableActor(req.actor)}`,
|
|
497
|
+
);
|
|
498
|
+
throw new Refusal(
|
|
499
|
+
"bad-request",
|
|
500
|
+
`--keep-env ${refusedEnv || "?"}: a person's credential is never forwarded to a session`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
480
503
|
if (
|
|
481
504
|
!Array.isArray(argv) ||
|
|
482
505
|
!argv.length ||
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-1a: the web server's secret and the person token derived from it.
|
|
3
|
+
*
|
|
4
|
+
* `web.secret` is 32 random bytes in the distribution's state dir, mode
|
|
5
|
+
* 0600 (the dir 0700 when Den creates it). Reading it is the proof that a
|
|
6
|
+
* caller is the OS user, the same proof as the session host's socket. It is
|
|
7
|
+
* never printed, never logged and never served.
|
|
8
|
+
*
|
|
9
|
+
* The person token is HMAC-SHA256(secret, "den-person-v1:" + os user),
|
|
10
|
+
* sent as `Authorization: Bearer den_pt_<b64url>`. The server recomputes it
|
|
11
|
+
* and compares in constant time; its actor is `person:<os user>`.
|
|
12
|
+
*
|
|
13
|
+
* SEC-1b's `web logout --all` rotates the secret (rotateWebSecret).
|
|
14
|
+
* SEC-1c's agent tokens (`den_at_…`) are actor-token.ts's: random, not
|
|
15
|
+
* derived from the secret.
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as os from "os";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { ACTOR_TOKEN_ENV, isActorTokenShape } from "./actor-token.js";
|
|
22
|
+
import { getStateDir } from "./paths.js";
|
|
23
|
+
import { AGENT_ACTOR_ENV } from "./sessions/env.js";
|
|
24
|
+
|
|
25
|
+
export const WEB_SECRET_FILE = "web.secret";
|
|
26
|
+
const SECRET_BYTES = 32;
|
|
27
|
+
export const PERSON_TOKEN_PREFIX = "den_pt_";
|
|
28
|
+
const PERSON_CONTEXT = "den-person-v1:";
|
|
29
|
+
|
|
30
|
+
/** `<state dir>/web.secret`. */
|
|
31
|
+
export function webSecretPath(stateDir: string = getStateDir()): string {
|
|
32
|
+
return path.join(stateDir, WEB_SECRET_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The OS user the process runs as (the person-token actor's name). */
|
|
36
|
+
export function osUser(): string {
|
|
37
|
+
try {
|
|
38
|
+
return os.userInfo().username;
|
|
39
|
+
} catch {
|
|
40
|
+
return process.env.USER ?? process.env.LOGNAME ?? "unknown";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The secret, or null when it is missing, unreadable or malformed. */
|
|
45
|
+
export function readWebSecret(stateDir: string = getStateDir()): Buffer | null {
|
|
46
|
+
try {
|
|
47
|
+
const b = fs.readFileSync(webSecretPath(stateDir));
|
|
48
|
+
return b.length === SECRET_BYTES ? b : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The secret, created when missing: the state dir 0700 when it is made
|
|
56
|
+
* here, the file 0600, written exclusively so two starts never race to two
|
|
57
|
+
* secrets. An existing file looser than 0600 is tightened.
|
|
58
|
+
*/
|
|
59
|
+
export function ensureWebSecret(stateDir: string = getStateDir()): Buffer {
|
|
60
|
+
const file = webSecretPath(stateDir);
|
|
61
|
+
const existing = readWebSecret(stateDir);
|
|
62
|
+
if (existing) {
|
|
63
|
+
tighten(file);
|
|
64
|
+
return existing;
|
|
65
|
+
}
|
|
66
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
67
|
+
const fresh = randomBytes(SECRET_BYTES);
|
|
68
|
+
try {
|
|
69
|
+
// A malformed file (wrong length) is replaced; a missing one created.
|
|
70
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
71
|
+
fs.writeFileSync(file, fresh, { mode: 0o600, flag: "wx" });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Another process created it first: use theirs.
|
|
74
|
+
const won = readWebSecret(stateDir);
|
|
75
|
+
if (won) return won;
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
tighten(file);
|
|
79
|
+
return fresh;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* SEC-1b (`web logout --all`): a fresh secret in place of the old one,
|
|
84
|
+
* written to a temp file (0600) and renamed over it, so a reader sees one
|
|
85
|
+
* or the other, never half. Every person token and every browser session
|
|
86
|
+
* derived from the old one stops verifying; the CLI re-reads the file on
|
|
87
|
+
* each call, so it keeps working.
|
|
88
|
+
*/
|
|
89
|
+
export function rotateWebSecret(stateDir: string = getStateDir()): void {
|
|
90
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
91
|
+
const file = webSecretPath(stateDir);
|
|
92
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
93
|
+
fs.writeFileSync(tmp, randomBytes(SECRET_BYTES), { mode: 0o600, flag: "wx" });
|
|
94
|
+
fs.renameSync(tmp, file);
|
|
95
|
+
tighten(file);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tighten(file: string): void {
|
|
99
|
+
try {
|
|
100
|
+
if ((fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, 0o600);
|
|
101
|
+
} catch {
|
|
102
|
+
/* gone or not ours: the read already decided */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `den_pt_<b64url(HMAC-SHA256(secret, "den-person-v1:" + user))>`. */
|
|
107
|
+
export function personToken(secret: Buffer, user: string = osUser()): string {
|
|
108
|
+
const mac = createHmac("sha256", secret)
|
|
109
|
+
.update(PERSON_CONTEXT + user)
|
|
110
|
+
.digest("base64url");
|
|
111
|
+
return PERSON_TOKEN_PREFIX + mac;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The actor a presented person token proves (`person:<user>`), or null.
|
|
116
|
+
* Constant time: both sides are hashed to one length before comparing.
|
|
117
|
+
*/
|
|
118
|
+
export function verifyPersonToken(
|
|
119
|
+
presented: string,
|
|
120
|
+
secret: Buffer,
|
|
121
|
+
user: string = osUser(),
|
|
122
|
+
): `person:${string}` | null {
|
|
123
|
+
const expected = personToken(secret, user);
|
|
124
|
+
const a = createHmac("sha256", "den-cmp").update(presented).digest();
|
|
125
|
+
const b = createHmac("sha256", "den-cmp").update(expected).digest();
|
|
126
|
+
return timingSafeEqual(a, b) ? `person:${user}` : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The headers every CLI and stdio-MCP call to the web server carries:
|
|
131
|
+
* - inside an agent's session (SEC-1c): its DEN_ACTOR_TOKEN, never the
|
|
132
|
+
* person's; an agent (DEN_AGENT set) without one sends no credential
|
|
133
|
+
* rather than fall back to the person token;
|
|
134
|
+
* - otherwise the person token, or none when there is no secret yet (a
|
|
135
|
+
* server that predates SEC-1, or none started).
|
|
136
|
+
*/
|
|
137
|
+
export function cliAuthHeaders(
|
|
138
|
+
stateDir: string = getStateDir(),
|
|
139
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
140
|
+
): Record<string, string> {
|
|
141
|
+
const actorToken = env[ACTOR_TOKEN_ENV];
|
|
142
|
+
if (isActorTokenShape(actorToken))
|
|
143
|
+
return { Authorization: `Bearer ${actorToken}` };
|
|
144
|
+
if (env[AGENT_ACTOR_ENV] !== undefined || actorToken !== undefined) return {};
|
|
145
|
+
const secret = readWebSecret(stateDir);
|
|
146
|
+
return secret ? { Authorization: `Bearer ${personToken(secret)}` } : {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The server's view of the secret: read once, re-read when the file changes
|
|
151
|
+
* (SEC-1b's rotation), created at start.
|
|
152
|
+
*/
|
|
153
|
+
export class WebSecretStore {
|
|
154
|
+
private cached: { key: string; secret: Buffer } | null = null;
|
|
155
|
+
constructor(private readonly stateDir: () => string = getStateDir) {}
|
|
156
|
+
|
|
157
|
+
/** Create the secret if needed (at server start). */
|
|
158
|
+
ensure(): void {
|
|
159
|
+
const secret = ensureWebSecret(this.stateDir());
|
|
160
|
+
this.cached = { key: this.key() ?? "", secret };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The current secret, or null when it is missing. */
|
|
164
|
+
current(): Buffer | null {
|
|
165
|
+
const key = this.key();
|
|
166
|
+
if (key === null) return null;
|
|
167
|
+
if (this.cached?.key === key) return this.cached.secret;
|
|
168
|
+
const secret = readWebSecret(this.stateDir());
|
|
169
|
+
this.cached = secret ? { key, secret } : null;
|
|
170
|
+
return secret;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private key(): string | null {
|
|
174
|
+
try {
|
|
175
|
+
const s = fs.statSync(webSecretPath(this.stateDir()));
|
|
176
|
+
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|