@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
package/src/ui/mcp/server.ts
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
type ResolvedMcpConfig,
|
|
17
17
|
} from "../../shared/den-config.js";
|
|
18
18
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
19
|
-
import { engineVersion } from "../../shared/paths.js";
|
|
19
|
+
import { engineVersion, getStateDir } from "../../shared/paths.js";
|
|
20
|
+
import { ACTOR_TOKEN_ENV, lookupActorToken } from "../../shared/actor-token.js";
|
|
20
21
|
import { product } from "../../shared/product.js";
|
|
21
22
|
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
22
23
|
import { createDenApi, type DenApi } from "./api.js";
|
|
@@ -259,6 +260,63 @@ export async function runOneShot(
|
|
|
259
260
|
return r.isError ? 1 : 0;
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
/**
|
|
264
|
+
* SEC-1c: the MCP server Den starts for an agent carries the agent's actor
|
|
265
|
+
* token (DEN_ACTOR_TOKEN, inherited from the agent's session) as the
|
|
266
|
+
* credential of every call (cliAuthHeaders), and the web server records
|
|
267
|
+
* that token's agent as the actor. Why it must not start, or null: a token
|
|
268
|
+
* minted for another agent than `--actor`, or one this distribution never
|
|
269
|
+
* minted. With no token it runs as before (a person's own scoped server:
|
|
270
|
+
* the person token, its --actor an unverified claim).
|
|
271
|
+
*/
|
|
272
|
+
export function actorTokenRefusal(
|
|
273
|
+
scope: McpScope | null,
|
|
274
|
+
env: NodeJS.ProcessEnv,
|
|
275
|
+
stateDir: string,
|
|
276
|
+
): string | null {
|
|
277
|
+
const token = env[ACTOR_TOKEN_ENV];
|
|
278
|
+
if (token === undefined) return null;
|
|
279
|
+
const rec = lookupActorToken(stateDir, token);
|
|
280
|
+
if (!rec)
|
|
281
|
+
return `${ACTOR_TOKEN_ENV} is not a live actor token of this distribution (stopped, resumed, or never minted here)`;
|
|
282
|
+
if (rec.revokedAt)
|
|
283
|
+
return `${ACTOR_TOKEN_ENV} was revoked (\`web logout --all\`): restart or resume the agent to get a new one`;
|
|
284
|
+
if (scope && scope.actor !== rec.actor)
|
|
285
|
+
return `--actor ${scope.actor} does not match ${ACTOR_TOKEN_ENV}, which is ${rec.actor}'s`;
|
|
286
|
+
if (scope && scope.workspace !== rec.workspace)
|
|
287
|
+
return `--workspace ${scope.workspace} does not match ${ACTOR_TOKEN_ENV}, which is workspace ${rec.workspace}'s`;
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The line the stdio server prints at start (stderr). Its actor reads
|
|
293
|
+
* `as agent:<id>` only when DEN_ACTOR_TOKEN verified it (actorTokenRefusal
|
|
294
|
+
* passed with a token); without one the actor is a claim, and says so.
|
|
295
|
+
*/
|
|
296
|
+
export function mcpStartupLine(o: {
|
|
297
|
+
cli: string;
|
|
298
|
+
tools: number;
|
|
299
|
+
actions: readonly string[];
|
|
300
|
+
baseUrl: string;
|
|
301
|
+
scope: McpScope | null;
|
|
302
|
+
/** A DEN_ACTOR_TOKEN that matched the scope's actor and workspace. */
|
|
303
|
+
verified: boolean;
|
|
304
|
+
}): string {
|
|
305
|
+
const { scope } = o;
|
|
306
|
+
const actor = scope
|
|
307
|
+
? o.verified
|
|
308
|
+
? scope.actor
|
|
309
|
+
: `${scope.actor} (unverified: no ${ACTOR_TOKEN_ENV})`
|
|
310
|
+
: "";
|
|
311
|
+
return (
|
|
312
|
+
`${o.cli} mcp: ${plural(o.tools, "tool")} ` +
|
|
313
|
+
`(${o.actions.length ? `actions: ${o.actions.join(", ")}` : "read only"}) → ${o.baseUrl}` +
|
|
314
|
+
(scope ? ` · scoped to ${scope.workspace} as ${actor}` : "") +
|
|
315
|
+
(scope?.grants?.length ? ` · grants: ${scope.grants.join(", ")}` : "") +
|
|
316
|
+
"\n"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
262
320
|
/** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
|
|
263
321
|
export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
264
322
|
const cli = product().cli;
|
|
@@ -279,6 +337,12 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
|
279
337
|
process.exitCode = 2;
|
|
280
338
|
return;
|
|
281
339
|
}
|
|
340
|
+
const refusedToken = actorTokenRefusal(scope, process.env, getStateDir());
|
|
341
|
+
if (refusedToken) {
|
|
342
|
+
process.stderr.write(`${cli} mcp: ${refusedToken}\n`);
|
|
343
|
+
process.exitCode = 2;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
282
346
|
if (scope?.grants?.length) {
|
|
283
347
|
// A grant is config: a flag alone never widens a scope.
|
|
284
348
|
let refused: string | null;
|
|
@@ -336,10 +400,14 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
|
336
400
|
});
|
|
337
401
|
await server.connect(new StdioServerTransport());
|
|
338
402
|
process.stderr.write(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
403
|
+
mcpStartupLine({
|
|
404
|
+
cli,
|
|
405
|
+
tools: toolsFor(cfg).length,
|
|
406
|
+
actions: cfg.actions,
|
|
407
|
+
baseUrl,
|
|
408
|
+
scope,
|
|
409
|
+
// actorTokenRefusal passed: a token, if any, is this scope's.
|
|
410
|
+
verified: process.env[ACTOR_TOKEN_ENV] !== undefined,
|
|
411
|
+
}),
|
|
344
412
|
);
|
|
345
413
|
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Workspaces page's Agents section (AG-4a, AG spec §5c), its routes:
|
|
3
|
+
*
|
|
4
|
+
* GET /api/personas personas and runtimes
|
|
5
|
+
* GET /api/workspaces/<ws>/agents the workspace's agents
|
|
6
|
+
* POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
|
|
7
|
+
* POST /api/workspaces/<ws>/agents/<id>/stop
|
|
8
|
+
* POST /api/workspaces/<ws>/agents/<id>/resume
|
|
9
|
+
*
|
|
10
|
+
* Each calls the CLI's own operation (cli/agent-ops.ts): neither the page
|
|
11
|
+
* nor the CLI keeps state of its own, so an agent started here is the one
|
|
12
|
+
* `<cli> agents list -w <ws>` shows, and the other way round. A workspace
|
|
13
|
+
* lists its own agents only: another workspace's id is no agent here.
|
|
14
|
+
*
|
|
15
|
+
* Guards on Start, Stop and Resume, as on a service action:
|
|
16
|
+
* - the lock (R8): locked, the action is refused (423 locked); the page
|
|
17
|
+
* opens the lock popover first, as Local Dev's service actions do;
|
|
18
|
+
* - a person only: an agent (its actor token, SEC-1c) can't start or
|
|
19
|
+
* stop agents from the page (403; agents use MCP agent_spawn, §5,
|
|
20
|
+
* under its own guards);
|
|
21
|
+
* - agents.max_concurrent (the host's cap: 429 cap).
|
|
22
|
+
* The env is the CLI's (§5a-env): the allowlist and the runtime's, built
|
|
23
|
+
* from this server's environment, never all of it. Every action, refused
|
|
24
|
+
* or done, is audited (recordAction) with the person as its actor.
|
|
25
|
+
*
|
|
26
|
+
* There is no terminal here: the Agents tab's io (session-routes.ts) is
|
|
27
|
+
* the only route that writes to a session or streams its output.
|
|
28
|
+
*/
|
|
29
|
+
import * as http from "http";
|
|
30
|
+
import { getReposRoot } from "../../shared/den-config.js";
|
|
31
|
+
import {
|
|
32
|
+
readWorkspacesFile,
|
|
33
|
+
resolveWorkspaceDir,
|
|
34
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
35
|
+
import { getStateDir } from "../../shared/paths.js";
|
|
36
|
+
import { HostError } from "../../shared/sessions/client.js";
|
|
37
|
+
import {
|
|
38
|
+
AgentError,
|
|
39
|
+
agentRecord,
|
|
40
|
+
listAgents,
|
|
41
|
+
listPersonas,
|
|
42
|
+
listRuntimes,
|
|
43
|
+
NOT_IN_WORKSPACE,
|
|
44
|
+
resolveAgentsDeps,
|
|
45
|
+
resumeAgent,
|
|
46
|
+
startAgent,
|
|
47
|
+
stopAgent,
|
|
48
|
+
type AgentsDeps,
|
|
49
|
+
type AgentsResolved,
|
|
50
|
+
type Started,
|
|
51
|
+
} from "../cli/agent-ops.js";
|
|
52
|
+
import { recordAction } from "./audit.js";
|
|
53
|
+
import { auditActorOf, principalOf } from "./request-guard.js";
|
|
54
|
+
import { readBody, sendJson } from "./http-helpers.js";
|
|
55
|
+
import { hasPasswordlessSudo, hasSudoPassword } from "./sudo-store.js";
|
|
56
|
+
import type { Actor } from "../../shared/agent-actor.js";
|
|
57
|
+
|
|
58
|
+
export interface AgentRouteDeps {
|
|
59
|
+
/** The operations' deps (tests: an in-process host, a temp state dir). */
|
|
60
|
+
agents?: AgentsDeps;
|
|
61
|
+
/** R8: whether the lock is open (the lock chip's own status). */
|
|
62
|
+
unlocked?: () => boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const AGENTS_PATH =
|
|
66
|
+
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume))?$/;
|
|
67
|
+
/** A start's body is a few short names. */
|
|
68
|
+
const MAX_BODY = 4096;
|
|
69
|
+
|
|
70
|
+
/** Never a TTY: a web start's terminal is 80×24. */
|
|
71
|
+
const NO_TTY = { isTTY: false } as NodeJS.WriteStream;
|
|
72
|
+
|
|
73
|
+
function webDeps(over: AgentsDeps = {}): AgentsResolved {
|
|
74
|
+
return resolveAgentsDeps({
|
|
75
|
+
stateDir: () => getStateDir(),
|
|
76
|
+
workspaceExists: (ws) => Boolean(readWorkspacesFile().workspaces[ws]),
|
|
77
|
+
workspaceDir: (ws) => {
|
|
78
|
+
const cfg = readWorkspacesFile().workspaces[ws];
|
|
79
|
+
if (!cfg) throw new AgentError(`workspace "${ws}" does not exist`);
|
|
80
|
+
return resolveWorkspaceDir(ws, cfg, getReposRoot());
|
|
81
|
+
},
|
|
82
|
+
stdout: NO_TTY,
|
|
83
|
+
out: () => undefined,
|
|
84
|
+
err: () => undefined,
|
|
85
|
+
...over,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The lock chip's state: an admin password set, or sudo without one. */
|
|
90
|
+
function defaultUnlocked(): boolean {
|
|
91
|
+
return hasSudoPassword() || hasPasswordlessSudo();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An AgentError (or the host's refusal) as the R26 answer. */
|
|
95
|
+
function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
|
|
96
|
+
if (e instanceof AgentError) {
|
|
97
|
+
// From another workspace, an agent isn't there at all: no name of it.
|
|
98
|
+
const error =
|
|
99
|
+
e.code === NOT_IN_WORKSPACE && ws
|
|
100
|
+
? `no agent here in workspace ${ws}`
|
|
101
|
+
: e.message;
|
|
102
|
+
sendJson(
|
|
103
|
+
res,
|
|
104
|
+
{
|
|
105
|
+
ok: false,
|
|
106
|
+
error,
|
|
107
|
+
...(e.code && e.code !== NOT_IN_WORKSPACE ? { code: e.code } : {}),
|
|
108
|
+
...(e.hints.length ? { hints: e.hints } : {}),
|
|
109
|
+
},
|
|
110
|
+
e.status,
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (e instanceof HostError) {
|
|
115
|
+
sendJson(
|
|
116
|
+
res,
|
|
117
|
+
{ ok: false, error: e.message, code: "session_host" },
|
|
118
|
+
e.code === "cap" ? 429 : 503,
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A started or resumed agent's answer: its record, or its early exit. */
|
|
126
|
+
function sendStarted(res: http.ServerResponse, s: Started): void {
|
|
127
|
+
if (s.early) {
|
|
128
|
+
const code =
|
|
129
|
+
s.early.exitCode === undefined ? "" : ` (exit ${s.early.exitCode})`;
|
|
130
|
+
sendJson(
|
|
131
|
+
res,
|
|
132
|
+
{
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `agent ${s.rec.id} exited at once${code}`,
|
|
135
|
+
code: "exited_at_once",
|
|
136
|
+
tail: s.early.tail,
|
|
137
|
+
session: s.early.sessionName,
|
|
138
|
+
...(s.warnings.length ? { warnings: s.warnings } : {}),
|
|
139
|
+
},
|
|
140
|
+
502,
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
sendJson(res, {
|
|
145
|
+
ok: true,
|
|
146
|
+
agent: {
|
|
147
|
+
id: s.rec.id,
|
|
148
|
+
persona: s.rec.persona,
|
|
149
|
+
runtime: s.rec.runtime,
|
|
150
|
+
workspace: s.rec.workspace,
|
|
151
|
+
cwd: s.rec.cwd,
|
|
152
|
+
session: s.rec.sessionName,
|
|
153
|
+
// The host's id: the Agents tab opens its pane by it (?open=<id>).
|
|
154
|
+
sessionId: s.info.ref.id,
|
|
155
|
+
startedAt: s.rec.startedAt,
|
|
156
|
+
},
|
|
157
|
+
...(s.warnings.length ? { warnings: s.warnings } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What a POST's body names: a few optional strings. */
|
|
162
|
+
async function startBody(
|
|
163
|
+
req: http.IncomingMessage,
|
|
164
|
+
): Promise<Record<string, string | undefined>> {
|
|
165
|
+
const raw = await readBody(req).catch(() => "");
|
|
166
|
+
if (raw.length > MAX_BODY)
|
|
167
|
+
throw new AgentError("request too large", { status: 413, usage: true });
|
|
168
|
+
let v: unknown;
|
|
169
|
+
try {
|
|
170
|
+
v = JSON.parse(raw || "{}");
|
|
171
|
+
} catch {
|
|
172
|
+
throw new AgentError("invalid JSON", { usage: true });
|
|
173
|
+
}
|
|
174
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
175
|
+
throw new AgentError("the body must be a JSON object", { usage: true });
|
|
176
|
+
const out: Record<string, string | undefined> = {};
|
|
177
|
+
for (const k of ["persona", "runtime", "model", "id"]) {
|
|
178
|
+
const x = (v as Record<string, unknown>)[k];
|
|
179
|
+
if (x === undefined || x === null || x === "") continue;
|
|
180
|
+
if (typeof x !== "string")
|
|
181
|
+
throw new AgentError(`${k} must be a string`, { usage: true });
|
|
182
|
+
out[k] = x;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Handles the routes above; false when `pathname` is none of them. Every
|
|
189
|
+
* answer is JSON; an error is R26's { ok: false, error, code, requestId }
|
|
190
|
+
* (the server's jsonApiErrors adds the requestId).
|
|
191
|
+
*/
|
|
192
|
+
export async function handleAgentRoutes(
|
|
193
|
+
req: http.IncomingMessage,
|
|
194
|
+
res: http.ServerResponse,
|
|
195
|
+
pathname: string,
|
|
196
|
+
deps: AgentRouteDeps = {},
|
|
197
|
+
): Promise<boolean> {
|
|
198
|
+
const m = AGENTS_PATH.exec(pathname);
|
|
199
|
+
if (pathname !== "/api/personas" && !m) return false;
|
|
200
|
+
const method = req.method ?? "GET";
|
|
201
|
+
const d = webDeps(deps.agents);
|
|
202
|
+
|
|
203
|
+
if (!m) {
|
|
204
|
+
if (method !== "GET") return methodNotAllowed(res, "GET");
|
|
205
|
+
try {
|
|
206
|
+
sendJson(res, { personas: listPersonas(d), runtimes: listRuntimes(d) });
|
|
207
|
+
} catch (e) {
|
|
208
|
+
sendError(res, e);
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let ws: string;
|
|
214
|
+
try {
|
|
215
|
+
ws = decodeURIComponent(m[1]);
|
|
216
|
+
} catch {
|
|
217
|
+
sendJson(res, { ok: false, error: "not a workspace name" }, 400);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
const id = m[2] === undefined ? undefined : safeDecode(m[2]);
|
|
221
|
+
const action = m[3] as "stop" | "resume" | undefined;
|
|
222
|
+
|
|
223
|
+
if (!action && method === "GET") {
|
|
224
|
+
try {
|
|
225
|
+
if (!d.workspaceExists(ws))
|
|
226
|
+
throw new AgentError(`workspace "${ws}" does not exist`, {
|
|
227
|
+
status: 404,
|
|
228
|
+
});
|
|
229
|
+
sendJson(res, {
|
|
230
|
+
workspace: ws,
|
|
231
|
+
// Asking never starts a session host: with none, nothing runs.
|
|
232
|
+
agents: await listAgents(d, ws, { startHost: false }),
|
|
233
|
+
maxConcurrent: d.maxConcurrent(),
|
|
234
|
+
});
|
|
235
|
+
} catch (e) {
|
|
236
|
+
sendError(res, e, ws);
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (method !== "POST")
|
|
241
|
+
return methodNotAllowed(res, action ? "POST" : "GET, POST");
|
|
242
|
+
|
|
243
|
+
// From the credential (SEC-1c): an agent's actor token, never a header.
|
|
244
|
+
const actor = auditActorOf(req);
|
|
245
|
+
const verb = action ?? "start";
|
|
246
|
+
const audit = (detail: string, reason?: string) =>
|
|
247
|
+
recordAction(
|
|
248
|
+
actor,
|
|
249
|
+
{
|
|
250
|
+
verb: "agents",
|
|
251
|
+
detail,
|
|
252
|
+
...(reason ? { outcome: "refused" as const, reason } : {}),
|
|
253
|
+
},
|
|
254
|
+
principalOf(req),
|
|
255
|
+
);
|
|
256
|
+
let what = `${verb} ${id ?? "?"} in ${ws}`;
|
|
257
|
+
try {
|
|
258
|
+
// Another workspace's agent is no agent here: a 404 that doesn't name
|
|
259
|
+
// it, before the lock (a locked request says the same).
|
|
260
|
+
if (id !== undefined) agentRecord(d, id, ws);
|
|
261
|
+
guard(actor, deps.unlocked ?? defaultUnlocked);
|
|
262
|
+
if (verb === "start") {
|
|
263
|
+
const body = await startBody(req);
|
|
264
|
+
if (!body.persona)
|
|
265
|
+
throw new AgentError("persona is required", { usage: true });
|
|
266
|
+
what = `start ${body.id ?? body.persona} in ${ws}`;
|
|
267
|
+
const s = await startAgent(d, {
|
|
268
|
+
workspace: ws,
|
|
269
|
+
persona: body.persona,
|
|
270
|
+
runtime: body.runtime,
|
|
271
|
+
model: body.model,
|
|
272
|
+
id: body.id,
|
|
273
|
+
});
|
|
274
|
+
what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
|
|
275
|
+
audit(what, s.early ? "exited at once" : undefined);
|
|
276
|
+
sendStarted(res, s);
|
|
277
|
+
} else if (verb === "stop") {
|
|
278
|
+
const s = await stopAgent(d, id!, ws);
|
|
279
|
+
audit(what);
|
|
280
|
+
sendJson(res, { ok: true, agent: s.rec.id, wasRunning: !!s.session });
|
|
281
|
+
} else {
|
|
282
|
+
const s = await resumeAgent(d, id!, ws);
|
|
283
|
+
audit(what);
|
|
284
|
+
sendStarted(res, s);
|
|
285
|
+
}
|
|
286
|
+
} catch (e) {
|
|
287
|
+
audit(what, e instanceof Error ? e.message : String(e));
|
|
288
|
+
sendError(res, e, ws);
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* R8 and the actor: a person, with the lock open. An agent is one its actor
|
|
295
|
+
* token proves (the request checks already refuse its start and resume);
|
|
296
|
+
* an x-den-actor header alone is an unverified claim, not an agent.
|
|
297
|
+
*/
|
|
298
|
+
function guard(actor: Actor, unlocked: () => boolean): void {
|
|
299
|
+
if (actor && !actor.unverified)
|
|
300
|
+
throw new AgentError(
|
|
301
|
+
"an agent can't start, stop or resume agents from the page; agents use MCP agent_spawn",
|
|
302
|
+
{ status: 403 },
|
|
303
|
+
);
|
|
304
|
+
if (!unlocked())
|
|
305
|
+
throw new AgentError("Unlock first: agent actions need the lock open", {
|
|
306
|
+
status: 423,
|
|
307
|
+
code: "locked",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function safeDecode(s: string): string {
|
|
312
|
+
try {
|
|
313
|
+
return decodeURIComponent(s);
|
|
314
|
+
} catch {
|
|
315
|
+
return s;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function methodNotAllowed(res: http.ServerResponse, allow: string): true {
|
|
320
|
+
res.setHeader("Allow", allow);
|
|
321
|
+
sendJson(res, { ok: false, error: "method not allowed" }, 405);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
package/src/ui/server/audit.ts
CHANGED
|
@@ -17,7 +17,28 @@ export { actorFromHeaders } from "../../shared/agent-actor.js";
|
|
|
17
17
|
|
|
18
18
|
export interface AuditEntry {
|
|
19
19
|
ts: string;
|
|
20
|
+
/** "agent" only for an agent's actor token (SEC-1c); a claim never is. */
|
|
20
21
|
actor: "agent" | "user";
|
|
22
|
+
/**
|
|
23
|
+
* Who the request's credential proves it is: `person:<os user>` for the
|
|
24
|
+
* person token or a signed-in browser, `agent:<id>` for an actor token
|
|
25
|
+
* (SEC-1c). From the credential only, never from a header the caller
|
|
26
|
+
* sets; absent for a call without one.
|
|
27
|
+
*/
|
|
28
|
+
principal?: string;
|
|
29
|
+
/**
|
|
30
|
+
* SEC-1c: what the caller's x-den-actor headers claimed, when no actor
|
|
31
|
+
* token proved it (unverified is then set). A label, never the actor.
|
|
32
|
+
*/
|
|
33
|
+
claimed?: {
|
|
34
|
+
agent?: string;
|
|
35
|
+
workspace?: string;
|
|
36
|
+
client: string;
|
|
37
|
+
clientVersion: string;
|
|
38
|
+
mcpPid: number | null;
|
|
39
|
+
session: string;
|
|
40
|
+
};
|
|
41
|
+
unverified?: true;
|
|
21
42
|
client?: string;
|
|
22
43
|
clientVersion?: string;
|
|
23
44
|
mcpPid?: number | null;
|
|
@@ -71,18 +92,36 @@ export function recordAction(
|
|
|
71
92
|
addresses?: string[];
|
|
72
93
|
targetWorkspace?: string;
|
|
73
94
|
},
|
|
95
|
+
/** The credential's actor (request-guard principalOf). */
|
|
96
|
+
principal?: string,
|
|
74
97
|
): AuditEntry {
|
|
98
|
+
const claim = actor?.unverified ? actor : null;
|
|
99
|
+
const verified = actor && !actor.unverified ? actor : null;
|
|
75
100
|
const entry: AuditEntry = {
|
|
76
101
|
ts: new Date().toISOString(),
|
|
77
|
-
actor:
|
|
78
|
-
...(
|
|
102
|
+
actor: verified ? "agent" : "user",
|
|
103
|
+
...(principal ? { principal } : {}),
|
|
104
|
+
...(claim
|
|
79
105
|
? {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
claimed: {
|
|
107
|
+
...(claim.agent ? { agent: claim.agent } : {}),
|
|
108
|
+
...(claim.workspace ? { workspace: claim.workspace } : {}),
|
|
109
|
+
client: claim.client,
|
|
110
|
+
clientVersion: claim.clientVersion,
|
|
111
|
+
mcpPid: claim.mcpPid,
|
|
112
|
+
session: claim.session,
|
|
113
|
+
},
|
|
114
|
+
unverified: true as const,
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
...(verified
|
|
118
|
+
? {
|
|
119
|
+
client: verified.client,
|
|
120
|
+
clientVersion: verified.clientVersion,
|
|
121
|
+
mcpPid: verified.mcpPid,
|
|
122
|
+
session: verified.session,
|
|
123
|
+
...(verified.agent ? { agent: verified.agent } : {}),
|
|
124
|
+
...(verified.workspace ? { workspace: verified.workspace } : {}),
|
|
86
125
|
}
|
|
87
126
|
: {}),
|
|
88
127
|
...(action.targetWorkspace
|
|
@@ -105,7 +144,8 @@ export function recordAction(
|
|
|
105
144
|
? ""
|
|
106
145
|
: action.detail;
|
|
107
146
|
sink?.(
|
|
108
|
-
`${mark} ${action.verb}${what ? ` ${what}` : ""}${why}${actorSuffix(actor)}
|
|
147
|
+
`${mark} ${action.verb}${what ? ` ${what}` : ""}${why}${actorSuffix(actor)}` +
|
|
148
|
+
(principal && !verified ? ` · as ${principal}` : ""),
|
|
109
149
|
actor,
|
|
110
150
|
);
|
|
111
151
|
return entry;
|
|
@@ -177,6 +217,7 @@ export function recordAgentRefusal(
|
|
|
177
217
|
targets?: unknown;
|
|
178
218
|
reason?: unknown;
|
|
179
219
|
},
|
|
220
|
+
principal?: string,
|
|
180
221
|
): AuditEntry {
|
|
181
222
|
const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
|
|
182
223
|
const repo = typeof report.repo === "string" ? report.repo : "";
|
|
@@ -186,22 +227,30 @@ export function recordAgentRefusal(
|
|
|
186
227
|
if (lifecycle && SAFE_PART.test(repo)) {
|
|
187
228
|
const address =
|
|
188
229
|
service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
|
|
189
|
-
return recordAction(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
230
|
+
return recordAction(
|
|
231
|
+
actor,
|
|
232
|
+
{
|
|
233
|
+
verb: "exec",
|
|
234
|
+
detail: `${address} ${lifecycle[1]} (local)`,
|
|
235
|
+
outcome: "refused",
|
|
236
|
+
reason,
|
|
237
|
+
},
|
|
238
|
+
principal,
|
|
239
|
+
);
|
|
195
240
|
}
|
|
196
241
|
// Any other tool: the refusal is the detail, under every service or repo
|
|
197
242
|
// the call named (all of a logs_search's targets, in one entry).
|
|
198
|
-
return recordAction(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
243
|
+
return recordAction(
|
|
244
|
+
actor,
|
|
245
|
+
{
|
|
246
|
+
verb: tool,
|
|
247
|
+
detail: reason,
|
|
248
|
+
outcome: "refused",
|
|
249
|
+
reason,
|
|
250
|
+
addresses: refusedAddresses(report),
|
|
251
|
+
},
|
|
252
|
+
principal,
|
|
253
|
+
);
|
|
205
254
|
}
|
|
206
255
|
|
|
207
256
|
/**
|
|
@@ -215,6 +264,7 @@ export function recordAgentRefusal(
|
|
|
215
264
|
export function recordCrossWorkspace(
|
|
216
265
|
actor: Actor,
|
|
217
266
|
report: { tool?: unknown; crossWorkspace?: unknown },
|
|
267
|
+
principal?: string,
|
|
218
268
|
): AuditEntry | null {
|
|
219
269
|
if (!actor?.agent || !actor.workspace) return null;
|
|
220
270
|
const target =
|
|
@@ -222,11 +272,15 @@ export function recordCrossWorkspace(
|
|
|
222
272
|
if (!WORKSPACE_NAME_RE.test(target) || target === actor.workspace)
|
|
223
273
|
return null;
|
|
224
274
|
const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
|
|
225
|
-
return recordAction(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
275
|
+
return recordAction(
|
|
276
|
+
actor,
|
|
277
|
+
{
|
|
278
|
+
verb: tool,
|
|
279
|
+
detail: `in workspace ${target}, under a grant to ${actor.agent} of workspace ${actor.workspace}`,
|
|
280
|
+
targetWorkspace: target,
|
|
281
|
+
},
|
|
282
|
+
principal,
|
|
283
|
+
);
|
|
230
284
|
}
|
|
231
285
|
|
|
232
286
|
/**
|
|
@@ -262,11 +262,16 @@ export function pluginForPath(
|
|
|
262
262
|
|
|
263
263
|
/** What error.html is told: everything error-page.js draws from. */
|
|
264
264
|
export interface ErrorPageData {
|
|
265
|
-
/**
|
|
266
|
-
|
|
265
|
+
/**
|
|
266
|
+
* 422: the config fails validation (a ConfigError). 401, 403, 421: the
|
|
267
|
+
* request checks refused it (SEC-1a): `message` says why, `code` names it.
|
|
268
|
+
*/
|
|
269
|
+
status: 401 | 403 | 404 | 421 | 422 | 500;
|
|
270
|
+
/** 401, 403, 421: the refusal's code (misdirected, origin_rejected, …). */
|
|
271
|
+
code?: string;
|
|
267
272
|
/** 404 and 422: the path asked for (no query). */
|
|
268
273
|
path?: string;
|
|
269
|
-
/** 422: the ConfigError's message (it names config keys, never values). */
|
|
274
|
+
/** 422: the ConfigError's message (it names config keys, never values); 401/403/421: the refusal's. */
|
|
270
275
|
message?: string;
|
|
271
276
|
/** 422: each problem: its file, key path and message (0.3.0-dev.40). */
|
|
272
277
|
issues?: Array<{ file: string; key: string; message: string }>;
|
|
@@ -281,6 +286,19 @@ export interface ErrorPageData {
|
|
|
281
286
|
plugin?: PluginRoute | null;
|
|
282
287
|
/** MD-2: the restart message, when the engine on disk is newer. */
|
|
283
288
|
engine?: { loaded: string | null; onDisk: string | null; message: string };
|
|
289
|
+
/**
|
|
290
|
+
* 401 signin_required (SEC-1b): the signed-out page. `scope` terminal
|
|
291
|
+
* (shell-grade on loopback) or den (beyond loopback); `cmd` the command
|
|
292
|
+
* that prints a link; `localhost` the same path there (on 127.0.0.1 or
|
|
293
|
+
* [::1]); `expired` for a login link that expired or was used.
|
|
294
|
+
*/
|
|
295
|
+
signin?: {
|
|
296
|
+
scope: "terminal" | "agents" | "den";
|
|
297
|
+
name: string;
|
|
298
|
+
cmd: string;
|
|
299
|
+
localhost?: string;
|
|
300
|
+
expired?: boolean;
|
|
301
|
+
};
|
|
284
302
|
}
|
|
285
303
|
|
|
286
304
|
/**
|