@monster-orange/den 0.4.14 → 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 +127 -0
- package/README.md +7 -7
- package/docs/distributions.md +144 -8
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +33 -4
- package/src/shared/agents/den-claude.ts +13 -3
- package/src/shared/agents/records.ts +18 -0
- package/src/shared/agents/runtime.ts +6 -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/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +75 -22
- package/src/ui/cli/agents-command.ts +4 -1
- 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 +2 -5
- 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 +91 -4
- 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 +29 -0
- package/src/ui/server/public/i18n/ja.js +31 -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 +21 -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 +313 -0
- package/src/ui/server/request-guard.ts +2 -0
- package/src/ui/server/route-classes.ts +28 -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,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;
|
|
@@ -21,7 +21,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
|
21
21
|
import * as fs from "fs";
|
|
22
22
|
import * as path from "path";
|
|
23
23
|
import * as yaml from "yaml";
|
|
24
|
-
import { parseMachineName } from "./cli-command.js";
|
|
24
|
+
import { parseMachineName, parseSshUser } from "./cli-command.js";
|
|
25
25
|
import { getStateDir } from "./paths.js";
|
|
26
26
|
|
|
27
27
|
export const LOGIN_CODE_TTL_MS = 60_000;
|
|
@@ -195,6 +195,12 @@ export interface WebAuthConfig {
|
|
|
195
195
|
* page gives this machine ("runs on <name>"). Unset: the short hostname.
|
|
196
196
|
*/
|
|
197
197
|
machineName?: string;
|
|
198
|
+
/**
|
|
199
|
+
* `web.ssh_user` (SEC-2a-UX; user.yml, else the distribution's): the
|
|
200
|
+
* account the signed-out page's ssh link logs in as. Unset: the account
|
|
201
|
+
* Den runs as. "": no account on the page.
|
|
202
|
+
*/
|
|
203
|
+
sshUser?: string;
|
|
198
204
|
}
|
|
199
205
|
|
|
200
206
|
/** `web.edge_unattested`'s values. */
|
|
@@ -290,8 +296,13 @@ export function readWebAuthConfig(
|
|
|
290
296
|
const clientId =
|
|
291
297
|
clientIdOf(user, "config/local/user.yml") ?? clientIdOf(team, configFile);
|
|
292
298
|
const machineName = parseMachineName(user.machine_name);
|
|
299
|
+
const sshUser =
|
|
300
|
+
user.ssh_user !== undefined
|
|
301
|
+
? parseSshUser(user.ssh_user)
|
|
302
|
+
: parseSshUser(team.ssh_user);
|
|
293
303
|
return {
|
|
294
304
|
...(machineName ? { machineName } : {}),
|
|
305
|
+
...(sshUser !== undefined ? { sshUser } : {}),
|
|
295
306
|
allowPlainHttpRemote: plain === true,
|
|
296
307
|
ignored,
|
|
297
308
|
edgeUnattested: unattested ?? "signin",
|
|
@@ -319,6 +330,43 @@ function clientIdOf(
|
|
|
319
330
|
return v.trim();
|
|
320
331
|
}
|
|
321
332
|
|
|
333
|
+
// ── The approval key (0.4.15) ─────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Why a cookie alone can't approve an agent's change: browsers send a
|
|
337
|
+
* localhost cookie to every localhost port (cookies ignore ports), so a
|
|
338
|
+
* dev server an agent runs on another port receives Den's session cookie,
|
|
339
|
+
* and GET /api/session gives any cookie holder the session's CSRF token.
|
|
340
|
+
* The approval key is a second secret only the signed-in page holds: it
|
|
341
|
+
* comes in the sign-in's own answer (a 303's URL fragment, which never
|
|
342
|
+
* reaches a server, or the sign-in poll's JSON), the page keeps it in its
|
|
343
|
+
* localStorage (per origin, port included), and sends it as
|
|
344
|
+
* X-Den-Approve-Key.
|
|
345
|
+
*/
|
|
346
|
+
export const APPROVE_KEY_HEADER = "x-den-approve-key";
|
|
347
|
+
/** The fragment parameter a sign-in's 303 carries it in. */
|
|
348
|
+
export const APPROVE_KEY_FRAGMENT = "ak";
|
|
349
|
+
|
|
350
|
+
export function approveKeyHash(key: string): string {
|
|
351
|
+
return createHash("sha256").update(`den-approve-v1:${key}`).digest("hex");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Whether `given` is the key whose hash the session keeps (constant time). */
|
|
355
|
+
export function approveKeyMatches(
|
|
356
|
+
storedHash: string | undefined,
|
|
357
|
+
given: string | undefined | null,
|
|
358
|
+
): boolean {
|
|
359
|
+
if (!storedHash || !given || !/^[A-Za-z0-9_-]{43}$/.test(given)) return false;
|
|
360
|
+
const a = Buffer.from(approveKeyHash(given), "hex");
|
|
361
|
+
const b = Buffer.from(storedHash, "hex");
|
|
362
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** `next` with the approval key in its fragment (a sign-in's 303). */
|
|
366
|
+
export function withApproveKey(next: string, key: string): string {
|
|
367
|
+
return `${next.split("#")[0]}#${APPROVE_KEY_FRAGMENT}=${key}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
322
370
|
// ── The session store ──────────────────────────────────────────────────────
|
|
323
371
|
|
|
324
372
|
export interface SessionRecord {
|
|
@@ -329,14 +377,21 @@ export interface SessionRecord {
|
|
|
329
377
|
csrf: string;
|
|
330
378
|
/** The Host it was issued on. */
|
|
331
379
|
host: string;
|
|
332
|
-
/** SEC-2a: how it signed in: a GitHub sign-in,
|
|
333
|
-
via?: "github" | "link" | "code";
|
|
380
|
+
/** SEC-2a: how it signed in: a GitHub sign-in, a one-time link, a code (pairing), or the install's setup link (claim, 0.4.15). Unset: a link (before 0.4.5). */
|
|
381
|
+
via?: "github" | "link" | "code" | "claim";
|
|
334
382
|
/** SEC-2a: the client's address when Den could attest it. */
|
|
335
383
|
client?: string;
|
|
336
384
|
/** SEC-2a: a GitHub sign-in's login (the chip's "@login"). */
|
|
337
385
|
login?: string;
|
|
338
386
|
/** SEC-2a: not yet greeted ("This device is signed in…", once). */
|
|
339
387
|
welcome?: true;
|
|
388
|
+
/**
|
|
389
|
+
* 0.4.15: sha256 of this session's approval key (approvalKeyMatches).
|
|
390
|
+
* The key itself is handed over once, at the end of the sign-in, and
|
|
391
|
+
* never by GET /api/session. Unset: a session from before 0.4.15, which
|
|
392
|
+
* must sign in again to approve.
|
|
393
|
+
*/
|
|
394
|
+
approveKeyHash?: string;
|
|
340
395
|
}
|
|
341
396
|
|
|
342
397
|
interface StoreFile {
|
|
@@ -464,14 +519,17 @@ export class WebSessionStore {
|
|
|
464
519
|
create(meta: {
|
|
465
520
|
userAgent?: string;
|
|
466
521
|
host?: string;
|
|
467
|
-
via?: "github" | "link" | "code";
|
|
522
|
+
via?: "github" | "link" | "code" | "claim";
|
|
468
523
|
client?: string;
|
|
469
524
|
login?: string;
|
|
470
525
|
}): {
|
|
471
526
|
id: string;
|
|
472
527
|
session: SessionView;
|
|
528
|
+
/** The approval key, for the sign-in's answer only (never stored). */
|
|
529
|
+
approveKey: string;
|
|
473
530
|
} {
|
|
474
531
|
const id = randomBytes(32).toString("base64url");
|
|
532
|
+
const approveKey = randomBytes(32).toString("base64url");
|
|
475
533
|
const key = this.keyOf(id);
|
|
476
534
|
if (!key) throw new Error("web.secret is missing: no session can be made");
|
|
477
535
|
const now = this.now();
|
|
@@ -487,12 +545,13 @@ export class WebSessionStore {
|
|
|
487
545
|
...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
|
|
488
546
|
...(meta.login ? { login: String(meta.login).slice(0, 64) } : {}),
|
|
489
547
|
welcome: true,
|
|
548
|
+
approveKeyHash: approveKeyHash(approveKey),
|
|
490
549
|
};
|
|
491
550
|
const data = structuredClone(this.load());
|
|
492
551
|
this.prune(data, now);
|
|
493
552
|
data.sessions[key] = rec;
|
|
494
553
|
this.save(data);
|
|
495
|
-
return { id, session: { key, ...rec } };
|
|
554
|
+
return { id, session: { key, ...rec }, approveKey };
|
|
496
555
|
}
|
|
497
556
|
|
|
498
557
|
/**
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -116,6 +116,7 @@ import {
|
|
|
116
116
|
restoreRecord,
|
|
117
117
|
writeRecord,
|
|
118
118
|
checkDisplayName,
|
|
119
|
+
nameSlug,
|
|
119
120
|
writeRuntimeFiles,
|
|
120
121
|
type AgentRecord,
|
|
121
122
|
type Override,
|
|
@@ -138,6 +139,7 @@ import type {
|
|
|
138
139
|
RuntimePermissions,
|
|
139
140
|
} from "../../shared/agents/runtime.js";
|
|
140
141
|
import {
|
|
142
|
+
inScope,
|
|
141
143
|
OPEN_ELSEWHERE_MESSAGE,
|
|
142
144
|
openElsewhere,
|
|
143
145
|
type RuntimeConversation,
|
|
@@ -195,7 +197,7 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
195
197
|
* AG-17.1: the folders a workspace's conversations may have run in: its
|
|
196
198
|
* own folder, and each repo's path, a repo added by path outside it too.
|
|
197
199
|
*/
|
|
198
|
-
workspaceFolders?: (ws: string) => string[];
|
|
200
|
+
workspaceFolders?: (ws: string) => { under: string[]; exact?: string[] };
|
|
199
201
|
/** The MCP server binary and its env; Den adds the scoped args. */
|
|
200
202
|
mcpServer?: () => { command: string[] | string; env: Record<string, string> };
|
|
201
203
|
/**
|
|
@@ -232,7 +234,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
232
234
|
agentsConfig: () => ResolvedAgentsConfig;
|
|
233
235
|
distRoot: () => string;
|
|
234
236
|
workspaceDir: (ws: string) => string;
|
|
235
|
-
workspaceFolders: (ws: string) => string[];
|
|
237
|
+
workspaceFolders: (ws: string) => { under: string[]; exact?: string[] };
|
|
236
238
|
mcpServer: () => { command: string[] | string; env: Record<string, string> };
|
|
237
239
|
exitWatchMs: number;
|
|
238
240
|
stopWaitMs: number;
|
|
@@ -292,7 +294,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
|
|
|
292
294
|
new AgentError(message, { status, hints });
|
|
293
295
|
|
|
294
296
|
let workspaceDirFn: ((ws: string) => string) | null = null;
|
|
295
|
-
let
|
|
297
|
+
let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
|
|
298
|
+
null;
|
|
296
299
|
/** The workspaces config, for start and resume (loaded on first use). */
|
|
297
300
|
export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
298
301
|
await loadWorkspaces(d);
|
|
@@ -305,8 +308,11 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
|
305
308
|
return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
|
|
306
309
|
};
|
|
307
310
|
// A by-path repo's own folder (repo add <path>), wherever it is.
|
|
308
|
-
|
|
309
|
-
ws.
|
|
311
|
+
foldersFn = (name) => {
|
|
312
|
+
const cfg = ws.readWorkspacesFile().workspaces[name];
|
|
313
|
+
if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
|
|
314
|
+
return ws.conversationFolders(name, cfg, getReposRoot());
|
|
315
|
+
};
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
/** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
|
|
@@ -340,13 +346,10 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
340
346
|
workspaceFolders:
|
|
341
347
|
d.workspaceFolders ??
|
|
342
348
|
((ws) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return workspaceDirFn(ws);
|
|
348
|
-
})();
|
|
349
|
-
return [dir, ...(repoPathsFn?.(ws) ?? [])];
|
|
349
|
+
// A caller that gives its own folder (a test) gives just it.
|
|
350
|
+
if (d.workspaceDir) return { under: [d.workspaceDir(ws)] };
|
|
351
|
+
if (!foldersFn) throw new Error("workspaces not loaded");
|
|
352
|
+
return foldersFn(ws);
|
|
350
353
|
}),
|
|
351
354
|
mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
|
|
352
355
|
exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
|
|
@@ -1048,6 +1051,48 @@ function sessionOf(
|
|
|
1048
1051
|
* else the first of `<base>-2`, `<base>-3`… no agent has. A stopped agent's
|
|
1049
1052
|
* id is never reused by a new start: only its Resume or Start fresh.
|
|
1050
1053
|
*/
|
|
1054
|
+
/**
|
|
1055
|
+
* AG-10.10b: the id a Name gives: its slug while no agent has it; a
|
|
1056
|
+
* running agent in the workspace with that id or that title is refused
|
|
1057
|
+
* (open it instead); a stopped one, or another workspace's, makes it the
|
|
1058
|
+
* next free `<slug>-2`… (Resume continues the stopped one). Null when the
|
|
1059
|
+
* Name has no id characters: the persona's default then.
|
|
1060
|
+
*/
|
|
1061
|
+
async function namedId(
|
|
1062
|
+
d: AgentsResolved,
|
|
1063
|
+
name: string,
|
|
1064
|
+
ws: string,
|
|
1065
|
+
): Promise<string | null> {
|
|
1066
|
+
const slug = nameSlug(name);
|
|
1067
|
+
const sessions = await withClient(d, (c) => c.list());
|
|
1068
|
+
const live = (rec: AgentRecord) => {
|
|
1069
|
+
const s = sessionOf(sessions, rec);
|
|
1070
|
+
return s && !s.exited ? s : undefined;
|
|
1071
|
+
};
|
|
1072
|
+
// No two live agents in one workspace share a title, or this id.
|
|
1073
|
+
for (const rec of listRecords(d.stateDir())) {
|
|
1074
|
+
if (rec.workspace !== ws) continue;
|
|
1075
|
+
const same =
|
|
1076
|
+
rec.id === slug ||
|
|
1077
|
+
(rec.displayName ?? "").toLowerCase() === name.toLowerCase();
|
|
1078
|
+
const s = same ? live(rec) : undefined;
|
|
1079
|
+
if (s)
|
|
1080
|
+
throw new AgentError(`agent ${rec.id} is running (${s.name})`, {
|
|
1081
|
+
status: 409,
|
|
1082
|
+
hints: [`Attach: ${d.cli} sessions attach ${s.name}`],
|
|
1083
|
+
running: { id: rec.id, sessionId: s.ref.id },
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
if (!slug) return null;
|
|
1087
|
+
if (!readRecord(d.stateDir(), slug)) return slug;
|
|
1088
|
+
const stem = slug.slice(0, 58);
|
|
1089
|
+
for (let n = 2; n < 1000; n++) {
|
|
1090
|
+
const id = `${stem}-${n}`;
|
|
1091
|
+
if (!readRecord(d.stateDir(), id)) return id;
|
|
1092
|
+
}
|
|
1093
|
+
throw refused(`no free agent id for ${slug}: give one with --id`);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1051
1096
|
export function defaultId(
|
|
1052
1097
|
stateDir: string,
|
|
1053
1098
|
persona: string,
|
|
@@ -1487,8 +1532,6 @@ export async function startAgent(
|
|
|
1487
1532
|
throw usage(
|
|
1488
1533
|
`${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
|
|
1489
1534
|
);
|
|
1490
|
-
const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
|
|
1491
|
-
checkId(where, id);
|
|
1492
1535
|
let given: string | null = null;
|
|
1493
1536
|
if (req.name !== undefined)
|
|
1494
1537
|
try {
|
|
@@ -1496,6 +1539,11 @@ export async function startAgent(
|
|
|
1496
1539
|
} catch (e) {
|
|
1497
1540
|
throw usage(`${where}: ${(e as Error).message}`);
|
|
1498
1541
|
}
|
|
1542
|
+
const id =
|
|
1543
|
+
req.id ??
|
|
1544
|
+
(given ? await namedId(d, given, ws) : null) ??
|
|
1545
|
+
defaultId(d.stateDir(), persona.id, ws);
|
|
1546
|
+
checkId(where, id);
|
|
1499
1547
|
syncCatalog(d);
|
|
1500
1548
|
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
1501
1549
|
const det = await availableOrRefuse(d, rt, ws);
|
|
@@ -1678,13 +1726,8 @@ export function listConversations(
|
|
|
1678
1726
|
throw usage(`${where}: workspace "${ws}" does not exist`, 404);
|
|
1679
1727
|
syncCatalog(d);
|
|
1680
1728
|
if (runtime !== undefined) runtimeFor(runtime);
|
|
1681
|
-
// Its folder and every repo's
|
|
1682
|
-
const
|
|
1683
|
-
...new Set(d.workspaceFolders(ws).map((f) => path.resolve(f))),
|
|
1684
|
-
];
|
|
1685
|
-
const scan = folders.filter(
|
|
1686
|
-
(f) => !folders.some((o) => o !== f && f.startsWith(o + path.sep)),
|
|
1687
|
-
);
|
|
1729
|
+
// Its folder and every repo's; a shared root only exactly (AG-17.1).
|
|
1730
|
+
const scope = d.workspaceFolders(ws);
|
|
1688
1731
|
const taken = agentByToken(d);
|
|
1689
1732
|
const now = Date.now();
|
|
1690
1733
|
const conversations: ListedConversation[] = [];
|
|
@@ -1693,7 +1736,7 @@ export function listConversations(
|
|
|
1693
1736
|
if (runtime !== undefined && id !== runtime) continue;
|
|
1694
1737
|
const rt = getRuntime(id);
|
|
1695
1738
|
if (!rt?.listSessions) continue;
|
|
1696
|
-
for (const c of
|
|
1739
|
+
for (const c of rt.listSessions(scope, d.env)) {
|
|
1697
1740
|
if (seen.has(`${id} ${c.id}`)) continue;
|
|
1698
1741
|
seen.add(`${id} ${c.id}`);
|
|
1699
1742
|
const agent = taken.get(`${id} ${c.id}`);
|
|
@@ -1755,6 +1798,16 @@ export async function importAgent(
|
|
|
1755
1798
|
422,
|
|
1756
1799
|
);
|
|
1757
1800
|
const c = conversations.find((x) => x.id === req.session);
|
|
1801
|
+
// Another workspace's (or no repo's): say whose, never bring it in.
|
|
1802
|
+
const elsewhere = c
|
|
1803
|
+
? null
|
|
1804
|
+
: (getRuntime(runtime)?.conversationCwd?.(req.session, d.env) ?? null);
|
|
1805
|
+
if (!c && elsewhere && !inScope(elsewhere, d.workspaceFolders(req.workspace)))
|
|
1806
|
+
throw refused(
|
|
1807
|
+
`This conversation belongs to ${elsewhere}, not to workspace ${req.workspace}`,
|
|
1808
|
+
[`List this workspace's: ${d.cli} agents import -w ${req.workspace}`],
|
|
1809
|
+
409,
|
|
1810
|
+
);
|
|
1758
1811
|
if (!c)
|
|
1759
1812
|
throw refused(
|
|
1760
1813
|
`${where}: no ${rt.label} conversation "${req.session}" in workspace ${req.workspace}'s folder`,
|
|
@@ -123,7 +123,7 @@ export const AGENTS_HELP = {
|
|
|
123
123
|
"",
|
|
124
124
|
"{cli} agents list -w <ws> [--stopped] [--json]",
|
|
125
125
|
" --stopped: only the stopped, interrupted and exited ones",
|
|
126
|
-
"{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--id <id>]",
|
|
126
|
+
"{cli} agents start -w <ws> --persona <p> [--runtime <r>] [--model <m>] [--name <name>] [--id <id>]",
|
|
127
127
|
" runs in the workspace's dir as <dist>-<ws>:<id>; its MCP is pinned to <ws>",
|
|
128
128
|
" (plus agents.<id>.grants); capped by agents.max_concurrent",
|
|
129
129
|
"{cli} agents stop <id> [-w <ws>] [--no-wait]",
|
|
@@ -273,12 +273,14 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
273
273
|
"--persona": true,
|
|
274
274
|
"--runtime": true,
|
|
275
275
|
"--model": true,
|
|
276
|
+
"--name": true,
|
|
276
277
|
"--id": true,
|
|
277
278
|
});
|
|
278
279
|
noExtra(where, p);
|
|
279
280
|
const ws = workspaceFlag(where, p);
|
|
280
281
|
if (!ws) throw new UsageError(`${where} needs -w <workspace>`);
|
|
281
282
|
const persona = str(p, "--persona");
|
|
283
|
+
const name = str(p, "--name");
|
|
282
284
|
if (!persona) throw new UsageError(`${where} needs --persona <p>`);
|
|
283
285
|
const s = await startAgent(d, {
|
|
284
286
|
workspace: ws,
|
|
@@ -286,6 +288,7 @@ async function start(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
286
288
|
runtime: str(p, "--runtime"),
|
|
287
289
|
model: str(p, "--model"),
|
|
288
290
|
id: str(p, "--id"),
|
|
291
|
+
...(name !== undefined ? { name } : {}),
|
|
289
292
|
});
|
|
290
293
|
return report(
|
|
291
294
|
d,
|
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
* nothing is sent.
|
|
23
23
|
*/
|
|
24
24
|
import chalk from "chalk";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
readDenConfig,
|
|
27
|
+
readMcpConfig,
|
|
28
|
+
type DenConfig,
|
|
29
|
+
} from "../../shared/den-config.js";
|
|
30
|
+
import { approvalsState } from "../mcp/tools.js";
|
|
26
31
|
import { getStateDir } from "../../shared/paths.js";
|
|
27
32
|
import { product } from "../../shared/product.js";
|
|
28
33
|
import { listRecords } from "../../shared/agents/records.js";
|
|
@@ -216,6 +221,25 @@ export function agentsConfigured(
|
|
|
216
221
|
}
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The held writes' state (mcp.approvals), as doctor prints it. A bad value
|
|
226
|
+
* is a problem: the MCP server refuses to start on it.
|
|
227
|
+
*/
|
|
228
|
+
export function approvalsLines(read: () => boolean): {
|
|
229
|
+
lines: string[];
|
|
230
|
+
problem: boolean;
|
|
231
|
+
} {
|
|
232
|
+
try {
|
|
233
|
+
const state = approvalsState({ approvals: read() });
|
|
234
|
+
return { lines: ["", ` ℹ MCP ${state}`], problem: false };
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return {
|
|
237
|
+
lines: ["", chalk.red(` ✗ MCP ${(err as Error).message}`)],
|
|
238
|
+
problem: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
219
243
|
/** Informational lines after the rows: never a failure. */
|
|
220
244
|
export function doctorNotes(cli: string, configured: boolean): string[] {
|
|
221
245
|
return configured
|
|
@@ -312,6 +336,8 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
312
336
|
}
|
|
313
337
|
const rows = doctorRows();
|
|
314
338
|
for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
|
|
339
|
+
const held = approvalsLines(() => readMcpConfig().approvals);
|
|
340
|
+
for (const l of held.lines) console.log(l);
|
|
315
341
|
for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
|
|
316
342
|
const cli = product().cli;
|
|
317
343
|
if (!probe) {
|
|
@@ -320,7 +346,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
320
346
|
agentsConfigured(readConfigOrNull(), getStateDir()),
|
|
321
347
|
))
|
|
322
348
|
console.log(l);
|
|
323
|
-
return rows.some((r) => r.problem) ? 1 : 0;
|
|
349
|
+
return rows.some((r) => r.problem) || held.problem ? 1 : 0;
|
|
324
350
|
}
|
|
325
351
|
const { runEnvProbe, formatProbe } = await import("./env-probe.js");
|
|
326
352
|
const { resolveDeps, plainSessionEnv } =
|
|
@@ -339,5 +365,5 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
339
365
|
console.log("");
|
|
340
366
|
for (const l of formatProbe(results)) console.log(l);
|
|
341
367
|
const found = results.some((r) => r.findings.length);
|
|
342
|
-
return rows.some((r) => r.problem) || found ? 1 : 0;
|
|
368
|
+
return rows.some((r) => r.problem) || held.problem || found ? 1 : 0;
|
|
343
369
|
}
|