@monster-orange/den 0.4.7 → 0.4.9

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +243 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +57 -3
  7. package/src/shared/agents/guardrails/hooks.json +23 -0
  8. package/src/shared/agents/guardrails/instructions.md +23 -0
  9. package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
  10. package/src/shared/agents/guardrails/stop-hook.ts +48 -2
  11. package/src/shared/agents/guardrails.ts +38 -7
  12. package/src/shared/agents/handoff.ts +118 -0
  13. package/src/shared/agents/personas.ts +11 -1
  14. package/src/shared/agents/records.ts +122 -1
  15. package/src/shared/agents/rotation.ts +362 -0
  16. package/src/shared/agents/runtime.ts +27 -0
  17. package/src/shared/den-config.ts +72 -1
  18. package/src/shared/git/gh-pr-state.ts +32 -0
  19. package/src/shared/plugins/manifest.ts +3 -0
  20. package/src/shared/usage/paths.ts +16 -0
  21. package/src/shared/usage/report.ts +377 -0
  22. package/src/shared/usage/sources.ts +127 -0
  23. package/src/shared/usage/transcript.ts +319 -0
  24. package/src/ui/cli/agent-ops.ts +678 -51
  25. package/src/ui/cli/agents-command.ts +192 -22
  26. package/src/ui/cli/index.ts +13 -1
  27. package/src/ui/cli/usage-command.ts +323 -0
  28. package/src/ui/mcp/api.ts +10 -1
  29. package/src/ui/mcp/server.ts +34 -0
  30. package/src/ui/mcp/tools.ts +50 -0
  31. package/src/ui/server/agent-resume-grant.ts +53 -0
  32. package/src/ui/server/agent-routes.ts +247 -9
  33. package/src/ui/server/audit.ts +68 -1
  34. package/src/ui/server/index.ts +16 -5
  35. package/src/ui/server/public/agents-page.js +400 -13
  36. package/src/ui/server/public/agents-section.js +17 -3
  37. package/src/ui/server/public/agents.html +44 -0
  38. package/src/ui/server/public/i18n/es.js +40 -0
  39. package/src/ui/server/public/i18n/ja.js +36 -0
  40. package/src/ui/server/public/shell.js +79 -0
  41. package/src/ui/server/request-guard.ts +21 -1
  42. package/src/ui/server/route-classes.ts +29 -0
  43. package/src/ui/server/session-routes.ts +60 -5
  44. package/src/ui/server/sudo-store.ts +36 -7
@@ -387,7 +387,43 @@
387
387
  "ag.cancel": "キャンセル",
388
388
  "ag.started": "エージェント {id} を開始しました",
389
389
  "ag.stopped": "エージェント {id} を停止しました",
390
+ "ag.stillStopping": "{id} はまだ停止中です",
391
+ "ag.resumedFresh": "{id}: まだ再開するものがないため、新しく開始しました",
392
+ "ag.inactive": "ワークスペース {ws} はアクティブではありません",
393
+ "ag.switchWs": "{ws} に切り替え",
394
+ "ag.unknownWs": "(その名前のワークスペースはありません)",
390
395
  "ag.resumed": "エージェント {id} を再開しました",
396
+ "ag.state.interrupted": "中断",
397
+ "ag.why.stopped": "{time} に停止しました",
398
+ "ag.why.interrupted": "中断: {name} が再起動しました",
399
+ "ag.why.exited": "終了しました",
400
+ "ag.why.exitedCode": "終了しました(コード {code})",
401
+ "ag.fresh": "新しく始める",
402
+ "ag.freshName": "{id} を新しく始める",
403
+ "ag.startedFresh": "{id} を新しく始めました",
404
+ "ag.rot.ready": "再起動の準備ができました({why})",
405
+ "ag.rot.due": "引き継ぎが必要です({why})",
406
+ "ag.rot.compactions": "圧縮 {n} 回",
407
+ "ag.rot.merged": "PR がマージされました",
408
+ "ag.rot.noHandoff":
409
+ "セッションは交代しました({why})が、handoff.md がありません。再開して書かせるか、新しく始めてください。",
410
+ "ag.rot.badHandoff":
411
+ "セッションは交代しました({why})が、handoff.md が不完全です。再開して直すか、新しく始めてください。",
412
+ "ag.relaunch": "引き継ぎから再起動",
413
+ "ag.relaunchName": "{id} を引き継ぎから再起動",
414
+ "ag.relaunched": "{id} を引き継ぎから再起動しました",
415
+ "ag.remove": "削除",
416
+ "ag.removeName": "{id} を削除",
417
+ "ag.removed": "{id} を削除しました",
418
+ "ag.removedMany": "停止中のエージェント {n} 件を削除しました",
419
+ "ag.undo": "元に戻す",
420
+ "ag.restored": "一覧に戻しました: {ids}",
421
+ "ag.clear": "停止中を削除",
422
+ "ag.noResume": "{runtime} は会話を再開できません",
423
+ "ag.interrupted": "{name} の再起動でエージェント {n} 件が中断されました",
424
+ "ag.resumeAll": "すべて再開",
425
+ "ag.review": "確認",
426
+ "ag.resumedAll": "エージェント {n} 件を再開しました",
391
427
  // The Agents tab (AG-4a-term, agents-page.js).
392
428
  "n.agents": "エージェント",
393
429
  "kind.agent": "エージェント",
@@ -511,7 +511,48 @@
511
511
  "ag.cancel": "Cancel",
512
512
  "ag.started": "Started agent {id}",
513
513
  "ag.stopped": "Stopped agent {id}",
514
+ "ag.stillStopping": "{id} is still stopping",
515
+ "ag.resumedFresh": "{id}: nothing to resume yet; started fresh",
516
+ "ag.inactive": "Workspace {ws} isn't active",
517
+ "ag.switchWs": "Switch to {ws}",
518
+ "ag.unknownWs": "(no workspace has that name)",
514
519
  "ag.resumed": "Resumed agent {id}",
520
+ "ag.state.interrupted": "interrupted",
521
+ "ag.why.stopped": "Stopped by you {time}",
522
+ "ag.why.interrupted": "Interrupted: {name} restarted",
523
+ "ag.why.exited": "Exited",
524
+ "ag.why.exitedCode": "Exited (code {code})",
525
+ "ag.fresh": "Start fresh",
526
+ "ag.freshName": "Start {id} fresh",
527
+ "ag.startedFresh": "Started {id} fresh",
528
+ "ag.remove": "Remove",
529
+ "ag.removeName": "Remove {id}",
530
+ "ag.removed": "Removed {id}",
531
+ "ag.removedMany": "Removed {n} stopped agents",
532
+ "ag.removedMany.one": "Removed {n} stopped agent",
533
+ "ag.undo": "Undo",
534
+ "ag.restored": "Back in the list: {ids}",
535
+ "ag.clear": "Clear stopped",
536
+ "ag.noResume": "{runtime} can't resume a conversation",
537
+ // Session rotation (den#4).
538
+ "ag.rot.ready": "Ready to relaunch ({why})",
539
+ "ag.rot.due": "Handoff due ({why})",
540
+ "ag.rot.compactions": "{n} compactions",
541
+ "ag.rot.compactions.one": "{n} compaction",
542
+ "ag.rot.merged": "PR merged",
543
+ "ag.rot.noHandoff":
544
+ "Its session rotated ({why}), but it wrote no handoff.md. Resume it to write one, or start fresh.",
545
+ "ag.rot.badHandoff":
546
+ "Its session rotated ({why}), but its handoff.md is not complete. Resume it to fix it, or start fresh.",
547
+ "ag.relaunch": "Relaunch from handoff",
548
+ "ag.relaunchName": "Relaunch {id} from handoff",
549
+ "ag.relaunched": "Relaunched {id} from its handoff",
550
+ "ag.interrupted": "{n} agents were interrupted when {name} restarted",
551
+ "ag.interrupted.one": "{n} agent was interrupted when {name} restarted",
552
+ "ag.resumeAll": "Resume all",
553
+ "ag.review": "Review",
554
+ "ag.resumedAll": "Resumed {n} agents",
555
+ "ag.resumedAll.one": "Resumed {n} agent",
515
556
  // The Agents tab (AG-4a-term, agents-page.js).
516
557
  "n.agents": "Agents",
517
558
  "kind.agent": "Agent",
@@ -1116,6 +1157,42 @@
1116
1157
  if (el.remove) el.remove();
1117
1158
  }, 2200);
1118
1159
  }
1160
+ /**
1161
+ * A toast with one action (AG-14.1b: Remove's Undo): the message and a
1162
+ * small button, for `ms` (default 10 s). The action runs once, and the
1163
+ * toast goes.
1164
+ */
1165
+ function toastAction(msg, label, onAction, ms) {
1166
+ if (!document.body || !document.createElement) return null;
1167
+ var old = document.querySelector && document.querySelector(".den-toast");
1168
+ if (old && old.remove) old.remove();
1169
+ var el = document.createElement("div");
1170
+ el.className = "den-toast den-toast-act";
1171
+ el.setAttribute("role", "status");
1172
+ var text = document.createElement("span");
1173
+ text.textContent = msg;
1174
+ var b = document.createElement("button");
1175
+ b.type = "button";
1176
+ b.className = "den-tbtn";
1177
+ b.textContent = label;
1178
+ var done = false;
1179
+ b.addEventListener("click", function () {
1180
+ if (done) return;
1181
+ done = true;
1182
+ clearTimeout(toastTimer);
1183
+ el.remove();
1184
+ onAction();
1185
+ });
1186
+ el.appendChild(text);
1187
+ el.appendChild(b);
1188
+ document.body.appendChild(el);
1189
+ clearTimeout(toastTimer);
1190
+ toastTimer = setTimeout(function () {
1191
+ done = true;
1192
+ if (el.remove) el.remove();
1193
+ }, ms || 10000);
1194
+ return el;
1195
+ }
1119
1196
 
1120
1197
  // ── menus (.pop, R18): the scope parts' popovers ─────────────────────
1121
1198
  // A menu is { label, items: [{ heading } | { hr: true } | { label, tag?,
@@ -3776,6 +3853,7 @@
3776
3853
  unpin: unpin,
3777
3854
  isPinned: isPinned,
3778
3855
  toast: toast,
3856
+ toastAction: toastAction,
3779
3857
  /** R26c: a call that resolves with its data or throws a DenApiError. */
3780
3858
  api: api,
3781
3859
  /** R26c: the error toast for a DenApiError (what an uncaught one gets). */
@@ -4168,6 +4246,7 @@
4168
4246
  // the prototype's small buttons (.btn.sm).
4169
4247
  ".den-toast-err { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px));",
4170
4248
  " border-color: color-mix(in srgb, var(--bad) 45%, var(--border-strong)); }",
4249
+ ".den-toast-act { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px)); }",
4171
4250
  ".den-toast-err .den-te-ic { color: var(--bad); }",
4172
4251
  ".den-toast-err .den-te-msg { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }",
4173
4252
  ".den-toast-err .den-te-id { font: 11px var(--mono); color: var(--faint); }",
@@ -17,7 +17,9 @@
17
17
  * and sign-in (/api/web/*, /auth, /api/session/logout), the lock (R8:
18
18
  * /api/sudo-password*), or a shell-grade route other than its own
19
19
  * session's /api/sessions/<id>/*: agent start and resume mint tokens.
20
- * 403 `agent_forbidden`. WS-3a (0.4.2) adds the workspace check at
20
+ * 403 `agent_forbidden`. One exception (AG-14.4): MCP agent_resume,
21
+ * when mcp.actions lists it, for another agent of its own workspace
22
+ * or a granted one (agent-resume-grant.ts). WS-3a (0.4.2) adds the workspace check at
21
23
  * agentRefusal's seam.
22
24
  * - Sign-in (§4, route-classes.ts): beyond loopback (a LAN name, a peer
23
25
  * that isn't loopback, or the edge hostname for a client on another
@@ -58,6 +60,8 @@
58
60
  */
59
61
  import { randomBytes, timingSafeEqual, createHash } from "crypto";
60
62
  import type * as http from "http";
63
+ import { readMcpConfig } from "../../shared/den-config.js";
64
+ import { agentResumeRefusal, agentResumeTarget } from "./agent-resume-grant.js";
61
65
  import * as net from "net";
62
66
  import * as os from "os";
63
67
  import {
@@ -707,11 +711,27 @@ export function agentRefusal(
707
711
  if (id === p.session.id || id === p.session.name) return null;
708
712
  return `${p.actor} can use its own session only, not ${id}.`;
709
713
  }
714
+ // AG-14.4: MCP agent_resume, opted in, inside the agent's scope.
715
+ const target = method === "POST" ? agentResumeTarget(pathname) : null;
716
+ if (target)
717
+ return (
718
+ agentResumeRefusal(p, target.ws, target.id, mcpActions()) ??
719
+ workspaceRefusal(p, pathname)
720
+ );
710
721
  if (cls === "shell-grade")
711
722
  return `${p.actor} can't ${method === "GET" ? "open" : "use"} ${pathname}: starting or resuming an agent, and the browser sessions, are a person's.`;
712
723
  return workspaceRefusal(p, pathname);
713
724
  }
714
725
 
726
+ /** The org config's mcp.actions; none when it can't be read. */
727
+ function mcpActions(): readonly string[] {
728
+ try {
729
+ return readMcpConfig().actions;
730
+ } catch {
731
+ return [];
732
+ }
733
+ }
734
+
715
735
  /** WS-3a's seam (0.4.2): an agent's route outside its workspace. Not yet. */
716
736
  function workspaceRefusal(_p: AgentPrincipal, _pathname: string): null {
717
737
  return null;
@@ -302,6 +302,32 @@ export const ROUTES: readonly RouteEntry[] = [
302
302
  M(["POST", "DELETE"], "/api/workspaces/services"),
303
303
  M(["POST"], "/api/workspaces/settings"),
304
304
  M(["POST"], "/api/workspaces/:ws/agents/:id/stop", "TERM to its session"),
305
+ M(
306
+ ["POST"],
307
+ "/api/workspaces/:ws/agents/:id/remove",
308
+ "forgets a stopped agent (AG-14.1b); its record waits for Undo",
309
+ ),
310
+ M(
311
+ ["POST"],
312
+ "/api/agents/clear-stopped",
313
+ "forgets every stopped agent of the workspaces named (AG-14.1b)",
314
+ ),
315
+ M(["POST"], "/api/agents/restore", "Undo: brings removed agents back"),
316
+ S(
317
+ ["POST"],
318
+ "/api/workspaces/:ws/agents/:id/fresh",
319
+ "starts an agent afresh: a runtime in a PTY",
320
+ ),
321
+ S(
322
+ ["POST"],
323
+ "/api/workspaces/:ws/agents/:id/relaunch",
324
+ "relaunches a rotated agent from its handoff: a runtime in a PTY (den#4)",
325
+ ),
326
+ S(
327
+ ["POST"],
328
+ "/api/agents/resume-interrupted",
329
+ "resumes interrupted agents: runtimes in PTYs (AG-14.3)",
330
+ ),
305
331
  S(
306
332
  ["POST"],
307
333
  "/api/workspaces/:ws/agents",
@@ -378,6 +404,9 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
378
404
  "/api/workspaces/:ws/agents",
379
405
  "/api/workspaces/:ws/agents/:id/stop",
380
406
  "/api/workspaces/:ws/agents/:id/resume",
407
+ "/api/workspaces/:ws/agents/:id/fresh",
408
+ "/api/workspaces/:ws/agents/:id/relaunch",
409
+ "/api/workspaces/:ws/agents/:id/remove",
381
410
  ],
382
411
  "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
383
412
  "session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
@@ -35,11 +35,15 @@
35
35
  */
36
36
  import type * as http from "http";
37
37
  import type { Duplex } from "stream";
38
- import { getActiveWorkspaces } from "../../shared/environment-manager/workspaces.js";
38
+ import {
39
+ getActiveWorkspaces,
40
+ readWorkspacesFile,
41
+ } from "../../shared/environment-manager/workspaces.js";
39
42
  import { getStateDir } from "../../shared/paths.js";
40
43
  import { product } from "../../shared/product.js";
41
44
  import { revokedAgentIds } from "../../shared/actor-token.js";
42
45
  import { listRecords } from "../../shared/agents/records.js";
46
+ import type { RotationInfo } from "../../shared/agents/rotation.js";
43
47
  import {
44
48
  connectHost,
45
49
  noHost,
@@ -49,7 +53,7 @@ import { parseSessionName } from "../../shared/sessions/names.js";
49
53
  import { sessionPaths } from "../../shared/sessions/paths.js";
50
54
  import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
51
55
  import type { SessionInfo } from "../../shared/sessions/types.js";
52
- import { agentRows } from "../cli/agent-ops.js";
56
+ import { agentRows, noteEnded } from "../cli/agent-ops.js";
53
57
  import { state as sessionState } from "../cli/sessions-command.js";
54
58
  import { recordAction } from "./audit.js";
55
59
  import { sendJson } from "./http-helpers.js";
@@ -68,6 +72,8 @@ export interface SessionRouteDeps {
68
72
  /** A client of the running host, or null (never starts one). */
69
73
  connectExisting?: () => Promise<HostClient | null>;
70
74
  activeWorkspaces?: () => string[];
75
+ /** Whether workspaces.yml names `ws` (active or not). */
76
+ workspaceExists?: (ws: string) => boolean;
71
77
  pty?: () => PtyLoad;
72
78
  }
73
79
 
@@ -76,9 +82,13 @@ interface Resolved {
76
82
  dist: () => string;
77
83
  connectExisting: () => Promise<HostClient | null>;
78
84
  activeWorkspaces: () => string[];
85
+ workspaceExists: (ws: string) => boolean;
79
86
  pty: () => PtyLoad;
80
87
  }
81
88
 
89
+ /** A workspace name as `?ws=` may give it. */
90
+ const WS_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
91
+
82
92
  function resolve(d: SessionRouteDeps = {}): Resolved {
83
93
  const stateDir = d.stateDir ?? (() => getStateDir());
84
94
  return {
@@ -95,6 +105,13 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
95
105
  }
96
106
  }),
97
107
  activeWorkspaces: d.activeWorkspaces ?? (() => getActiveWorkspaces()),
108
+ workspaceExists:
109
+ d.workspaceExists ??
110
+ ((ws) =>
111
+ Object.prototype.hasOwnProperty.call(
112
+ readWorkspacesFile().workspaces ?? {},
113
+ ws,
114
+ )),
98
115
  pty: d.pty ?? (() => loadPty()),
99
116
  };
100
117
  }
@@ -119,7 +136,7 @@ export interface ListedSession {
119
136
  runtime?: string;
120
137
  model?: string;
121
138
  cwd?: string;
122
- /** running, stopping, exited (<code>), stopped, ended. */
139
+ /** running, stopping, exited (<code>), stopped, interrupted (AG-14). */
123
140
  state: string;
124
141
  exited: boolean;
125
142
  exitCode?: number;
@@ -127,19 +144,33 @@ export interface ListedSession {
127
144
  /** The host has it (running, or ended with its scrollback): a pane can attach. */
128
145
  attachable: boolean;
129
146
  resumable?: boolean;
147
+ /** Why only Start fresh applies (AG-14.2): "<runtime> can't resume a conversation". */
148
+ noResume?: string;
149
+ /** When it was stopped, seen to end, or found interrupted (AG-14.1). */
150
+ stoppedAt?: string;
151
+ endedAt?: string;
152
+ interruptedAt?: string;
130
153
  /** Its actor token was revoked (`web logout --all`): restart or resume it. */
131
154
  tokenRevoked?: boolean;
132
155
  /** An approval is pending (AG-6a; only when the host knows). */
133
156
  waiting?: boolean;
157
+ /**
158
+ * Session rotation (den#4): why it rotates and its handoff.md. `ready`:
159
+ * Relaunch from handoff is its action, not Resume.
160
+ */
161
+ rotation?: RotationInfo;
134
162
  }
135
163
 
136
164
  /** The list, from the host's sessions and the agent records. */
137
165
  export function listedSessions(
138
166
  sessions: SessionInfo[],
139
167
  d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces">,
168
+ /** A workspace asked for by name (`?ws=`), listed even when inactive. */
169
+ also?: string,
140
170
  ): ListedSession[] {
141
171
  const dist = d.dist();
142
172
  const active = new Set(d.activeWorkspaces());
173
+ if (also) active.add(also);
143
174
  const mine = sessions.filter((s) => {
144
175
  const p = parseSessionName(s.name, dist);
145
176
  return p !== null && active.has(p.workspace);
@@ -150,7 +181,8 @@ export function listedSessions(
150
181
  const revoked = revokedAgentIds(d.stateDir());
151
182
  const out: ListedSession[] = [];
152
183
  const agentSessions = new Set<string>();
153
- for (const r of agentRows(mine, records)) {
184
+ noteEnded(d.stateDir(), mine, records);
185
+ for (const r of agentRows(mine, records, d.stateDir())) {
154
186
  const rec = records.find((x) => x.id === r.agent)!;
155
187
  const s = r.session;
156
188
  if (s) agentSessions.add(s.ref.id);
@@ -171,6 +203,11 @@ export function listedSessions(
171
203
  ...(r.startedAt ? { startedAt: r.startedAt } : {}),
172
204
  attachable: Boolean(s),
173
205
  resumable: r.resumable,
206
+ ...(r.noResume ? { noResume: r.noResume } : {}),
207
+ ...(r.stoppedAt ? { stoppedAt: r.stoppedAt } : {}),
208
+ ...(r.endedAt ? { endedAt: r.endedAt } : {}),
209
+ ...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
210
+ ...(r.rotation?.due ? { rotation: r.rotation } : {}),
174
211
  ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
175
212
  });
176
213
  }
@@ -235,9 +272,27 @@ export async function handleSessionList(
235
272
  } catch (e) {
236
273
  hostError = (e as Error).message;
237
274
  }
275
+ // `?ws=<name>`: that workspace's too, active or not, and whether it is
276
+ // active and known (the page says so rather than show all of them).
277
+ const asked = new URL(req.url ?? "/", "http://localhost").searchParams.get(
278
+ "ws",
279
+ );
280
+ const wanted =
281
+ asked && WS_NAME.test(asked)
282
+ ? {
283
+ workspace: asked,
284
+ active: d.activeWorkspaces().includes(asked),
285
+ known: d.workspaceExists(asked),
286
+ }
287
+ : null;
238
288
  sendJson(res, {
239
- sessions: listedSessions(sessions, d),
289
+ sessions: listedSessions(
290
+ sessions,
291
+ d,
292
+ wanted?.known ? wanted.workspace : undefined,
293
+ ),
240
294
  workspaces: d.activeWorkspaces(),
295
+ ...(wanted ? { requested: wanted } : {}),
241
296
  host,
242
297
  ...(hostError ? { hostError } : {}),
243
298
  backend: pty.available
@@ -32,7 +32,7 @@ import { AsyncLocalStorage } from "async_hooks";
32
32
  import * as fs from "fs";
33
33
  import * as os from "os";
34
34
  import * as path from "path";
35
- import { execSync } from "child_process";
35
+ import { execFileSync } from "child_process";
36
36
  import { CLI_SPAWN_TIMEOUT_MS } from "../../shared/constants.js";
37
37
 
38
38
  import { product } from "../../shared/product.js";
@@ -62,11 +62,40 @@ function shimDirPrefix(): string {
62
62
  return path.join(os.tmpdir(), `${product().cli}-sudo-`);
63
63
  }
64
64
 
65
+ /** The system's sudo: what Den runs whenever it is there. */
66
+ export const SYSTEM_SUDO = "/usr/bin/sudo";
67
+
68
+ /**
69
+ * Where the system's sudo is: /usr/bin/sudo, or DEN_SYSTEM_SUDO when set
70
+ * to an absolute path (a machine whose sudo lives elsewhere, and the
71
+ * tests' fake sudo). Never a name looked up on PATH.
72
+ */
73
+ export function systemSudo(env: NodeJS.ProcessEnv = process.env): string {
74
+ const v = env.DEN_SYSTEM_SUDO;
75
+ return v && path.isAbsolute(v) ? v : SYSTEM_SUDO;
76
+ }
77
+
78
+ function isExecutableFile(file: string): boolean {
79
+ try {
80
+ fs.accessSync(file, fs.constants.X_OK);
81
+ return fs.statSync(file).isFile();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
65
87
  /**
66
- * The real sudo: the first `sudo` on PATH that isn't one of Den's shims,
67
- * else /usr/bin/sudo. Read when a shim is written.
88
+ * The real sudo: /usr/bin/sudo (systemSudo), pinned, so a `sudo` earlier
89
+ * on PATH (a directory a user or a tool can write to) never receives the
90
+ * admin password. Only where there is none there: the first `sudo` on
91
+ * PATH that isn't one of Den's shims. Read when a shim is written, and by
92
+ * every check Den runs itself.
68
93
  */
69
- export function realSudoPath(envPath: string = process.env.PATH ?? ""): string {
94
+ export function realSudoPath(
95
+ envPath: string = process.env.PATH ?? "",
96
+ system: string = systemSudo(),
97
+ ): string {
98
+ if (isExecutableFile(system)) return system;
70
99
  const prefix = shimDirPrefix();
71
100
  for (const dir of envPath.split(path.delimiter)) {
72
101
  if (!dir || dir.startsWith(prefix)) continue;
@@ -78,7 +107,7 @@ export function realSudoPath(envPath: string = process.env.PATH ?? ""): string {
78
107
  /* not here */
79
108
  }
80
109
  }
81
- return "/usr/bin/sudo";
110
+ return system;
82
111
  }
83
112
 
84
113
  /** A path inside single quotes, for a shim. */
@@ -254,7 +283,7 @@ export function watchSudo(
254
283
  export function prewarmSudo(): void {
255
284
  if (!_sudoPassword || lockClosedHere()) return;
256
285
  try {
257
- execSync("sudo -S -v", {
286
+ execFileSync(realSudoPath(), ["-S", "-v"], {
258
287
  input: _sudoPassword + "\n",
259
288
  stdio: ["pipe", "pipe", "pipe"],
260
289
  timeout: CLI_SPAWN_TIMEOUT_MS,
@@ -288,7 +317,7 @@ export function hasPasswordlessSudo(): boolean {
288
317
  }
289
318
  let value = false;
290
319
  try {
291
- execSync("sudo -n true", {
320
+ execFileSync(realSudoPath(), ["-n", "true"], {
292
321
  stdio: ["ignore", "ignore", "ignore"],
293
322
  timeout: CLI_SPAWN_TIMEOUT_MS,
294
323
  });