@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.
@@ -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
- /** Overwrite the field; an item that isn't there yet is created (a Password item). */
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
- const field = `${ref.field}[password]=${value}`;
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
- await this.run(["item", "edit", ref.item, ...vault, field], true);
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
- } catch {
153
- throw err;
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> {
@@ -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 ? { url: cfg.url, token, allowTools: cfg.allowTools } : null;
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(d, rec.workspace, rec.displayName ?? rec.id);
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 = [st.team && `team ${st.team}`, st.label && `install ${st.label}`]
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
- sendJson(res, await (deps.hubStatus ?? (() => hubStatus()))());
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
+ }