@monster-orange/den 0.4.14 → 0.4.16
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 +194 -0
- package/README.md +7 -7
- package/docs/distributions.md +226 -19
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +155 -8
- package/src/shared/agents/den-claude.ts +25 -3
- package/src/shared/agents/records.ts +37 -0
- package/src/shared/agents/runtime.ts +11 -1
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +12 -0
- package/src/shared/environment-manager/workspaces.ts +20 -0
- package/src/shared/github-app.ts +7 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +372 -23
- package/src/ui/cli/agents-command.ts +71 -4
- 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 +14 -2
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +368 -1
- package/src/ui/server/agent-routes.ts +91 -7
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +13 -5
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +308 -21
- 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 +378 -30
- package/src/ui/server/public/agents.html +1 -0
- package/src/ui/server/public/error-page.js +97 -18
- package/src/ui/server/public/error.html +18 -0
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +65 -5
- package/src/ui/server/public/i18n/ja.js +67 -5
- 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 +32 -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 +348 -5
- package/src/ui/server/request-guard.ts +2 -0
- package/src/ui/server/route-classes.ts +34 -1
- package/src/ui/server/session-routes.ts +14 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- package/themes/den/den-theme.json +6 -6
|
@@ -32,6 +32,16 @@ export interface RuntimeConversation {
|
|
|
32
32
|
lastAt: string;
|
|
33
33
|
/** The folder it ran in. */
|
|
34
34
|
cwd: string;
|
|
35
|
+
/**
|
|
36
|
+
* AG-17.1c: the model its last answer came from (the last assistant
|
|
37
|
+
* record's `message.model`), so Bring in keeps it. Absent when unknown.
|
|
38
|
+
*/
|
|
39
|
+
model?: string;
|
|
40
|
+
/**
|
|
41
|
+
* AG-17.1c: the MCP servers its tools came from (`mcp__<server>__<tool>`
|
|
42
|
+
* tool calls in the ends read), sorted. Absent when none.
|
|
43
|
+
*/
|
|
44
|
+
mcp?: string[];
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/** Bytes read at each end of a transcript. */
|
|
@@ -114,22 +124,63 @@ function promptText(rec: Record<string, unknown>): string | null {
|
|
|
114
124
|
return cleanName(text);
|
|
115
125
|
}
|
|
116
126
|
|
|
117
|
-
/** A transcript
|
|
127
|
+
/** A model name as a transcript records it: short, printable, not synthetic. */
|
|
128
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
129
|
+
/** An MCP tool's name: `mcp__<server>__<tool>`. */
|
|
130
|
+
const MCP_TOOL_RE = /^mcp__([A-Za-z0-9_.-]+?)__[A-Za-z0-9_.-]+$/;
|
|
131
|
+
|
|
132
|
+
/** An assistant record's tool calls' names. */
|
|
133
|
+
function toolNames(rec: Record<string, unknown>): string[] {
|
|
134
|
+
const content = (rec.message as { content?: unknown } | undefined)?.content;
|
|
135
|
+
if (!Array.isArray(content)) return [];
|
|
136
|
+
return content.flatMap((c) => {
|
|
137
|
+
const b = c as { type?: unknown; name?: unknown };
|
|
138
|
+
return b?.type === "tool_use" && typeof b.name === "string" ? [b.name] : [];
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A transcript's summary from its two ends (AG-17.1, AG-17.1c). */
|
|
143
|
+
export interface TranscriptSummary {
|
|
144
|
+
cwd: string | null;
|
|
145
|
+
title: string | null;
|
|
146
|
+
/** The last assistant record's model, or null. */
|
|
147
|
+
model: string | null;
|
|
148
|
+
/** The MCP servers its tool calls name, sorted. */
|
|
149
|
+
mcp: string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A transcript's cwd, title, last model and MCP servers, from its two ends. */
|
|
118
153
|
export function transcriptSummary(
|
|
119
154
|
file: string,
|
|
120
155
|
bytes = ENDS_BYTES,
|
|
121
|
-
):
|
|
156
|
+
): TranscriptSummary {
|
|
122
157
|
const { head, tail } = readEnds(file, bytes);
|
|
123
158
|
let cwd: string | null = null;
|
|
124
159
|
let prompt: string | null = null;
|
|
125
160
|
let title: string | null = null;
|
|
161
|
+
let model: string | null = null;
|
|
162
|
+
const mcp = new Set<string>();
|
|
126
163
|
const titleIn = (line: string) => {
|
|
127
164
|
if (!line.includes("custom-title")) return;
|
|
128
165
|
const r = parse(line);
|
|
129
166
|
if (r?.type === "custom-title") title = cleanName(r.customTitle) ?? title;
|
|
130
167
|
};
|
|
168
|
+
// AG-17.1c: the last answer's model, and the MCP servers its tools came from.
|
|
169
|
+
const assistantIn = (line: string) => {
|
|
170
|
+
if (!line.includes('"assistant"')) return;
|
|
171
|
+
const r = parse(line);
|
|
172
|
+
if (r?.type !== "assistant") return;
|
|
173
|
+
const m = (r.message as { model?: unknown } | undefined)?.model;
|
|
174
|
+
if (typeof m === "string" && MODEL_RE.test(m) && m !== "<synthetic>")
|
|
175
|
+
model = m;
|
|
176
|
+
for (const n of toolNames(r)) {
|
|
177
|
+
const hit = MCP_TOOL_RE.exec(n);
|
|
178
|
+
if (hit) mcp.add(hit[1]);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
131
181
|
for (const line of head) {
|
|
132
182
|
titleIn(line);
|
|
183
|
+
assistantIn(line);
|
|
133
184
|
if (cwd && prompt) continue;
|
|
134
185
|
if (!line.includes('"cwd"') && !line.includes('"user"')) continue;
|
|
135
186
|
const r = parse(line);
|
|
@@ -138,8 +189,73 @@ export function transcriptSummary(
|
|
|
138
189
|
cwd = r.cwd;
|
|
139
190
|
prompt ??= promptText(r);
|
|
140
191
|
}
|
|
141
|
-
for (const line of tail)
|
|
142
|
-
|
|
192
|
+
for (const line of tail) {
|
|
193
|
+
titleIn(line);
|
|
194
|
+
assistantIn(line);
|
|
195
|
+
}
|
|
196
|
+
return { cwd, title: title ?? prompt, model, mcp: [...mcp].sort() };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Written this recently, a turn whose last record is an answer is still going. */
|
|
200
|
+
export const TURN_QUIET_MS = 5_000;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* AG-18: whether a conversation's turn is still going, from its tail: the
|
|
204
|
+
* last user or assistant record. An answer that ended (`end_turn`, a stop
|
|
205
|
+
* sequence, the token limit) is idle; a tool call, or a prompt or tool
|
|
206
|
+
* result with no answer yet, is busy; an answer with no stop reason is busy
|
|
207
|
+
* until the file has been quiet TURN_QUIET_MS. An interrupted turn is idle.
|
|
208
|
+
* No transcript: idle (nothing was asked yet).
|
|
209
|
+
*/
|
|
210
|
+
export function transcriptBusy(
|
|
211
|
+
file: string | null,
|
|
212
|
+
now = Date.now(),
|
|
213
|
+
bytes = ENDS_BYTES,
|
|
214
|
+
): boolean {
|
|
215
|
+
if (!file) return false;
|
|
216
|
+
let mtime: number;
|
|
217
|
+
try {
|
|
218
|
+
mtime = fs.statSync(file).mtimeMs;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
const { head, tail } = readEnds(file, bytes);
|
|
223
|
+
const lines = tail.length ? [...head, ...tail] : head;
|
|
224
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
225
|
+
const line = lines[i];
|
|
226
|
+
if (!line.includes('"user"') && !line.includes('"assistant"')) continue;
|
|
227
|
+
const r = parse(line);
|
|
228
|
+
if (!r || r.isSidechain === true || r.isMeta === true) continue;
|
|
229
|
+
if (r.type === "assistant") {
|
|
230
|
+
const msg = r.message as { stop_reason?: unknown } | undefined;
|
|
231
|
+
const stop = msg?.stop_reason;
|
|
232
|
+
if (stop === "tool_use" || toolNames(r).length) return true;
|
|
233
|
+
if (
|
|
234
|
+
stop === "end_turn" ||
|
|
235
|
+
stop === "stop_sequence" ||
|
|
236
|
+
stop === "max_tokens"
|
|
237
|
+
)
|
|
238
|
+
return false;
|
|
239
|
+
return now - mtime < TURN_QUIET_MS;
|
|
240
|
+
}
|
|
241
|
+
if (r.type === "user") {
|
|
242
|
+
const content = (r.message as { content?: unknown } | undefined)?.content;
|
|
243
|
+
const text =
|
|
244
|
+
typeof content === "string"
|
|
245
|
+
? content
|
|
246
|
+
: Array.isArray(content)
|
|
247
|
+
? ((
|
|
248
|
+
content.find(
|
|
249
|
+
(c) => (c as { type?: unknown })?.type === "text",
|
|
250
|
+
) as { text?: unknown } | undefined
|
|
251
|
+
)?.text ?? "")
|
|
252
|
+
: "";
|
|
253
|
+
if (typeof text === "string" && text.startsWith("[Request interrupted"))
|
|
254
|
+
return false;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
143
259
|
}
|
|
144
260
|
|
|
145
261
|
/** A folder's absolute forms: as given and its real path. */
|
|
@@ -159,13 +275,25 @@ function forms(dir: string): string[] {
|
|
|
159
275
|
* newest first, at most MAX_CONVERSATIONS. Never throws: a history it
|
|
160
276
|
* can't read is no conversations.
|
|
161
277
|
*/
|
|
278
|
+
/**
|
|
279
|
+
* Where a workspace's conversations ran (AG-17.1): in or under one of
|
|
280
|
+
* `under`, or exactly in one of `exact` (a shared repos root: its other
|
|
281
|
+
* subfolders are other workspaces' or no repo's).
|
|
282
|
+
*/
|
|
283
|
+
export interface ConversationScope {
|
|
284
|
+
under: string[];
|
|
285
|
+
exact?: string[];
|
|
286
|
+
}
|
|
287
|
+
|
|
162
288
|
export function claudeConversations(
|
|
163
|
-
|
|
289
|
+
scope: string | ConversationScope,
|
|
164
290
|
env: NodeJS.ProcessEnv = process.env,
|
|
165
291
|
): RuntimeConversation[] {
|
|
292
|
+
const sc = typeof scope === "string" ? { under: [scope] } : scope;
|
|
166
293
|
const root = claudeProjectsDir(env);
|
|
167
|
-
const dirs = forms
|
|
168
|
-
const
|
|
294
|
+
const dirs = sc.under.flatMap(forms);
|
|
295
|
+
const exact = (sc.exact ?? []).flatMap(forms);
|
|
296
|
+
const slugs = [...dirs, ...exact].map(claudeSlug);
|
|
169
297
|
let projects: string[];
|
|
170
298
|
try {
|
|
171
299
|
projects = fs.readdirSync(root);
|
|
@@ -208,18 +336,37 @@ export function claudeConversations(
|
|
|
208
336
|
continue;
|
|
209
337
|
}
|
|
210
338
|
const cwd = s.cwd;
|
|
211
|
-
|
|
339
|
+
// Filtered before the cap, so others' conversations never crowd out
|
|
340
|
+
// this workspace's.
|
|
341
|
+
if (
|
|
342
|
+
!cwd ||
|
|
343
|
+
!(
|
|
344
|
+
dirs.some((d) => within(cwd, d)) ||
|
|
345
|
+
exact.some((d) => path.resolve(cwd) === d)
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
continue;
|
|
212
349
|
seen.add(f.id);
|
|
213
350
|
out.push({
|
|
214
351
|
id: f.id,
|
|
215
352
|
title: s.title,
|
|
216
353
|
lastAt: new Date(f.mtimeMs).toISOString(),
|
|
217
354
|
cwd,
|
|
355
|
+
...(s.model ? { model: s.model } : {}),
|
|
356
|
+
...(s.mcp.length ? { mcp: s.mcp } : {}),
|
|
218
357
|
});
|
|
219
358
|
}
|
|
220
359
|
return out;
|
|
221
360
|
}
|
|
222
361
|
|
|
362
|
+
/** Whether a conversation that ran in `cwd` is in `scope` (AG-17.1). */
|
|
363
|
+
export function inScope(cwd: string, scope: ConversationScope): boolean {
|
|
364
|
+
return (
|
|
365
|
+
scope.under.flatMap(forms).some((d) => within(cwd, d)) ||
|
|
366
|
+
(scope.exact ?? []).flatMap(forms).some((d) => path.resolve(cwd) === d)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
223
370
|
/** Whether a conversation last written at `lastAt` is still open elsewhere. */
|
|
224
371
|
export function openElsewhere(lastAt: string, now = Date.now()): boolean {
|
|
225
372
|
const t = Date.parse(lastAt);
|
|
@@ -70,7 +70,11 @@ 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 {
|
|
73
|
+
import {
|
|
74
|
+
claudeConversations,
|
|
75
|
+
transcriptBusy,
|
|
76
|
+
transcriptSummary,
|
|
77
|
+
} from "./conversations.js";
|
|
74
78
|
|
|
75
79
|
/**
|
|
76
80
|
* `/rename "x"` keeps the quotes in customTitle: one matching pair around
|
|
@@ -490,13 +494,31 @@ export const denClaude: Runtime = {
|
|
|
490
494
|
return transcriptFile(token, env) !== null;
|
|
491
495
|
},
|
|
492
496
|
// AG-17.1: its transcripts for the folder (conversations.ts).
|
|
493
|
-
listSessions(
|
|
497
|
+
listSessions(scope, env = process.env) {
|
|
494
498
|
try {
|
|
495
|
-
return claudeConversations(
|
|
499
|
+
return claudeConversations(scope, env);
|
|
496
500
|
} catch {
|
|
497
501
|
return [];
|
|
498
502
|
}
|
|
499
503
|
},
|
|
504
|
+
// AG-17.1: the folder a conversation ran in (only the ends of its file),
|
|
505
|
+
// so a foreign one is refused by name.
|
|
506
|
+
conversationCwd(token, env = process.env) {
|
|
507
|
+
try {
|
|
508
|
+
const file = transcriptFile(token, env);
|
|
509
|
+
return file ? transcriptSummary(file).cwd : null;
|
|
510
|
+
} catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
// AG-18: mid-turn, from its transcript's tail (never the whole file).
|
|
515
|
+
turnBusy(token, env = process.env) {
|
|
516
|
+
try {
|
|
517
|
+
return transcriptBusy(transcriptFile(token, env));
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
},
|
|
500
522
|
// AG-4b, unofficial (catalog sessionName): `/rename` appends a
|
|
501
523
|
// {"type":"custom-title","customTitle":…} record to the transcript; the
|
|
502
524
|
// latest one, read on from the last ask (never the whole file), less one
|
|
@@ -103,6 +103,25 @@ export interface AgentRecord {
|
|
|
103
103
|
* the list tags it. Start fresh and Relaunch keep the choices.
|
|
104
104
|
*/
|
|
105
105
|
overrides?: Override[];
|
|
106
|
+
/**
|
|
107
|
+
* AG-18: each persona change, oldest first: from, to, when, and who (a
|
|
108
|
+
* person's sign-in, or "cli").
|
|
109
|
+
*/
|
|
110
|
+
personaChanges?: PersonaChange[];
|
|
111
|
+
/**
|
|
112
|
+
* AG-18: a persona change waiting for this agent's turn to end. The wait
|
|
113
|
+
* lives in the server's memory, so a restart drops it; the upkeep at
|
|
114
|
+
* start clears this and audits the cancel (cancelStalePersonaChanges).
|
|
115
|
+
*/
|
|
116
|
+
pendingPersona?: { to: string; since: string; by?: string };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One persona change of an agent (AG-18). */
|
|
120
|
+
export interface PersonaChange {
|
|
121
|
+
from: string;
|
|
122
|
+
to: string;
|
|
123
|
+
at: string;
|
|
124
|
+
by?: string;
|
|
106
125
|
}
|
|
107
126
|
|
|
108
127
|
/** A setting a session can override for one launch (AG-10.9). */
|
|
@@ -166,6 +185,24 @@ export interface AgentUsage {
|
|
|
166
185
|
/** An agent id: the same rules as a workspace or session name. */
|
|
167
186
|
export const AGENT_ID_RE = WORKSPACE_NAME_RE;
|
|
168
187
|
|
|
188
|
+
/**
|
|
189
|
+
* AG-10.10b: the agent id a Name gives, so the name works in the CLI too:
|
|
190
|
+
* lowercase, spaces to "-", only id characters, at most 64. Null when
|
|
191
|
+
* nothing of it is left ("!!!"). The page's nameSlug is the same rule.
|
|
192
|
+
*/
|
|
193
|
+
export function nameSlug(name: string): string | null {
|
|
194
|
+
const s = name
|
|
195
|
+
.trim()
|
|
196
|
+
.toLowerCase()
|
|
197
|
+
.replace(/\s+/g, "-")
|
|
198
|
+
.replace(/[^a-z0-9._-]/g, "")
|
|
199
|
+
.replace(/-{2,}/g, "-")
|
|
200
|
+
.replace(/^[^a-z0-9]+/, "")
|
|
201
|
+
.slice(0, 64)
|
|
202
|
+
.replace(/[-._]+$/, "");
|
|
203
|
+
return s && AGENT_ID_RE.test(s) ? s : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
169
206
|
export function agentsDir(stateDir: string): string {
|
|
170
207
|
return path.join(stateDir, "agents");
|
|
171
208
|
}
|
|
@@ -201,7 +201,17 @@ export interface Runtime {
|
|
|
201
201
|
* transcript). Each id is one `resume` continues. None: this runtime
|
|
202
202
|
* can't bring one in yet. Must never throw.
|
|
203
203
|
*/
|
|
204
|
-
listSessions?(
|
|
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;
|
|
210
|
+
/**
|
|
211
|
+
* AG-18: whether conversation `token` is mid-turn (den-claude: its
|
|
212
|
+
* transcript's tail). None: the runtime can't tell. Never throws.
|
|
213
|
+
*/
|
|
214
|
+
turnBusy?(token: string, env?: NodeJS.ProcessEnv): boolean;
|
|
205
215
|
/**
|
|
206
216
|
* What the runtime prints when it exits at once (0.4.8): `noConversation`
|
|
207
217
|
* 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
|
@@ -831,6 +831,11 @@ export interface McpConfig {
|
|
|
831
831
|
run_tests?: { envs?: string[] };
|
|
832
832
|
/** `remote: true` lets the log tools read non-local sources (e.g. CloudWatch). */
|
|
833
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;
|
|
834
839
|
}
|
|
835
840
|
|
|
836
841
|
/** `mcp:` with its defaults applied. */
|
|
@@ -838,6 +843,8 @@ export interface ResolvedMcpConfig {
|
|
|
838
843
|
actions: McpAction[];
|
|
839
844
|
runTestsEnvs: Environment[];
|
|
840
845
|
logsRemote: boolean;
|
|
846
|
+
/** Whether the held writes are registered (default true). */
|
|
847
|
+
approvals: boolean;
|
|
841
848
|
}
|
|
842
849
|
|
|
843
850
|
/**
|
|
@@ -865,10 +872,15 @@ export function readMcpConfig(
|
|
|
865
872
|
return e as Environment;
|
|
866
873
|
},
|
|
867
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
|
+
);
|
|
868
879
|
return {
|
|
869
880
|
actions: [...new Set(actions)],
|
|
870
881
|
runTestsEnvs: [...new Set(runTestsEnvs)],
|
|
871
882
|
logsRemote: raw.logs?.remote === true,
|
|
883
|
+
approvals: raw.approvals !== false,
|
|
872
884
|
};
|
|
873
885
|
}
|
|
874
886
|
|
|
@@ -355,6 +355,26 @@ export function repoFolders(cfg: WorkspaceConfig | undefined): string[] {
|
|
|
355
355
|
.filter((p): p is string => typeof p === "string" && path.isAbsolute(p));
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Where Bring in looks for a workspace's conversations (AG-17.1). Its own
|
|
360
|
+
* folder, and everything in it; each by-path repo's folder, outside it
|
|
361
|
+
* too. When its folder is only the shared repos root (no folder of its
|
|
362
|
+
* own), the root's other subfolders are other workspaces' repos or no
|
|
363
|
+
* repo at all: only its repos' folders count, and the root itself only
|
|
364
|
+
* exactly.
|
|
365
|
+
*/
|
|
366
|
+
export function conversationFolders(
|
|
367
|
+
name: string,
|
|
368
|
+
cfg: WorkspaceConfig,
|
|
369
|
+
reposRoot: string,
|
|
370
|
+
): { under: string[]; exact: string[] } {
|
|
371
|
+
const dir = resolveWorkspaceDir(name, cfg, reposRoot);
|
|
372
|
+
const repos = repoFolders(cfg);
|
|
373
|
+
if (path.resolve(dir) === path.resolve(reposRoot))
|
|
374
|
+
return { under: repos, exact: [dir] };
|
|
375
|
+
return { under: [dir, ...repos], exact: [] };
|
|
376
|
+
}
|
|
377
|
+
|
|
358
378
|
export function getWorkspaceByName(name: string): {
|
|
359
379
|
name: string;
|
|
360
380
|
config: WorkspaceConfig;
|
package/src/shared/github-app.ts
CHANGED
|
@@ -83,6 +83,13 @@ export function githubClientId(configured: string | undefined): string {
|
|
|
83
83
|
return (configured ?? MO_DEN_GITHUB_CLIENT_ID).trim();
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The docs section the signed-out page's Learn more opens (SEC-2a-UX):
|
|
88
|
+
* docs/distributions.md, "Signing in with GitHub", in the public repo.
|
|
89
|
+
*/
|
|
90
|
+
export const GITHUB_SIGNIN_DOCS =
|
|
91
|
+
"https://github.com/Monster-Orange/den/blob/main/docs/distributions.md#signing-in-with-github";
|
|
92
|
+
|
|
86
93
|
/** What a GitHub button says while no client id is configured. */
|
|
87
94
|
export function noClientIdMessage(cli: string): string {
|
|
88
95
|
return (
|