@monster-orange/den 0.4.6 → 0.4.8
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 +160 -0
- package/README.md +7 -7
- package/docs/distributions.md +244 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/skills/install-den/SKILL.md +2 -2
- package/src/shared/agents/den-claude.ts +38 -5
- package/src/shared/agents/guardrails/hooks.json +27 -0
- package/src/shared/agents/guardrails/instructions.md +39 -0
- package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
- package/src/shared/agents/guardrails/stop-hook.ts +66 -0
- package/src/shared/agents/guardrails.ts +114 -0
- package/src/shared/agents/handback.ts +241 -0
- package/src/shared/agents/personas.ts +66 -7
- package/src/shared/agents/records.ts +114 -0
- package/src/shared/agents/runtime.ts +12 -0
- package/src/shared/den-config.ts +97 -6
- package/src/shared/dev-metrics/emit.ts +31 -4
- package/src/shared/dev-metrics/polling.ts +135 -0
- package/src/shared/engine.ts +14 -2
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +283 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +262 -0
- package/src/ui/cli/agent-ops.ts +495 -53
- package/src/ui/cli/agents-command.ts +170 -14
- package/src/ui/cli/doctor-command.ts +20 -0
- package/src/ui/cli/flag-parser.ts +1 -0
- package/src/ui/cli/index.ts +119 -2
- package/src/ui/cli/usage-command.ts +256 -0
- package/src/ui/cli/web-command.ts +3 -1
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +230 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +333 -11
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +28 -0
- package/src/ui/server/public/i18n/ja.js +25 -0
- package/src/ui/server/public/shell.js +66 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +23 -0
- package/src/ui/server/session-routes.ts +52 -4
- package/src/ui/server/sudo-store.ts +36 -7
|
@@ -15,7 +15,16 @@
|
|
|
15
15
|
* GET /api/workspaces/<ws>/agents the workspace's agents
|
|
16
16
|
* POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
|
|
17
17
|
* POST /api/workspaces/<ws>/agents/<id>/stop
|
|
18
|
-
* POST /api/workspaces/<ws>/agents/<id>/resume
|
|
18
|
+
* POST /api/workspaces/<ws>/agents/<id>/resume the same conversation (AG-14)
|
|
19
|
+
* POST /api/workspaces/<ws>/agents/<id>/fresh a new conversation, same
|
|
20
|
+
* persona and workspace
|
|
21
|
+
* POST /api/workspaces/<ws>/agents/<id>/remove forget a stopped one (Undo:
|
|
22
|
+
* /api/agents/restore)
|
|
23
|
+
* POST /api/agents/clear-stopped { workspaces: [...] }: forget
|
|
24
|
+
* every stopped one there
|
|
25
|
+
* POST /api/agents/restore { agents: [ids] }: Undo
|
|
26
|
+
* POST /api/agents/resume-interrupted { workspaces: [...] }: Resume
|
|
27
|
+
* all after a restart (AG-14.3)
|
|
19
28
|
*
|
|
20
29
|
* Each calls the CLI's own operation (cli/agent-ops.ts): neither the page
|
|
21
30
|
* nor the CLI keeps state of its own, so an agent started here is the one
|
|
@@ -39,7 +48,11 @@
|
|
|
39
48
|
* the only route that writes to a session or streams its output.
|
|
40
49
|
*/
|
|
41
50
|
import * as http from "http";
|
|
42
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
configuredRemoveStoppedAfter,
|
|
53
|
+
getReposRoot,
|
|
54
|
+
readMcpConfig,
|
|
55
|
+
} from "../../shared/den-config.js";
|
|
43
56
|
import {
|
|
44
57
|
readWorkspacesFile,
|
|
45
58
|
resolveWorkspaceDir,
|
|
@@ -52,18 +65,30 @@ import {
|
|
|
52
65
|
listAgents,
|
|
53
66
|
listPersonas,
|
|
54
67
|
listRuntimes,
|
|
68
|
+
markInterrupted,
|
|
55
69
|
NOT_IN_WORKSPACE,
|
|
70
|
+
pruneStopped,
|
|
71
|
+
removeAgent,
|
|
72
|
+
removeStopped,
|
|
73
|
+
restoreAgents,
|
|
74
|
+
resumeInterrupted,
|
|
56
75
|
setRuntimePath,
|
|
57
76
|
resolveAgentsDeps,
|
|
58
77
|
resumeAgent,
|
|
59
78
|
startAgent,
|
|
79
|
+
startFresh,
|
|
60
80
|
stopAgent,
|
|
61
81
|
type AgentsDeps,
|
|
62
82
|
type AgentsResolved,
|
|
63
83
|
type Started,
|
|
64
84
|
} from "../cli/agent-ops.js";
|
|
65
85
|
import { recordAction } from "./audit.js";
|
|
66
|
-
import {
|
|
86
|
+
import {
|
|
87
|
+
agentPrincipalOf,
|
|
88
|
+
auditActorOf,
|
|
89
|
+
principalOf,
|
|
90
|
+
} from "./request-guard.js";
|
|
91
|
+
import { agentResumeRefusal } from "./agent-resume-grant.js";
|
|
67
92
|
import { readBody, sendJson } from "./http-helpers.js";
|
|
68
93
|
import type { Actor } from "../../shared/agent-actor.js";
|
|
69
94
|
|
|
@@ -73,10 +98,14 @@ export interface AgentRouteDeps {
|
|
|
73
98
|
}
|
|
74
99
|
|
|
75
100
|
const AGENTS_PATH =
|
|
76
|
-
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume))?$/;
|
|
101
|
+
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|remove))?$/;
|
|
102
|
+
|
|
77
103
|
const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
|
|
78
104
|
/** A start's body is a few short names. */
|
|
79
105
|
const MAX_BODY = 4096;
|
|
106
|
+
/** A list of names (a page's workspaces, an Undo's agents). */
|
|
107
|
+
const MANY_BODY = 64 * 1024;
|
|
108
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
80
109
|
|
|
81
110
|
/** Never a TTY: a web start's terminal is 80×24. */
|
|
82
111
|
const NO_TTY = { isTTY: false } as NodeJS.WriteStream;
|
|
@@ -129,7 +158,10 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
|
|
|
129
158
|
}
|
|
130
159
|
|
|
131
160
|
/** A started or resumed agent's answer: its record, or its early exit. */
|
|
132
|
-
function sendStarted(
|
|
161
|
+
function sendStarted(
|
|
162
|
+
res: http.ServerResponse,
|
|
163
|
+
s: Started & { fresh?: boolean },
|
|
164
|
+
): void {
|
|
133
165
|
if (s.early) {
|
|
134
166
|
const code =
|
|
135
167
|
s.early.exitCode === undefined ? "" : ` (exit ${s.early.exitCode})`;
|
|
@@ -160,6 +192,8 @@ function sendStarted(res: http.ServerResponse, s: Started): void {
|
|
|
160
192
|
sessionId: s.info.ref.id,
|
|
161
193
|
startedAt: s.rec.startedAt,
|
|
162
194
|
},
|
|
195
|
+
// A resume with nothing to continue yet started fresh (same id).
|
|
196
|
+
...(s.fresh ? { fresh: true } : {}),
|
|
163
197
|
...(s.warnings.length ? { warnings: s.warnings } : {}),
|
|
164
198
|
});
|
|
165
199
|
}
|
|
@@ -203,17 +237,24 @@ export async function handleAgentRoutes(
|
|
|
203
237
|
): Promise<boolean> {
|
|
204
238
|
const m = AGENTS_PATH.exec(pathname);
|
|
205
239
|
const rp = RUNTIME_PATH.exec(pathname);
|
|
240
|
+
// AG-14's routes over many agents at once.
|
|
241
|
+
const many =
|
|
242
|
+
pathname === "/api/agents/clear-stopped" ||
|
|
243
|
+
pathname === "/api/agents/restore" ||
|
|
244
|
+
pathname === "/api/agents/resume-interrupted";
|
|
206
245
|
if (
|
|
207
246
|
pathname !== "/api/personas" &&
|
|
208
247
|
pathname !== "/api/agents/runtimes" &&
|
|
209
248
|
!m &&
|
|
210
|
-
!rp
|
|
249
|
+
!rp &&
|
|
250
|
+
!many
|
|
211
251
|
)
|
|
212
252
|
return false;
|
|
213
253
|
const method = req.method ?? "GET";
|
|
214
254
|
const d = webDeps(deps.agents);
|
|
215
255
|
|
|
216
256
|
if (rp) return runtimePath(req, res, safeDecode(rp[1]), d);
|
|
257
|
+
if (many) return manyAgents(req, res, pathname, d);
|
|
217
258
|
if (!m) {
|
|
218
259
|
if (method !== "GET" && method !== "HEAD")
|
|
219
260
|
return methodNotAllowed(res, "GET");
|
|
@@ -241,7 +282,7 @@ export async function handleAgentRoutes(
|
|
|
241
282
|
return true;
|
|
242
283
|
}
|
|
243
284
|
const id = m[2] === undefined ? undefined : safeDecode(m[2]);
|
|
244
|
-
const action = m[3] as "stop" | "resume" | undefined;
|
|
285
|
+
const action = m[3] as "stop" | "resume" | "fresh" | "remove" | undefined;
|
|
245
286
|
|
|
246
287
|
if (!action && method === "GET") {
|
|
247
288
|
try {
|
|
@@ -281,7 +322,12 @@ export async function handleAgentRoutes(
|
|
|
281
322
|
// Another workspace's agent is no agent here: a 404 that doesn't name
|
|
282
323
|
// it, before anything else is said about it.
|
|
283
324
|
if (id !== undefined) agentRecord(d, id, ws);
|
|
284
|
-
|
|
325
|
+
// AG-14.4: MCP agent_resume may resume another agent of its scope.
|
|
326
|
+
const agent = agentPrincipalOf(req);
|
|
327
|
+
if (verb === "resume" && agent) {
|
|
328
|
+
const why = agentResumeRefusal(agent, ws, id!, mcpActions());
|
|
329
|
+
if (why) throw new AgentError(why, { status: 403 });
|
|
330
|
+
} else guard(actor);
|
|
285
331
|
if (verb === "start") {
|
|
286
332
|
const body = await startBody(req);
|
|
287
333
|
if (!body.persona)
|
|
@@ -298,9 +344,30 @@ export async function handleAgentRoutes(
|
|
|
298
344
|
audit(what, s.early ? "exited at once" : undefined);
|
|
299
345
|
sendStarted(res, s);
|
|
300
346
|
} else if (verb === "stop") {
|
|
347
|
+
// The answer comes once the session has ended (200, stopped: true),
|
|
348
|
+
// or, still running after the wait, 202 with stillStopping: true.
|
|
301
349
|
const s = await stopAgent(d, id!, ws);
|
|
350
|
+
audit(s.stillStopping ? `${what} (still stopping)` : what);
|
|
351
|
+
sendJson(
|
|
352
|
+
res,
|
|
353
|
+
{
|
|
354
|
+
ok: true,
|
|
355
|
+
agent: s.rec.id,
|
|
356
|
+
wasRunning: !!s.session,
|
|
357
|
+
stopped: !s.stillStopping,
|
|
358
|
+
...(s.stillStopping ? { stillStopping: true } : {}),
|
|
359
|
+
},
|
|
360
|
+
s.stillStopping ? 202 : 200,
|
|
361
|
+
);
|
|
362
|
+
} else if (verb === "fresh") {
|
|
363
|
+
const s = await startFresh(d, id!, ws);
|
|
364
|
+
what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
|
|
365
|
+
audit(what, s.early ? "exited at once" : undefined);
|
|
366
|
+
sendStarted(res, s);
|
|
367
|
+
} else if (verb === "remove") {
|
|
368
|
+
const removed = await removeAgent(d, id!, ws);
|
|
302
369
|
audit(what);
|
|
303
|
-
sendJson(res, { ok: true,
|
|
370
|
+
sendJson(res, { ok: true, removed });
|
|
304
371
|
} else {
|
|
305
372
|
const s = await resumeAgent(d, id!, ws);
|
|
306
373
|
audit(what);
|
|
@@ -313,6 +380,112 @@ export async function handleAgentRoutes(
|
|
|
313
380
|
return true;
|
|
314
381
|
}
|
|
315
382
|
|
|
383
|
+
/** The org config's mcp.actions; none when it can't be read. */
|
|
384
|
+
function mcpActions(): readonly string[] {
|
|
385
|
+
try {
|
|
386
|
+
return readMcpConfig().actions;
|
|
387
|
+
} catch {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A POST body's JSON object (a usage error otherwise). */
|
|
393
|
+
async function jsonBody(
|
|
394
|
+
req: http.IncomingMessage,
|
|
395
|
+
): Promise<Record<string, unknown>> {
|
|
396
|
+
const raw = await readBody(req).catch(() => "");
|
|
397
|
+
if (raw.length > MANY_BODY)
|
|
398
|
+
throw new AgentError("request too large", { status: 413, usage: true });
|
|
399
|
+
let v: unknown;
|
|
400
|
+
try {
|
|
401
|
+
v = JSON.parse(raw || "{}");
|
|
402
|
+
} catch {
|
|
403
|
+
throw new AgentError("invalid JSON", { usage: true });
|
|
404
|
+
}
|
|
405
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
406
|
+
throw new AgentError("the body must be a JSON object", { usage: true });
|
|
407
|
+
return v as Record<string, unknown>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A body's list of names (workspaces or agent ids), each checked. */
|
|
411
|
+
function names(v: unknown, key: string): string[] {
|
|
412
|
+
if (!Array.isArray(v) || !v.length)
|
|
413
|
+
throw new AgentError(`${key} must be a list of names`, { usage: true });
|
|
414
|
+
for (const x of v)
|
|
415
|
+
if (typeof x !== "string" || !NAME_RE.test(x))
|
|
416
|
+
throw new AgentError(`${key}: "${String(x)}" is not a name`, {
|
|
417
|
+
usage: true,
|
|
418
|
+
});
|
|
419
|
+
return [...new Set(v as string[])];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* POST /api/agents/clear-stopped, /restore, /resume-interrupted (AG-14):
|
|
424
|
+
* a person only. Each is audited, refused or done.
|
|
425
|
+
*/
|
|
426
|
+
async function manyAgents(
|
|
427
|
+
req: http.IncomingMessage,
|
|
428
|
+
res: http.ServerResponse,
|
|
429
|
+
pathname: string,
|
|
430
|
+
d: AgentsResolved,
|
|
431
|
+
): Promise<true> {
|
|
432
|
+
if (req.method !== "POST") return methodNotAllowed(res, "POST");
|
|
433
|
+
const actor = auditActorOf(req);
|
|
434
|
+
const verb = pathname.slice("/api/agents/".length);
|
|
435
|
+
let what = verb;
|
|
436
|
+
const audit = (reason?: string) =>
|
|
437
|
+
recordAction(
|
|
438
|
+
actor,
|
|
439
|
+
{
|
|
440
|
+
verb: "agents",
|
|
441
|
+
detail: what,
|
|
442
|
+
...(reason ? { outcome: "refused" as const, reason } : {}),
|
|
443
|
+
},
|
|
444
|
+
principalOf(req),
|
|
445
|
+
);
|
|
446
|
+
try {
|
|
447
|
+
guard(actor);
|
|
448
|
+
const body = await jsonBody(req);
|
|
449
|
+
if (verb === "restore") {
|
|
450
|
+
const ids = names(body.agents, "agents");
|
|
451
|
+
const restored = restoreAgents(d, ids);
|
|
452
|
+
what = `restore ${restored.join(", ") || "nothing"}`;
|
|
453
|
+
audit();
|
|
454
|
+
sendJson(res, { ok: true, restored });
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
const wss = names(body.workspaces, "workspaces");
|
|
458
|
+
for (const w of wss)
|
|
459
|
+
if (!d.workspaceExists(w))
|
|
460
|
+
throw new AgentError(`workspace "${w}" does not exist`, {
|
|
461
|
+
status: 404,
|
|
462
|
+
});
|
|
463
|
+
if (verb === "clear-stopped") {
|
|
464
|
+
const removed = await removeStopped(d, wss);
|
|
465
|
+
what = `clear stopped in ${wss.join(", ")}: ${removed.map((r) => r.agent).join(", ") || "none"}`;
|
|
466
|
+
audit();
|
|
467
|
+
sendJson(res, { ok: true, removed });
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
const r = await resumeInterrupted(d, wss);
|
|
471
|
+
what = `resume interrupted in ${wss.join(", ")}: ${r.resumed.map((x) => x.rec.id).join(", ") || "none"}`;
|
|
472
|
+
audit(r.failed.length ? `${r.failed.length} could not resume` : undefined);
|
|
473
|
+
sendJson(res, {
|
|
474
|
+
ok: r.failed.length === 0,
|
|
475
|
+
resumed: r.resumed.map((x) => ({
|
|
476
|
+
id: x.rec.id,
|
|
477
|
+
workspace: x.rec.workspace,
|
|
478
|
+
sessionId: x.info.ref.id,
|
|
479
|
+
})),
|
|
480
|
+
failed: r.failed,
|
|
481
|
+
});
|
|
482
|
+
} catch (e) {
|
|
483
|
+
audit(e instanceof Error ? e.message : String(e));
|
|
484
|
+
sendError(res, e);
|
|
485
|
+
}
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
|
|
316
489
|
/** `?workspace=<ws>`, when it names a workspace here; else none. */
|
|
317
490
|
function queryWorkspace(
|
|
318
491
|
req: http.IncomingMessage,
|
|
@@ -419,3 +592,51 @@ function methodNotAllowed(res: http.ServerResponse, allow: string): true {
|
|
|
419
592
|
sendJson(res, { ok: false, error: "method not allowed" }, 405);
|
|
420
593
|
return true;
|
|
421
594
|
}
|
|
595
|
+
|
|
596
|
+
/** How often stopped agents are checked against remove_stopped_after. */
|
|
597
|
+
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* What the web server does for agents on its own (AG-14):
|
|
601
|
+
* - once, shortly after it starts: marks interrupted the agents whose
|
|
602
|
+
* session is gone (Den or the machine went down), and resumes the
|
|
603
|
+
* standing personas and those with `resume_on_restart: true`
|
|
604
|
+
* (markInterrupted). A session host that outlived a web-server-only
|
|
605
|
+
* restart keeps every session: nothing is marked;
|
|
606
|
+
* - at start and every hour: forgets stopped and exited agents older
|
|
607
|
+
* than user.yml's agents.remove_stopped_after (never by default;
|
|
608
|
+
* interrupted ones never).
|
|
609
|
+
* Nothing here asks for the admin password (LK-0).
|
|
610
|
+
*/
|
|
611
|
+
export function startAgentUpkeep(
|
|
612
|
+
log: (line: string) => void,
|
|
613
|
+
deps: AgentRouteDeps & { delayMs?: number } = {},
|
|
614
|
+
): void {
|
|
615
|
+
const d = webDeps(deps.agents);
|
|
616
|
+
const restart = async () => {
|
|
617
|
+
try {
|
|
618
|
+
const f = await markInterrupted(d);
|
|
619
|
+
if (f.interrupted.length)
|
|
620
|
+
log(
|
|
621
|
+
`Agents interrupted when Den restarted: ${f.interrupted.join(", ")}` +
|
|
622
|
+
(f.resumed.length ? ` · resumed: ${f.resumed.join(", ")}` : ""),
|
|
623
|
+
);
|
|
624
|
+
for (const x of f.failed)
|
|
625
|
+
log(` ⚠ agent ${x.agent} did not resume: ${x.why}`);
|
|
626
|
+
} catch (e) {
|
|
627
|
+
log(` ⚠ agents after the restart: ${(e as Error).message}`);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
const prune = async () => {
|
|
631
|
+
try {
|
|
632
|
+
const gone = await pruneStopped(d, configuredRemoveStoppedAfter());
|
|
633
|
+
if (gone.length) log(`Removed stopped agents: ${gone.join(", ")}`);
|
|
634
|
+
} catch (e) {
|
|
635
|
+
log(` ⚠ agents.remove_stopped_after: ${(e as Error).message}`);
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
setTimeout(() => {
|
|
639
|
+
void restart().then(prune);
|
|
640
|
+
}, deps.delayMs ?? 2000).unref();
|
|
641
|
+
setInterval(() => void prune(), PRUNE_EVERY_MS).unref();
|
|
642
|
+
}
|
package/src/ui/server/audit.ts
CHANGED
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
* asked (a person in the UI, or an agent through `<cli> mcp`). Each action
|
|
4
4
|
* becomes a lifecycle line in the server log, the stream the /logs page shows
|
|
5
5
|
* as the web-server log, so agent actions sit beside the UI's own; the
|
|
6
|
-
* structured entries are kept in a small
|
|
6
|
+
* structured entries are kept in a small ring.
|
|
7
|
+
*
|
|
8
|
+
* den#6: the ring persists. With setAuditFile (the web server's state dir,
|
|
9
|
+
* audit.jsonl, 0600), each entry is appended as one JSON line and the last
|
|
10
|
+
* RING_MAX are read back when the server starts, so a restart keeps the
|
|
11
|
+
* trail. The file is compacted to the ring once it holds twice as many.
|
|
12
|
+
* Entries hold names and reasons, never an env value or a secret.
|
|
7
13
|
*/
|
|
14
|
+
import * as fs from "fs";
|
|
8
15
|
import * as path from "path";
|
|
9
16
|
import {
|
|
10
17
|
actorLabel,
|
|
@@ -72,6 +79,65 @@ const RING_MAX = 200;
|
|
|
72
79
|
const ring: AuditEntry[] = [];
|
|
73
80
|
let sink: Sink | null = null;
|
|
74
81
|
|
|
82
|
+
/** The persisted ring's file name, in the state dir. */
|
|
83
|
+
export const AUDIT_FILE = "audit.jsonl";
|
|
84
|
+
let file: string | null = null;
|
|
85
|
+
let fileLines = 0;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Persist the ring to `f` (null: memory only). Reads the last RING_MAX
|
|
89
|
+
* entries back into the ring; a line that isn't an entry is skipped.
|
|
90
|
+
*/
|
|
91
|
+
export function setAuditFile(f: string | null): void {
|
|
92
|
+
file = f;
|
|
93
|
+
fileLines = 0;
|
|
94
|
+
ring.length = 0;
|
|
95
|
+
if (!f) return;
|
|
96
|
+
let text = "";
|
|
97
|
+
try {
|
|
98
|
+
text = fs.readFileSync(f, "utf-8");
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
101
|
+
}
|
|
102
|
+
const lines = text.split("\n").filter(Boolean);
|
|
103
|
+
fileLines = lines.length;
|
|
104
|
+
for (const l of lines.slice(-RING_MAX)) {
|
|
105
|
+
try {
|
|
106
|
+
const e = JSON.parse(l) as AuditEntry;
|
|
107
|
+
if (e && typeof e.ts === "string" && typeof e.verb === "string")
|
|
108
|
+
ring.push(e);
|
|
109
|
+
} catch {
|
|
110
|
+
/* a torn line */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function persist(entry: AuditEntry): void {
|
|
116
|
+
if (!file) return;
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
119
|
+
if (fileLines + 1 > RING_MAX * 2) {
|
|
120
|
+
// Compact to the ring (which already holds this entry).
|
|
121
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
tmp,
|
|
124
|
+
ring.map((e) => JSON.stringify(e)).join("\n") + "\n",
|
|
125
|
+
{
|
|
126
|
+
mode: 0o600,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
fs.renameSync(tmp, file);
|
|
130
|
+
fileLines = ring.length;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
134
|
+
fs.chmodSync(file, 0o600);
|
|
135
|
+
fileLines++;
|
|
136
|
+
} catch {
|
|
137
|
+
/* the trail stays in memory; the log line still goes out */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
75
141
|
/** Where audit lines go (the server log); null to disconnect. */
|
|
76
142
|
export function setAuditSink(fn: Sink | null): void {
|
|
77
143
|
sink = fn;
|
|
@@ -135,6 +201,7 @@ export function recordAction(
|
|
|
135
201
|
};
|
|
136
202
|
ring.push(entry);
|
|
137
203
|
if (ring.length > RING_MAX) ring.shift();
|
|
204
|
+
persist(entry);
|
|
138
205
|
const mark = entry.outcome === "refused" ? "✗" : "▶";
|
|
139
206
|
const why = entry.reason ? ` — refused: ${entry.reason}` : "";
|
|
140
207
|
// A refusal whose detail is its reason names what it touched instead.
|
package/src/ui/server/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import * as net from "net";
|
|
|
24
24
|
import * as fs from "fs";
|
|
25
25
|
import * as os from "os";
|
|
26
26
|
import * as path from "path";
|
|
27
|
-
import {
|
|
27
|
+
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
28
28
|
import { fileURLToPath } from "url";
|
|
29
29
|
import { envSelector } from "../../shared/environment/env-selector.js";
|
|
30
30
|
import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
|
|
@@ -179,6 +179,8 @@ import {
|
|
|
179
179
|
recordCrossWorkspace,
|
|
180
180
|
repoAtPath,
|
|
181
181
|
setAuditSink,
|
|
182
|
+
setAuditFile,
|
|
183
|
+
AUDIT_FILE,
|
|
182
184
|
} from "./audit.js";
|
|
183
185
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
184
186
|
import {
|
|
@@ -672,6 +674,12 @@ function sendErrorPage(
|
|
|
672
674
|
setAuditSink((line, actor) =>
|
|
673
675
|
serverLog(line, "lifecycle", actor ? actorLabel(actor) : undefined),
|
|
674
676
|
);
|
|
677
|
+
// den#6: the audit ring outlives a restart (<state dir>/audit.jsonl, 0600).
|
|
678
|
+
try {
|
|
679
|
+
setAuditFile(path.join(getStateDir(), AUDIT_FILE));
|
|
680
|
+
} catch (e) {
|
|
681
|
+
console.error(`[server] the audit file could not be read: ${String(e)}`);
|
|
682
|
+
}
|
|
675
683
|
|
|
676
684
|
/** ANSI helpers (no chalk dependency in the server) */
|
|
677
685
|
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
@@ -752,6 +760,7 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
|
|
|
752
760
|
// ── Sudo password store ───────────────────────────────────────────────────────
|
|
753
761
|
import {
|
|
754
762
|
setSudoPassword,
|
|
763
|
+
realSudoPath,
|
|
755
764
|
clearSudoPassword,
|
|
756
765
|
clearSudoShims,
|
|
757
766
|
closeLockForThisRequest,
|
|
@@ -814,7 +823,7 @@ import { parseChangelog } from "./changelog-parser.js";
|
|
|
814
823
|
import { GitHubService } from "./github-service.js";
|
|
815
824
|
import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
816
825
|
import { handleWorkspaceRoutes } from "./workspace-routes.js";
|
|
817
|
-
import { handleAgentRoutes } from "./agent-routes.js";
|
|
826
|
+
import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
|
|
818
827
|
import {
|
|
819
828
|
handleSessionList,
|
|
820
829
|
handleSessionUpgrade,
|
|
@@ -4321,7 +4330,7 @@ async function handleRequest(
|
|
|
4321
4330
|
// Validate the password before storing it.
|
|
4322
4331
|
try {
|
|
4323
4332
|
// -k: ignore cached credentials for this check (don't permanently invalidate)
|
|
4324
|
-
|
|
4333
|
+
execFileSync(realSudoPath(), ["-k", "-S", "-v"], {
|
|
4325
4334
|
input: password + "\n",
|
|
4326
4335
|
stdio: ["pipe", "pipe", "pipe"],
|
|
4327
4336
|
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
@@ -4379,7 +4388,7 @@ async function handleRequest(
|
|
|
4379
4388
|
}
|
|
4380
4389
|
clearSudoPassword();
|
|
4381
4390
|
try {
|
|
4382
|
-
|
|
4391
|
+
execFileSync(realSudoPath(), ["-k"], {
|
|
4383
4392
|
stdio: ["ignore", "ignore", "ignore"],
|
|
4384
4393
|
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
4385
4394
|
});
|
|
@@ -4433,7 +4442,7 @@ async function handleRequest(
|
|
|
4433
4442
|
return sendJson(res, { ok: false, reason: "not-found" });
|
|
4434
4443
|
}
|
|
4435
4444
|
try {
|
|
4436
|
-
|
|
4445
|
+
execFileSync(realSudoPath(), ["-k", "-S", "-v"], {
|
|
4437
4446
|
input: password + "\n",
|
|
4438
4447
|
stdio: ["pipe", "pipe", "pipe"],
|
|
4439
4448
|
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
@@ -5152,6 +5161,8 @@ try {
|
|
|
5152
5161
|
// service's PATH leaves out ~/.local/bin. Until (or unless) it answers, the
|
|
5153
5162
|
// search is this PATH and the runtimes' install directories.
|
|
5154
5163
|
void initLoginPath();
|
|
5164
|
+
// AG-14: agents interrupted by a restart, and stopped ones to forget.
|
|
5165
|
+
startAgentUpkeep((line) => serverLog(line, "lifecycle"));
|
|
5155
5166
|
server.listen(PORT, BIND_HOST, () => {
|
|
5156
5167
|
const hosts = fs.existsSync("/etc/hosts")
|
|
5157
5168
|
? fs.readFileSync("/etc/hosts", "utf-8")
|