@monster-orange/den 0.4.13 → 0.4.15
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 +176 -0
- package/README.md +7 -7
- package/docs/distributions.md +185 -7
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +256 -0
- package/src/shared/agents/den-claude.ts +19 -0
- package/src/shared/agents/records.ts +23 -0
- package/src/shared/agents/runtime.ts +14 -0
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +14 -0
- package/src/shared/environment-manager/service-runner.ts +31 -12
- package/src/shared/environment-manager/workspaces.ts +31 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +320 -18
- package/src/ui/cli/agents-command.ts +89 -2
- package/src/ui/cli/doctor-command.ts +29 -3
- package/src/ui/cli/doctor-onboard.ts +26 -0
- package/src/ui/cli/flag-parser.ts +9 -1
- package/src/ui/cli/index.ts +29 -3
- package/src/ui/cli/onboard-command.ts +180 -0
- package/src/ui/cli/onboard-reads.ts +613 -0
- package/src/ui/cli/repo-command.ts +156 -10
- package/src/ui/cli/web-command.ts +92 -1
- package/src/ui/mcp/api.ts +26 -3
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +427 -1
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +149 -1
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +12 -4
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +286 -16
- package/src/ui/server/lifecycle-exec.ts +70 -0
- package/src/ui/server/onboard-routes.ts +132 -0
- package/src/ui/server/pair-routes.ts +7 -4
- package/src/ui/server/public/agents-page.js +288 -8
- package/src/ui/server/public/agents.html +18 -0
- package/src/ui/server/public/error-page.js +27 -3
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +52 -0
- package/src/ui/server/public/i18n/ja.js +55 -0
- package/src/ui/server/public/index.html +1 -1
- package/src/ui/server/public/local-dev.html +5 -4
- package/src/ui/server/public/persona-ui.css +45 -0
- package/src/ui/server/public/release.html +0 -1
- package/src/ui/server/public/settings-registry.js +3 -1
- package/src/ui/server/public/settings.html +9 -1
- package/src/ui/server/public/shell.js +336 -0
- package/src/ui/server/request-guard.ts +19 -4
- package/src/ui/server/route-classes.ts +41 -1
- package/src/ui/server/session-routes.ts +11 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- package/themes/den/den-theme.json +6 -6
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AG-17.1, bring in a conversation: the runtime's own recent conversations
|
|
3
|
+
* for a folder, read from its history without ever reading a whole
|
|
4
|
+
* transcript.
|
|
5
|
+
*
|
|
6
|
+
* Claude Code keeps one JSON-lines file per conversation,
|
|
7
|
+
* `<projects>/<slug>/<id>.jsonl`, where <slug> is the cwd with every
|
|
8
|
+
* character that isn't a letter or digit made "-" (`/w/a.b` → `-w-a-b`).
|
|
9
|
+
* The slug alone can't tell `/w/north` from `/w/north-2`, so a
|
|
10
|
+
* conversation is listed only when the `cwd` its own records carry is the
|
|
11
|
+
* folder or inside it.
|
|
12
|
+
*
|
|
13
|
+
* Each file is read at its two ends only (ENDS_BYTES each): the head for
|
|
14
|
+
* its cwd and its first prompt, the tail for its latest `/rename`
|
|
15
|
+
* (custom-title). Title: the latest custom-title, else the first prompt a
|
|
16
|
+
* person typed, else null. lastAt is the file's mtime: a conversation
|
|
17
|
+
* written in the last OPEN_ELSEWHERE_MS is taken to be open in another
|
|
18
|
+
* terminal (one writer only).
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "fs";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
import { claudeProjectsDir } from "../usage/paths.js";
|
|
23
|
+
import { cleanName } from "./transcript-follow.js";
|
|
24
|
+
|
|
25
|
+
/** One of a runtime's conversations, as Bring in lists it. */
|
|
26
|
+
export interface RuntimeConversation {
|
|
27
|
+
/** The runtime's own id for it (Claude Code: the transcript's name). */
|
|
28
|
+
id: string;
|
|
29
|
+
/** Its name for people, or null when it has none yet. */
|
|
30
|
+
title: string | null;
|
|
31
|
+
/** When it was last written (ISO). */
|
|
32
|
+
lastAt: string;
|
|
33
|
+
/** The folder it ran in. */
|
|
34
|
+
cwd: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bytes read at each end of a transcript. */
|
|
38
|
+
export const ENDS_BYTES = 64 * 1024;
|
|
39
|
+
/** Written this recently: still open in another terminal (AG-17.1). */
|
|
40
|
+
export const OPEN_ELSEWHERE_MS = 60_000;
|
|
41
|
+
/** The most conversations listed for one folder (the newest). */
|
|
42
|
+
export const MAX_CONVERSATIONS = 50;
|
|
43
|
+
|
|
44
|
+
export const OPEN_ELSEWHERE_MESSAGE =
|
|
45
|
+
"Still open in another terminal. Close it there first";
|
|
46
|
+
|
|
47
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
48
|
+
|
|
49
|
+
/** Claude Code's project folder name for `dir`. */
|
|
50
|
+
export function claudeSlug(dir: string): string {
|
|
51
|
+
return dir.replace(/[^A-Za-z0-9]/g, "-");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether `p` is `dir` or inside it. */
|
|
55
|
+
function within(p: string, dir: string): boolean {
|
|
56
|
+
const rel = path.relative(dir, p);
|
|
57
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The complete lines at the two ends of `file`: at most `bytes` from each.
|
|
62
|
+
* A line cut at either edge is left out; a short file is all head.
|
|
63
|
+
*/
|
|
64
|
+
export function readEnds(
|
|
65
|
+
file: string,
|
|
66
|
+
bytes = ENDS_BYTES,
|
|
67
|
+
): { head: string[]; tail: string[] } {
|
|
68
|
+
const fd = fs.openSync(file, "r");
|
|
69
|
+
try {
|
|
70
|
+
const size = fs.fstatSync(fd).size;
|
|
71
|
+
const read = (at: number, n: number) => {
|
|
72
|
+
const buf = Buffer.alloc(n);
|
|
73
|
+
const got = fs.readSync(fd, buf, 0, n, at);
|
|
74
|
+
return buf.subarray(0, got).toString("utf-8");
|
|
75
|
+
};
|
|
76
|
+
const headText = read(0, Math.min(size, bytes));
|
|
77
|
+
const head = headText.split("\n");
|
|
78
|
+
// The whole file: every line. Else the last piece was cut.
|
|
79
|
+
if (size <= bytes) return { head: head.filter(Boolean), tail: [] };
|
|
80
|
+
head.pop();
|
|
81
|
+
const start = Math.max(bytes, size - bytes);
|
|
82
|
+
const tail = read(start, size - start).split("\n");
|
|
83
|
+
// The first piece was cut (or is the head's).
|
|
84
|
+
tail.shift();
|
|
85
|
+
return { head: head.filter(Boolean), tail: tail.filter(Boolean) };
|
|
86
|
+
} finally {
|
|
87
|
+
fs.closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parse(line: string): Record<string, unknown> | null {
|
|
92
|
+
try {
|
|
93
|
+
const v = JSON.parse(line) as unknown;
|
|
94
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The text a person typed in a user record, or null (tool results, meta). */
|
|
101
|
+
function promptText(rec: Record<string, unknown>): string | null {
|
|
102
|
+
if (rec.type !== "user" || rec.isMeta === true) return null;
|
|
103
|
+
const msg = rec.message as { content?: unknown } | undefined;
|
|
104
|
+
let text: unknown = msg?.content;
|
|
105
|
+
if (Array.isArray(text)) {
|
|
106
|
+
const first = text.find(
|
|
107
|
+
(c) => (c as { type?: unknown })?.type === "text",
|
|
108
|
+
) as { text?: unknown } | undefined;
|
|
109
|
+
text = first?.text;
|
|
110
|
+
}
|
|
111
|
+
if (typeof text !== "string") return null;
|
|
112
|
+
// Slash commands and their output come wrapped in tags: not a prompt.
|
|
113
|
+
if (text.trimStart().startsWith("<")) return null;
|
|
114
|
+
return cleanName(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A transcript's cwd and title, from its two ends. */
|
|
118
|
+
export function transcriptSummary(
|
|
119
|
+
file: string,
|
|
120
|
+
bytes = ENDS_BYTES,
|
|
121
|
+
): { cwd: string | null; title: string | null } {
|
|
122
|
+
const { head, tail } = readEnds(file, bytes);
|
|
123
|
+
let cwd: string | null = null;
|
|
124
|
+
let prompt: string | null = null;
|
|
125
|
+
let title: string | null = null;
|
|
126
|
+
const titleIn = (line: string) => {
|
|
127
|
+
if (!line.includes("custom-title")) return;
|
|
128
|
+
const r = parse(line);
|
|
129
|
+
if (r?.type === "custom-title") title = cleanName(r.customTitle) ?? title;
|
|
130
|
+
};
|
|
131
|
+
for (const line of head) {
|
|
132
|
+
titleIn(line);
|
|
133
|
+
if (cwd && prompt) continue;
|
|
134
|
+
if (!line.includes('"cwd"') && !line.includes('"user"')) continue;
|
|
135
|
+
const r = parse(line);
|
|
136
|
+
if (!r) continue;
|
|
137
|
+
if (!cwd && typeof r.cwd === "string" && path.isAbsolute(r.cwd))
|
|
138
|
+
cwd = r.cwd;
|
|
139
|
+
prompt ??= promptText(r);
|
|
140
|
+
}
|
|
141
|
+
for (const line of tail) titleIn(line);
|
|
142
|
+
return { cwd, title: title ?? prompt };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A folder's absolute forms: as given and its real path. */
|
|
146
|
+
function forms(dir: string): string[] {
|
|
147
|
+
const out = [path.resolve(dir)];
|
|
148
|
+
try {
|
|
149
|
+
const real = fs.realpathSync(dir);
|
|
150
|
+
if (!out.includes(real)) out.push(real);
|
|
151
|
+
} catch {
|
|
152
|
+
/* gone: as given */
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Claude Code's conversations that ran in `folder` or a folder inside it,
|
|
159
|
+
* newest first, at most MAX_CONVERSATIONS. Never throws: a history it
|
|
160
|
+
* can't read is no conversations.
|
|
161
|
+
*/
|
|
162
|
+
/**
|
|
163
|
+
* Where a workspace's conversations ran (AG-17.1): in or under one of
|
|
164
|
+
* `under`, or exactly in one of `exact` (a shared repos root: its other
|
|
165
|
+
* subfolders are other workspaces' or no repo's).
|
|
166
|
+
*/
|
|
167
|
+
export interface ConversationScope {
|
|
168
|
+
under: string[];
|
|
169
|
+
exact?: string[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function claudeConversations(
|
|
173
|
+
scope: string | ConversationScope,
|
|
174
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
175
|
+
): RuntimeConversation[] {
|
|
176
|
+
const sc = typeof scope === "string" ? { under: [scope] } : scope;
|
|
177
|
+
const root = claudeProjectsDir(env);
|
|
178
|
+
const dirs = sc.under.flatMap(forms);
|
|
179
|
+
const exact = (sc.exact ?? []).flatMap(forms);
|
|
180
|
+
const slugs = [...dirs, ...exact].map(claudeSlug);
|
|
181
|
+
let projects: string[];
|
|
182
|
+
try {
|
|
183
|
+
projects = fs.readdirSync(root);
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const files: { id: string; file: string; mtimeMs: number }[] = [];
|
|
188
|
+
for (const p of projects) {
|
|
189
|
+
if (!slugs.some((s) => p === s || p.startsWith(`${s}-`))) continue;
|
|
190
|
+
let names: string[];
|
|
191
|
+
try {
|
|
192
|
+
names = fs.readdirSync(path.join(root, p));
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
for (const n of names) {
|
|
197
|
+
if (!n.endsWith(".jsonl")) continue;
|
|
198
|
+
const id = n.slice(0, -".jsonl".length);
|
|
199
|
+
if (!ID_RE.test(id)) continue;
|
|
200
|
+
const file = path.join(root, p, n);
|
|
201
|
+
try {
|
|
202
|
+
const st = fs.statSync(file);
|
|
203
|
+
if (st.isFile() && st.size > 0)
|
|
204
|
+
files.push({ id, file, mtimeMs: st.mtimeMs });
|
|
205
|
+
} catch {
|
|
206
|
+
/* gone */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
211
|
+
const out: RuntimeConversation[] = [];
|
|
212
|
+
const seen = new Set<string>();
|
|
213
|
+
for (const f of files) {
|
|
214
|
+
if (out.length >= MAX_CONVERSATIONS) break;
|
|
215
|
+
if (seen.has(f.id)) continue;
|
|
216
|
+
let s: ReturnType<typeof transcriptSummary>;
|
|
217
|
+
try {
|
|
218
|
+
s = transcriptSummary(f.file);
|
|
219
|
+
} catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const cwd = s.cwd;
|
|
223
|
+
// Filtered before the cap, so others' conversations never crowd out
|
|
224
|
+
// this workspace's.
|
|
225
|
+
if (
|
|
226
|
+
!cwd ||
|
|
227
|
+
!(
|
|
228
|
+
dirs.some((d) => within(cwd, d)) ||
|
|
229
|
+
exact.some((d) => path.resolve(cwd) === d)
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
continue;
|
|
233
|
+
seen.add(f.id);
|
|
234
|
+
out.push({
|
|
235
|
+
id: f.id,
|
|
236
|
+
title: s.title,
|
|
237
|
+
lastAt: new Date(f.mtimeMs).toISOString(),
|
|
238
|
+
cwd,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Whether a conversation that ran in `cwd` is in `scope` (AG-17.1). */
|
|
245
|
+
export function inScope(cwd: string, scope: ConversationScope): boolean {
|
|
246
|
+
return (
|
|
247
|
+
scope.under.flatMap(forms).some((d) => within(cwd, d)) ||
|
|
248
|
+
(scope.exact ?? []).flatMap(forms).some((d) => path.resolve(cwd) === d)
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether a conversation last written at `lastAt` is still open elsewhere. */
|
|
253
|
+
export function openElsewhere(lastAt: string, now = Date.now()): boolean {
|
|
254
|
+
const t = Date.parse(lastAt);
|
|
255
|
+
return Number.isFinite(t) && now - t < OPEN_ELSEWHERE_MS;
|
|
256
|
+
}
|
|
@@ -70,6 +70,7 @@ import { catalogFind } from "./catalog-runtime.js";
|
|
|
70
70
|
import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
|
|
71
71
|
import { claudeProjectsDir } from "../usage/paths.js";
|
|
72
72
|
import { cleanName, transcriptTitle } from "./transcript-follow.js";
|
|
73
|
+
import { claudeConversations, transcriptSummary } from "./conversations.js";
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
76
|
* `/rename "x"` keeps the quotes in customTitle: one matching pair around
|
|
@@ -488,6 +489,24 @@ export const denClaude: Runtime = {
|
|
|
488
489
|
conversationExists(token, env = process.env) {
|
|
489
490
|
return transcriptFile(token, env) !== null;
|
|
490
491
|
},
|
|
492
|
+
// AG-17.1: its transcripts for the folder (conversations.ts).
|
|
493
|
+
listSessions(scope, env = process.env) {
|
|
494
|
+
try {
|
|
495
|
+
return claudeConversations(scope, env);
|
|
496
|
+
} catch {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
// AG-17.1: the folder a conversation ran in (only the ends of its file),
|
|
501
|
+
// so a foreign one is refused by name.
|
|
502
|
+
conversationCwd(token, env = process.env) {
|
|
503
|
+
try {
|
|
504
|
+
const file = transcriptFile(token, env);
|
|
505
|
+
return file ? transcriptSummary(file).cwd : null;
|
|
506
|
+
} catch {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
},
|
|
491
510
|
// AG-4b, unofficial (catalog sessionName): `/rename` appends a
|
|
492
511
|
// {"type":"custom-title","customTitle":…} record to the transcript; the
|
|
493
512
|
// latest one, read on from the last ask (never the whole file), less one
|
|
@@ -42,6 +42,11 @@ export interface AgentRecord {
|
|
|
42
42
|
resumedAt?: string;
|
|
43
43
|
/** The runtime's handle for resume (den-claude: the conversation id). */
|
|
44
44
|
resumeToken?: string;
|
|
45
|
+
/**
|
|
46
|
+
* AG-17.1: brought in from a conversation started outside Den (when);
|
|
47
|
+
* Den's token, hooks and guards apply from then on.
|
|
48
|
+
*/
|
|
49
|
+
broughtInAt?: string;
|
|
45
50
|
/**
|
|
46
51
|
* The runtime was launched on resumeToken at least once (0.4.8). A
|
|
47
52
|
* resume then always tries the runtime's own resume first, never a new
|
|
@@ -161,6 +166,24 @@ export interface AgentUsage {
|
|
|
161
166
|
/** An agent id: the same rules as a workspace or session name. */
|
|
162
167
|
export const AGENT_ID_RE = WORKSPACE_NAME_RE;
|
|
163
168
|
|
|
169
|
+
/**
|
|
170
|
+
* AG-10.10b: the agent id a Name gives, so the name works in the CLI too:
|
|
171
|
+
* lowercase, spaces to "-", only id characters, at most 64. Null when
|
|
172
|
+
* nothing of it is left ("!!!"). The page's nameSlug is the same rule.
|
|
173
|
+
*/
|
|
174
|
+
export function nameSlug(name: string): string | null {
|
|
175
|
+
const s = name
|
|
176
|
+
.trim()
|
|
177
|
+
.toLowerCase()
|
|
178
|
+
.replace(/\s+/g, "-")
|
|
179
|
+
.replace(/[^a-z0-9._-]/g, "")
|
|
180
|
+
.replace(/-{2,}/g, "-")
|
|
181
|
+
.replace(/^[^a-z0-9]+/, "")
|
|
182
|
+
.slice(0, 64)
|
|
183
|
+
.replace(/[-._]+$/, "");
|
|
184
|
+
return s && AGENT_ID_RE.test(s) ? s : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
export function agentsDir(stateDir: string): string {
|
|
165
188
|
return path.join(stateDir, "agents");
|
|
166
189
|
}
|
|
@@ -21,6 +21,7 @@ import type { AgentRecord } from "./records.js";
|
|
|
21
21
|
import type { Persona } from "./personas.js";
|
|
22
22
|
import type { BinSource, RuntimeFind } from "./runtime-search.js";
|
|
23
23
|
import type { PermissionIntent } from "./permissions.js";
|
|
24
|
+
import type { RuntimeConversation } from "./conversations.js";
|
|
24
25
|
|
|
25
26
|
export const RUNTIME_KIND = "runtime";
|
|
26
27
|
|
|
@@ -193,6 +194,19 @@ export interface Runtime {
|
|
|
193
194
|
* just mean it isn't written yet.
|
|
194
195
|
*/
|
|
195
196
|
conversationExists?(token: string, env?: NodeJS.ProcessEnv): boolean;
|
|
197
|
+
/**
|
|
198
|
+
* AG-17.1, bring in a conversation: the runtime's own recent
|
|
199
|
+
* conversations that ran in `cwd` or a folder inside it, newest first,
|
|
200
|
+
* read from its history (only the ends of each file, never a whole
|
|
201
|
+
* transcript). Each id is one `resume` continues. None: this runtime
|
|
202
|
+
* can't bring one in yet. Must never throw.
|
|
203
|
+
*/
|
|
204
|
+
listSessions?(
|
|
205
|
+
scope: string | { under: string[]; exact?: string[] },
|
|
206
|
+
env?: NodeJS.ProcessEnv,
|
|
207
|
+
): RuntimeConversation[];
|
|
208
|
+
/** AG-17.1: the folder conversation `token` ran in, or null. Never throws. */
|
|
209
|
+
conversationCwd?(token: string, env?: NodeJS.ProcessEnv): string | null;
|
|
196
210
|
/**
|
|
197
211
|
* What the runtime prints when it exits at once (0.4.8): `noConversation`
|
|
198
212
|
* when a resume finds nothing to continue, `idInUse` when a new
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claim code (0.4.15, Ward 2026-11-07): how the browser the person
|
|
3
|
+
* opens at the end of the install signs in without a sign-in step, so that
|
|
4
|
+
* approving an agent's changes (which needs a signed-in browser session,
|
|
5
|
+
* even on localhost) works from the first page.
|
|
6
|
+
*
|
|
7
|
+
* - `c1.<nonce>.<expiry>.<mac>`, the mac HMAC-SHA256(web.secret, context +
|
|
8
|
+
* the rest), its context its own: a login code (web-session.ts) is never
|
|
9
|
+
* a claim code, and the other way round.
|
|
10
|
+
* - Valid for 30 minutes: an install hands it over, and the person may take
|
|
11
|
+
* a while to open it. An expiry further out than that is refused.
|
|
12
|
+
* - Single use. Used nonces are kept in `<state dir>/claim-nonces.json`
|
|
13
|
+
* (0600) until they expire, so a server restart can't revive one. The
|
|
14
|
+
* file holds nonces only, never a code. A file that won't parse refuses
|
|
15
|
+
* every claim (fail closed); the sign-in flows still work.
|
|
16
|
+
* - Minted by the CLI (`<cli> web claim-link`), which reads web.secret as
|
|
17
|
+
* the same OS user. The CLI refuses inside an agent's session
|
|
18
|
+
* (claimRefusalForAgent): an agent must never hold one.
|
|
19
|
+
*
|
|
20
|
+
* Limit: an agent running as the same OS user could, in principle, read
|
|
21
|
+
* web.secret or the browser's cookie store from disk. That is the strength
|
|
22
|
+
* of Den's other person-only routes too; only an OS boundary is stronger.
|
|
23
|
+
*/
|
|
24
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
25
|
+
import * as fs from "fs";
|
|
26
|
+
import * as path from "path";
|
|
27
|
+
import { ACTOR_TOKEN_ENV } from "./actor-token.js";
|
|
28
|
+
import { AGENT_ACTOR_ENV } from "./sessions/env.js";
|
|
29
|
+
|
|
30
|
+
export const CLAIM_CODE_TTL_MS = 30 * 60_000;
|
|
31
|
+
const SKEW_MS = 5_000;
|
|
32
|
+
const CONTEXT = "den-claim-v1:";
|
|
33
|
+
export const CLAIM_NONCES_FILE = "claim-nonces.json";
|
|
34
|
+
|
|
35
|
+
function mac(secret: Buffer, body: string): Buffer {
|
|
36
|
+
return createHmac("sha256", secret)
|
|
37
|
+
.update(CONTEXT + body)
|
|
38
|
+
.digest();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A fresh claim code, valid for 30 minutes from `now`. */
|
|
42
|
+
export function mintClaimCode(
|
|
43
|
+
secret: Buffer,
|
|
44
|
+
now: number = Date.now(),
|
|
45
|
+
): string {
|
|
46
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
47
|
+
const body = `c1.${nonce}.${(now + CLAIM_CODE_TTL_MS).toString(36)}`;
|
|
48
|
+
return `${body}.${mac(secret, body).toString("base64url")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ClaimCodeCheck =
|
|
52
|
+
| { ok: true; nonce: string; expiresAt: number }
|
|
53
|
+
| { ok: false; reason: "malformed" | "bad_mac" | "expired" };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether `code` is a claim code this secret minted, still in its 30
|
|
57
|
+
* minutes. Constant-time mac. Single use is ClaimNonces'.
|
|
58
|
+
*/
|
|
59
|
+
export function checkClaimCode(
|
|
60
|
+
code: string,
|
|
61
|
+
secret: Buffer,
|
|
62
|
+
now: number = Date.now(),
|
|
63
|
+
): ClaimCodeCheck {
|
|
64
|
+
const m =
|
|
65
|
+
/^(c1\.([A-Za-z0-9_-]{22})\.([0-9a-z]{1,11}))\.([A-Za-z0-9_-]{43})$/.exec(
|
|
66
|
+
code,
|
|
67
|
+
);
|
|
68
|
+
if (!m) return { ok: false, reason: "malformed" };
|
|
69
|
+
const given = Buffer.from(m[4], "base64url");
|
|
70
|
+
const want = mac(secret, m[1]);
|
|
71
|
+
if (given.length !== want.length || !timingSafeEqual(given, want))
|
|
72
|
+
return { ok: false, reason: "bad_mac" };
|
|
73
|
+
const expiresAt = parseInt(m[3], 36);
|
|
74
|
+
if (
|
|
75
|
+
!Number.isFinite(expiresAt) ||
|
|
76
|
+
now >= expiresAt ||
|
|
77
|
+
expiresAt - now > CLAIM_CODE_TTL_MS + SKEW_MS
|
|
78
|
+
)
|
|
79
|
+
return { ok: false, reason: "expired" };
|
|
80
|
+
return { ok: true, nonce: m[2], expiresAt };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `<origin>/auth?claim=<code>`: the sign-in route, so it is classed as one
|
|
85
|
+
* (a mutating GET: another site can't open it; an agent's token can't).
|
|
86
|
+
*/
|
|
87
|
+
export function claimLink(origin: string, code: string): string {
|
|
88
|
+
return `${origin.replace(/\/+$/, "")}/auth?claim=${encodeURIComponent(code)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Why the CLI won't let a browser in from here (a claim link, a sign-in
|
|
93
|
+
* link, pairing a device, the GitHub owner), or null when it may: inside
|
|
94
|
+
* an agent's session (DEN_AGENT or DEN_ACTOR_TOKEN set) it never does.
|
|
95
|
+
*/
|
|
96
|
+
export function personGrantRefusal(env: NodeJS.ProcessEnv): string | null {
|
|
97
|
+
if (env[AGENT_ACTOR_ENV] !== undefined || env[ACTOR_TOKEN_ENV] !== undefined)
|
|
98
|
+
return "An agent can't do this: it lets a browser in as you. Run it in your own terminal.";
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Why the CLI won't mint a claim code here, or null when it may. */
|
|
103
|
+
export function claimRefusalForAgent(env: NodeJS.ProcessEnv): string | null {
|
|
104
|
+
return personGrantRefusal(env)
|
|
105
|
+
? "An agent can't make a claim link: it signs a browser in as the person. Run it in your own terminal."
|
|
106
|
+
: null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The used claim nonces, kept until they expire (a file, 0600). */
|
|
110
|
+
export class ClaimNonces {
|
|
111
|
+
constructor(private readonly stateDir: string) {}
|
|
112
|
+
|
|
113
|
+
private file(): string {
|
|
114
|
+
return path.join(this.stateDir, CLAIM_NONCES_FILE);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The used nonces; null when the file is there but won't parse (fail closed). */
|
|
118
|
+
private read(): Record<string, number> | null {
|
|
119
|
+
let raw: string;
|
|
120
|
+
try {
|
|
121
|
+
raw = fs.readFileSync(this.file(), "utf-8");
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return (e as NodeJS.ErrnoException).code === "ENOENT" ? {} : null;
|
|
124
|
+
}
|
|
125
|
+
let o: unknown;
|
|
126
|
+
try {
|
|
127
|
+
o = JSON.parse(raw);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (!o || typeof o !== "object" || Array.isArray(o)) return null;
|
|
132
|
+
const out: Record<string, number> = {};
|
|
133
|
+
for (const [k, v] of Object.entries(o as Record<string, unknown>))
|
|
134
|
+
if (/^[A-Za-z0-9_-]{22}$/.test(k) && typeof v === "number") out[k] = v;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private write(used: Record<string, number>): void {
|
|
139
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
140
|
+
const tmp = `${this.file()}.${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
141
|
+
fs.writeFileSync(tmp, JSON.stringify(used) + "\n", { mode: 0o600 });
|
|
142
|
+
fs.chmodSync(tmp, 0o600);
|
|
143
|
+
fs.renameSync(tmp, this.file());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Use `nonce` (its code expires at `expiresAt`): true the first time,
|
|
148
|
+
* false once used. Expired entries are pruned on the way.
|
|
149
|
+
*/
|
|
150
|
+
consume(nonce: string, expiresAt: number, now: number = Date.now()): boolean {
|
|
151
|
+
const used = this.read();
|
|
152
|
+
// Unreadable: refuse every claim rather than forget which were used.
|
|
153
|
+
if (!used) return false;
|
|
154
|
+
for (const [k, exp] of Object.entries(used)) if (exp <= now) delete used[k];
|
|
155
|
+
if (nonce in used) {
|
|
156
|
+
this.write(used);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
used[nonce] = expiresAt;
|
|
160
|
+
this.write(used);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -143,6 +143,37 @@ export function sshTarget(
|
|
|
143
143
|
return /^[a-z0-9._:-]+$/.test(h) && !h.startsWith("-") ? h : fallback;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** An account name ssh can take before `@`: letters, digits, `.`, `_`, `-`. */
|
|
147
|
+
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `web.ssh_user` as stored (SEC-2a-UX): the account the signed-out page's
|
|
151
|
+
* ssh link and command log in as. undefined when unset (the account Den
|
|
152
|
+
* runs as); "" leaves the account off. Throws naming the key otherwise.
|
|
153
|
+
*/
|
|
154
|
+
export function parseSshUser(v: unknown): string | undefined {
|
|
155
|
+
if (v === undefined || v === null) return undefined;
|
|
156
|
+
if (typeof v !== "string" || (v.trim() !== "" && !SSH_USER_RE.test(v.trim())))
|
|
157
|
+
throw new Error(
|
|
158
|
+
`web.ssh_user must be an account name (letters, digits, ".", "_", "-"), or "" to leave it off; got ${JSON.stringify(v)}`,
|
|
159
|
+
);
|
|
160
|
+
return v.trim();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The account for the ssh link: `setting` when set ("" for none), else
|
|
165
|
+
* `osUser` when ssh can take it, else none.
|
|
166
|
+
*/
|
|
167
|
+
export function sshUser(setting: string | undefined, osUser: string): string {
|
|
168
|
+
if (setting !== undefined) return setting;
|
|
169
|
+
return SSH_USER_RE.test(osUser) ? osUser : "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `user@target`, or the bare target when there's no user. */
|
|
173
|
+
export function sshLogin(target: string, user: string): string {
|
|
174
|
+
return user ? `${user}@${target}` : target;
|
|
175
|
+
}
|
|
176
|
+
|
|
146
177
|
/** `Mac-Studio.localdomain` → `Mac-Studio`; an IP literal stays whole. */
|
|
147
178
|
export function shortHostname(hostname: string): string {
|
|
148
179
|
const h = hostname.trim();
|
package/src/shared/den-config.ts
CHANGED
|
@@ -818,6 +818,8 @@ export const MCP_ACTIONS = [
|
|
|
818
818
|
"run_tests",
|
|
819
819
|
// AG-14.4: resume another agent of its workspace (or a granted one).
|
|
820
820
|
"agent_resume",
|
|
821
|
+
// AG-17.1: bring in a conversation started outside Den, as an agent.
|
|
822
|
+
"agent_import",
|
|
821
823
|
] as const;
|
|
822
824
|
export type McpAction = (typeof MCP_ACTIONS)[number];
|
|
823
825
|
|
|
@@ -829,6 +831,11 @@ export interface McpConfig {
|
|
|
829
831
|
run_tests?: { envs?: string[] };
|
|
830
832
|
/** `remote: true` lets the log tools read non-local sources (e.g. CloudWatch). */
|
|
831
833
|
logs?: { remote?: boolean };
|
|
834
|
+
/**
|
|
835
|
+
* The held writes (workspace_create, repo_add, service_try): on by
|
|
836
|
+
* default, each run only after the person's Allow. false hides them.
|
|
837
|
+
*/
|
|
838
|
+
approvals?: boolean;
|
|
832
839
|
}
|
|
833
840
|
|
|
834
841
|
/** `mcp:` with its defaults applied. */
|
|
@@ -836,6 +843,8 @@ export interface ResolvedMcpConfig {
|
|
|
836
843
|
actions: McpAction[];
|
|
837
844
|
runTestsEnvs: Environment[];
|
|
838
845
|
logsRemote: boolean;
|
|
846
|
+
/** Whether the held writes are registered (default true). */
|
|
847
|
+
approvals: boolean;
|
|
839
848
|
}
|
|
840
849
|
|
|
841
850
|
/**
|
|
@@ -863,10 +872,15 @@ export function readMcpConfig(
|
|
|
863
872
|
return e as Environment;
|
|
864
873
|
},
|
|
865
874
|
);
|
|
875
|
+
if (raw.approvals !== undefined && typeof raw.approvals !== "boolean")
|
|
876
|
+
throw new Error(
|
|
877
|
+
`mcp.approvals: true or false, not ${JSON.stringify(raw.approvals)}`,
|
|
878
|
+
);
|
|
866
879
|
return {
|
|
867
880
|
actions: [...new Set(actions)],
|
|
868
881
|
runTestsEnvs: [...new Set(runTestsEnvs)],
|
|
869
882
|
logsRemote: raw.logs?.remote === true,
|
|
883
|
+
approvals: raw.approvals !== false,
|
|
870
884
|
};
|
|
871
885
|
}
|
|
872
886
|
|
|
@@ -2794,18 +2794,13 @@ export function runServiceCommand(
|
|
|
2794
2794
|
// later down signalled the dead group while the real listener ran on).
|
|
2795
2795
|
// A failed start keeps no record at all.
|
|
2796
2796
|
const serviceExited = (exitCode: number): void => {
|
|
2797
|
-
const rec = recorded;
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
if (now?.pgid === rec.pgid && exitCode !== 0) {
|
|
2805
|
-
if (!(now.containers?.length ?? 0)) unregisterDaemon(resolved.address);
|
|
2806
|
-
else clearDaemonProcessGroup(resolved.address);
|
|
2807
|
-
}
|
|
2808
|
-
}
|
|
2797
|
+
const rec: RecordedProcessGroup | null = recorded;
|
|
2798
|
+
const state = rec ? (processGroupStateSync(rec)?.state ?? null) : null;
|
|
2799
|
+
const now = findDaemonByAddress(resolved.address);
|
|
2800
|
+
const action = failedStartCleanup(exitCode, rec, state, now);
|
|
2801
|
+
if (action === "unregister") unregisterDaemon(resolved.address);
|
|
2802
|
+
else if (action === "clear-group")
|
|
2803
|
+
clearDaemonProcessGroup(resolved.address);
|
|
2809
2804
|
onServiceExit?.(exitCode);
|
|
2810
2805
|
};
|
|
2811
2806
|
const proc = recordGroup
|
|
@@ -2835,6 +2830,30 @@ export function runServiceCommand(
|
|
|
2835
2830
|
return { proc, detachable, interactive: false, logPath };
|
|
2836
2831
|
}
|
|
2837
2832
|
|
|
2833
|
+
/**
|
|
2834
|
+
* What a detached up does with its record when the service process exits.
|
|
2835
|
+
* A non-zero exit is a failed start: nothing of it runs, so no record (its
|
|
2836
|
+
* containers, if any, still run: then only the group goes). A zero exit
|
|
2837
|
+
* handed off (`make up`): its group stays, and down says it already exited.
|
|
2838
|
+
* The record goes only if it is this run's: its group is the one this run
|
|
2839
|
+
* recorded and that group is gone, or this run could not record a group
|
|
2840
|
+
* (the command exited, a zombie, before `ps` saw it) and the record names
|
|
2841
|
+
* none.
|
|
2842
|
+
*/
|
|
2843
|
+
export function failedStartCleanup(
|
|
2844
|
+
exitCode: number,
|
|
2845
|
+
rec: RecordedProcessGroup | null,
|
|
2846
|
+
groupState: string | null,
|
|
2847
|
+
now: { pgid?: number; containers?: unknown[] } | undefined,
|
|
2848
|
+
): "unregister" | "clear-group" | "keep" {
|
|
2849
|
+
if (exitCode === 0 || !now) return "keep";
|
|
2850
|
+
const ours = rec
|
|
2851
|
+
? groupState === "gone" && now.pgid === rec.pgid
|
|
2852
|
+
: now.pgid === undefined;
|
|
2853
|
+
if (!ours) return "keep";
|
|
2854
|
+
return (now.containers?.length ?? 0) ? "clear-group" : "unregister";
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2838
2857
|
// ── Health checks ─────────────────────────────────────────────────────────────
|
|
2839
2858
|
// (implementation lives in health.ts — re-exported above)
|
|
2840
2859
|
|