@monster-orange/den 0.4.25 → 0.4.27
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 +55 -0
- package/docs/distributions.md +74 -20
- package/package.json +1 -1
- package/src/shared/agents/runtime.ts +2 -2
- package/src/shared/hub.ts +304 -82
- package/src/shared/secrets/onepassword.ts +135 -20
- package/src/ui/cli/agent-ops.ts +17 -3
- package/src/ui/cli/doctor-command.ts +11 -2
- package/src/ui/server/agent-routes.ts +79 -1
- package/src/ui/server/hub-connect.ts +433 -0
- package/src/ui/server/public/i18n/es.js +2 -0
- package/src/ui/server/public/i18n/ja.js +2 -0
- package/src/ui/server/public/settings-registry.js +196 -33
- package/src/ui/server/public/settings.html +23 -0
- package/src/ui/server/public/shell.js +1 -0
- package/src/ui/server/public/topbar.js +5 -1
- package/src/ui/server/route-classes.ts +17 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 1Password strategy — reads/writes item fields with the `op` CLI.
|
|
3
3
|
* Selected with `secrets.provider: 1password`.
|
|
4
4
|
*/
|
|
5
|
-
import { execFile } from "child_process";
|
|
5
|
+
import { execFile, spawn } from "child_process";
|
|
6
6
|
import { promisify } from "util";
|
|
7
7
|
import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
|
|
8
8
|
import {
|
|
@@ -32,6 +32,39 @@ export interface OnePasswordOptions {
|
|
|
32
32
|
timeoutMs?: number;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** op's own words from a failed run (stderr), without its "[ERROR] date time" prefix. */
|
|
36
|
+
function opText(err: unknown): string {
|
|
37
|
+
const e = err as { stderr?: unknown; message?: unknown };
|
|
38
|
+
const raw = String(e?.stderr ?? "").trim() || String(e?.message ?? err);
|
|
39
|
+
return (
|
|
40
|
+
raw
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map((l) =>
|
|
43
|
+
l.replace(/^\[\w+\]\s*\d{4}\/\d\d\/\d\d \d\d:\d\d:\d\d\s*/, "").trim(),
|
|
44
|
+
)
|
|
45
|
+
// execFile's message starts with the command line it ran: never shown.
|
|
46
|
+
.filter((l) => l && !/^Command failed:/.test(l))
|
|
47
|
+
.join(" ")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The same failure, its message op's words (set's callers strip the value). */
|
|
52
|
+
function opError(err: unknown): Error {
|
|
53
|
+
const e = err as { killed?: boolean; code?: unknown };
|
|
54
|
+
if (e?.killed) return new Error("op timed out");
|
|
55
|
+
if (e?.code === "ENOENT") return new Error("op is not installed");
|
|
56
|
+
return new Error(opText(err) || "op failed (no detail)");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True only when op said the item isn't there (not "more than one item"). */
|
|
60
|
+
export function opItemMissing(err: unknown): boolean {
|
|
61
|
+
const t = opText(err);
|
|
62
|
+
if (/more than one item/i.test(t)) return false;
|
|
63
|
+
return /isn't an item|is not an item|(?:could not|couldn't) find (?:the )?item|no item (?:found|matches)/i.test(
|
|
64
|
+
t,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
35
68
|
export class OnePasswordProvider implements SecretsProvider {
|
|
36
69
|
readonly name = "1password" as const;
|
|
37
70
|
private readonly timeout: number;
|
|
@@ -128,31 +161,113 @@ export class OnePasswordProvider implements SecretsProvider {
|
|
|
128
161
|
}
|
|
129
162
|
}
|
|
130
163
|
|
|
131
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Run `op` with `input` on its stdin. A secret goes this way, never in
|
|
166
|
+
* argv: any process on the machine can read another's command line.
|
|
167
|
+
*/
|
|
168
|
+
private async runWithInput(args: string[], input: string): Promise<string> {
|
|
169
|
+
const { token: _t, ...opts } = await this.opOptions();
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const child = spawn("op", args, {
|
|
172
|
+
env: opts.env,
|
|
173
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
174
|
+
});
|
|
175
|
+
let stdout = "";
|
|
176
|
+
let stderr = "";
|
|
177
|
+
let killed = false;
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
killed = true;
|
|
180
|
+
child.kill("SIGTERM");
|
|
181
|
+
}, opts.timeout);
|
|
182
|
+
child.stdout.on("data", (d) => (stdout += String(d)));
|
|
183
|
+
child.stderr.on("data", (d) => (stderr += String(d)));
|
|
184
|
+
child.on("error", (e) => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
reject(e);
|
|
187
|
+
});
|
|
188
|
+
child.on("close", (code) => {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
if (code === 0) resolve(stdout);
|
|
191
|
+
else
|
|
192
|
+
reject(
|
|
193
|
+
Object.assign(new Error(stderr.trim() || `op exited ${code}`), {
|
|
194
|
+
stderr,
|
|
195
|
+
killed,
|
|
196
|
+
code,
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
child.stdin.on("error", () => undefined);
|
|
201
|
+
child.stdin.end(input);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Overwrite the field; an item that isn't there yet is created (a
|
|
207
|
+
* Password item). The value only ever travels on op's stdin, as the
|
|
208
|
+
* item's JSON (op item edit / op item create -), never in argv.
|
|
209
|
+
*/
|
|
132
210
|
async set(ref: SecretRef, value: string): Promise<void> {
|
|
133
211
|
const vault = ref.vault ? ["--vault", ref.vault] : [];
|
|
134
|
-
|
|
212
|
+
type Field = {
|
|
213
|
+
id?: string;
|
|
214
|
+
label?: string;
|
|
215
|
+
type?: string;
|
|
216
|
+
purpose?: string;
|
|
217
|
+
value?: string;
|
|
218
|
+
};
|
|
219
|
+
let item: { id?: string; fields?: Field[] } | null = null;
|
|
135
220
|
try {
|
|
136
|
-
|
|
137
|
-
} catch (err) {
|
|
138
|
-
try {
|
|
221
|
+
item = JSON.parse(
|
|
139
222
|
await this.run(
|
|
140
|
-
[
|
|
141
|
-
"item",
|
|
142
|
-
"create",
|
|
143
|
-
"--category",
|
|
144
|
-
"password",
|
|
145
|
-
"--title",
|
|
146
|
-
ref.item,
|
|
147
|
-
...vault,
|
|
148
|
-
field,
|
|
149
|
-
],
|
|
223
|
+
["item", "get", ref.item, ...vault, "--format", "json", "--reveal"],
|
|
150
224
|
true,
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
225
|
+
),
|
|
226
|
+
) as { id?: string; fields?: Field[] };
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// Create only when op says the item isn't there. Any other failure
|
|
229
|
+
// (a timeout, no access, two items by that title) is shown as it is:
|
|
230
|
+
// creating then would make a second item with the same title.
|
|
231
|
+
if (!opItemMissing(err)) throw opError(err);
|
|
232
|
+
}
|
|
233
|
+
if (item) {
|
|
234
|
+
const fields = item.fields ?? [];
|
|
235
|
+
const f = fields.find((x) => x.label === ref.field || x.id === ref.field);
|
|
236
|
+
if (f) {
|
|
237
|
+
f.value = value;
|
|
238
|
+
f.type = "CONCEALED";
|
|
239
|
+
} else fields.push({ label: ref.field, type: "CONCEALED", value });
|
|
240
|
+
item.fields = fields;
|
|
241
|
+
await this.runWithInput(
|
|
242
|
+
["item", "edit", item.id || ref.item, ...vault],
|
|
243
|
+
JSON.stringify(item),
|
|
244
|
+
).catch((err) => {
|
|
245
|
+
throw opError(err);
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
155
248
|
}
|
|
249
|
+
const field: Field =
|
|
250
|
+
ref.field === "password"
|
|
251
|
+
? {
|
|
252
|
+
id: "password",
|
|
253
|
+
type: "CONCEALED",
|
|
254
|
+
purpose: "PASSWORD",
|
|
255
|
+
label: "password",
|
|
256
|
+
value,
|
|
257
|
+
}
|
|
258
|
+
: { label: ref.field, type: "CONCEALED", value };
|
|
259
|
+
// A failed create shows create's own reason (a vault that isn't
|
|
260
|
+
// there, no access), never the get's "isn't an item".
|
|
261
|
+
await this.runWithInput(
|
|
262
|
+
["item", "create", ...vault, "-"],
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
title: ref.item,
|
|
265
|
+
category: "PASSWORD",
|
|
266
|
+
fields: [field],
|
|
267
|
+
}),
|
|
268
|
+
).catch((err) => {
|
|
269
|
+
throw opError(err);
|
|
270
|
+
});
|
|
156
271
|
}
|
|
157
272
|
|
|
158
273
|
async preflight(): Promise<PreflightResult> {
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -265,6 +265,8 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
265
265
|
url: string;
|
|
266
266
|
token: string;
|
|
267
267
|
allowTools?: boolean;
|
|
268
|
+
/** HUB-1.7: Hub's MCP endpoint (default <url>/mcp). */
|
|
269
|
+
mcpUrl?: string;
|
|
268
270
|
} | null>;
|
|
269
271
|
/** HUB-1.5: workspace `ws`'s repos as owner/repo (X-Den-Repos). */
|
|
270
272
|
workspaceRepos?: (ws: string) => string[];
|
|
@@ -288,6 +290,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
288
290
|
url: string;
|
|
289
291
|
token: string;
|
|
290
292
|
allowTools?: boolean;
|
|
293
|
+
mcpUrl?: string;
|
|
291
294
|
} | null>;
|
|
292
295
|
workspaceRepos: (ws: string) => string[];
|
|
293
296
|
}
|
|
@@ -395,6 +398,7 @@ export async function agentHub(
|
|
|
395
398
|
d: AgentsResolved,
|
|
396
399
|
ws: string,
|
|
397
400
|
name: string,
|
|
401
|
+
agentId?: string,
|
|
398
402
|
): Promise<AgentMcp["hub"] | undefined> {
|
|
399
403
|
const h = await d.hub().catch(() => null);
|
|
400
404
|
if (!h) return undefined;
|
|
@@ -407,10 +411,11 @@ export async function agentHub(
|
|
|
407
411
|
}
|
|
408
412
|
return {
|
|
409
413
|
name: HUB_SERVER,
|
|
410
|
-
url: h.url
|
|
414
|
+
url: h.mcpUrl || `${h.url}/mcp`,
|
|
411
415
|
allowTools: h.allowTools !== false,
|
|
412
416
|
headers: hubHeaders(h.token, {
|
|
413
417
|
agent: name,
|
|
418
|
+
...(agentId ? { agentId } : {}),
|
|
414
419
|
session: crypto.randomUUID(),
|
|
415
420
|
workspace: ws,
|
|
416
421
|
repos,
|
|
@@ -480,6 +485,7 @@ async function defaultHub(): Promise<{
|
|
|
480
485
|
url: string;
|
|
481
486
|
token: string;
|
|
482
487
|
allowTools: boolean;
|
|
488
|
+
mcpUrl: string;
|
|
483
489
|
} | null> {
|
|
484
490
|
const { readHubConfig, readHubToken } = await import("../../shared/hub.js");
|
|
485
491
|
let cfg;
|
|
@@ -490,7 +496,9 @@ async function defaultHub(): Promise<{
|
|
|
490
496
|
}
|
|
491
497
|
if (!cfg) return null;
|
|
492
498
|
const token = await readHubToken(cfg);
|
|
493
|
-
return token
|
|
499
|
+
return token
|
|
500
|
+
? { url: cfg.url, token, allowTools: cfg.allowTools, mcpUrl: cfg.mcpUrl }
|
|
501
|
+
: null;
|
|
494
502
|
}
|
|
495
503
|
|
|
496
504
|
/**
|
|
@@ -1901,6 +1909,7 @@ export async function startAgent(
|
|
|
1901
1909
|
d,
|
|
1902
1910
|
ws,
|
|
1903
1911
|
given ?? prev?.displayName ?? persona.displayName ?? id,
|
|
1912
|
+
id,
|
|
1904
1913
|
);
|
|
1905
1914
|
const input = {
|
|
1906
1915
|
...base,
|
|
@@ -2443,7 +2452,12 @@ export async function resumeAgent(
|
|
|
2443
2452
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
2444
2453
|
// D24 (and D25: a rename since) the name peers see it by.
|
|
2445
2454
|
const peerName = peerSessionName(d.cli, rec.workspace, rec);
|
|
2446
|
-
const hub = await agentHub(
|
|
2455
|
+
const hub = await agentHub(
|
|
2456
|
+
d,
|
|
2457
|
+
rec.workspace,
|
|
2458
|
+
rec.displayName ?? rec.id,
|
|
2459
|
+
rec.id,
|
|
2460
|
+
);
|
|
2447
2461
|
const at = {
|
|
2448
2462
|
...input,
|
|
2449
2463
|
...(hub ? { mcp: { ...input.mcp, hub } } : {}),
|
|
@@ -482,12 +482,21 @@ export function hubLines(
|
|
|
482
482
|
title: string,
|
|
483
483
|
): { lines: string[]; problem: boolean; check?: OnboardCheck } {
|
|
484
484
|
if (!st.configured) return { lines: [], problem: false };
|
|
485
|
-
const who = [
|
|
485
|
+
const who = [
|
|
486
|
+
st.team && `company ${st.team}`,
|
|
487
|
+
st.label && `install ${st.label}`,
|
|
488
|
+
st.role && `role ${st.role}`,
|
|
489
|
+
]
|
|
486
490
|
.filter(Boolean)
|
|
487
491
|
.join(", ");
|
|
492
|
+
// HUB-1.7: what to do about it is in Settings (Connect, Reconnect).
|
|
493
|
+
const fix =
|
|
494
|
+
st.state === "no_token" || st.state === "rejected"
|
|
495
|
+
? ` (Settings › Agents › Hub: ${st.state === "rejected" ? "Reconnect" : "Connect"})`
|
|
496
|
+
: "";
|
|
488
497
|
const detail = st.connected
|
|
489
498
|
? `${st.url}: connected${who ? ` (${who})` : ""}`
|
|
490
|
-
: `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}`;
|
|
499
|
+
: `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}${fix}`;
|
|
491
500
|
return {
|
|
492
501
|
lines: [
|
|
493
502
|
"",
|
|
@@ -164,6 +164,7 @@ import {
|
|
|
164
164
|
agentResumeRefusal,
|
|
165
165
|
} from "./agent-resume-grant.js";
|
|
166
166
|
import { readBody, sendJson } from "./http-helpers.js";
|
|
167
|
+
import { product } from "../../shared/product.js";
|
|
167
168
|
import type { Actor } from "../../shared/agent-actor.js";
|
|
168
169
|
|
|
169
170
|
export interface AgentRouteDeps {
|
|
@@ -175,6 +176,8 @@ export interface AgentRouteDeps {
|
|
|
175
176
|
personaWait?: { pollMs?: number; maxMs?: number };
|
|
176
177
|
/** HUB-1.5: GET /api/hub's answer (tests: a fake Hub). */
|
|
177
178
|
hubStatus?: () => Promise<import("../../shared/hub.js").HubStatus>;
|
|
179
|
+
/** HUB-1.7: Connect to Hub's state (tests: fake Hub calls and store). */
|
|
180
|
+
hubConnect?: import("./hub-connect.js").HubConnect;
|
|
178
181
|
}
|
|
179
182
|
|
|
180
183
|
const AGENTS_PATH =
|
|
@@ -434,9 +437,19 @@ export async function handleAgentRoutes(
|
|
|
434
437
|
if (method !== "GET" && method !== "HEAD")
|
|
435
438
|
return methodNotAllowed(res, "GET");
|
|
436
439
|
const { hubStatus } = await import("../../shared/hub.js");
|
|
437
|
-
|
|
440
|
+
const st = await (deps.hubStatus ?? (() => hubStatus()))();
|
|
441
|
+
// HUB-1.7: what the last Connect came to, once (words only).
|
|
442
|
+
const last = (deps.hubConnect ?? defaultConnect)?.lastResult();
|
|
443
|
+
sendJson(res, last ? { ...st, connect: last } : st);
|
|
438
444
|
return true;
|
|
439
445
|
}
|
|
446
|
+
// HUB-1.7: Connect to Hub, and its callback (see hub-connect.ts).
|
|
447
|
+
if (
|
|
448
|
+
pathname === "/api/hub/connect" ||
|
|
449
|
+
pathname === "/api/hub/disconnect" ||
|
|
450
|
+
pathname === "/hub/callback"
|
|
451
|
+
)
|
|
452
|
+
return hubConnectRoute(req, res, pathname, deps);
|
|
440
453
|
if (
|
|
441
454
|
pathname !== "/api/personas" &&
|
|
442
455
|
pathname !== "/api/agents/runtimes" &&
|
|
@@ -1381,3 +1394,68 @@ async function rotationRoute(
|
|
|
1381
1394
|
}
|
|
1382
1395
|
return true;
|
|
1383
1396
|
}
|
|
1397
|
+
|
|
1398
|
+
let defaultConnect: import("./hub-connect.js").HubConnect | null = null;
|
|
1399
|
+
|
|
1400
|
+
/** HUB-1.7: start a connect, finish it (Hub's redirect), or disconnect. */
|
|
1401
|
+
async function hubConnectRoute(
|
|
1402
|
+
req: http.IncomingMessage,
|
|
1403
|
+
res: http.ServerResponse,
|
|
1404
|
+
pathname: string,
|
|
1405
|
+
deps: AgentRouteDeps,
|
|
1406
|
+
): Promise<true> {
|
|
1407
|
+
const m = await import("./hub-connect.js");
|
|
1408
|
+
const hc = deps.hubConnect ?? (defaultConnect ??= new m.HubConnect());
|
|
1409
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1410
|
+
const method = req.method ?? "GET";
|
|
1411
|
+
if (pathname === "/hub/callback") {
|
|
1412
|
+
if (method !== "GET") return methodNotAllowed(res, "GET");
|
|
1413
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
1414
|
+
const r = await hc.finish(q);
|
|
1415
|
+
res.writeHead(r.ok ? 200 : 400, {
|
|
1416
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1417
|
+
"Referrer-Policy": "no-referrer",
|
|
1418
|
+
"Cache-Control": "no-store",
|
|
1419
|
+
});
|
|
1420
|
+
res.end(
|
|
1421
|
+
m.callbackPage(product().title, {
|
|
1422
|
+
ok: r.ok,
|
|
1423
|
+
...(r.ok ? {} : { reason: r.reason }),
|
|
1424
|
+
message: r.ok
|
|
1425
|
+
? `Connected to Hub${r.team ? ` (${r.team})` : ""}. Going back to Settings…`
|
|
1426
|
+
: r.message,
|
|
1427
|
+
}),
|
|
1428
|
+
);
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
if (method !== "POST") return methodNotAllowed(res, "POST");
|
|
1432
|
+
let body: Record<string, unknown> = {};
|
|
1433
|
+
try {
|
|
1434
|
+
const raw = await readBody(req);
|
|
1435
|
+
body = raw ? ((JSON.parse(raw) as Record<string, unknown>) ?? {}) : {};
|
|
1436
|
+
} catch {
|
|
1437
|
+
sendJson(res, { ok: false, error: "the body must be JSON" }, 400);
|
|
1438
|
+
return true;
|
|
1439
|
+
}
|
|
1440
|
+
if (pathname === "/api/hub/connect") {
|
|
1441
|
+
// The browser's Origin (request-guard checked it is this Den's).
|
|
1442
|
+
const origin = String(req.headers.origin ?? "");
|
|
1443
|
+
if (!origin) {
|
|
1444
|
+
sendJson(
|
|
1445
|
+
res,
|
|
1446
|
+
{
|
|
1447
|
+
ok: false,
|
|
1448
|
+
error: "Connect from Settings › Agents › Hub in a browser",
|
|
1449
|
+
},
|
|
1450
|
+
400,
|
|
1451
|
+
);
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
const r = hc.start(origin, body.url);
|
|
1455
|
+
sendJson(res, r, r.ok ? 200 : 400);
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
const r = await hc.disconnect(body.force === true);
|
|
1459
|
+
sendJson(res, r, r.ok ? 200 : 502);
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|