@monster-orange/den 0.4.6 → 0.4.8
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 +160 -0
- package/README.md +7 -7
- package/docs/distributions.md +244 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/skills/install-den/SKILL.md +2 -2
- package/src/shared/agents/den-claude.ts +38 -5
- package/src/shared/agents/guardrails/hooks.json +27 -0
- package/src/shared/agents/guardrails/instructions.md +39 -0
- package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
- package/src/shared/agents/guardrails/stop-hook.ts +66 -0
- package/src/shared/agents/guardrails.ts +114 -0
- package/src/shared/agents/handback.ts +241 -0
- package/src/shared/agents/personas.ts +66 -7
- package/src/shared/agents/records.ts +114 -0
- package/src/shared/agents/runtime.ts +12 -0
- package/src/shared/den-config.ts +97 -6
- package/src/shared/dev-metrics/emit.ts +31 -4
- package/src/shared/dev-metrics/polling.ts +135 -0
- package/src/shared/engine.ts +14 -2
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +283 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +262 -0
- package/src/ui/cli/agent-ops.ts +495 -53
- package/src/ui/cli/agents-command.ts +170 -14
- package/src/ui/cli/doctor-command.ts +20 -0
- package/src/ui/cli/flag-parser.ts +1 -0
- package/src/ui/cli/index.ts +119 -2
- package/src/ui/cli/usage-command.ts +256 -0
- package/src/ui/cli/web-command.ts +3 -1
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +230 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +333 -11
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +28 -0
- package/src/ui/server/public/i18n/ja.js +25 -0
- package/src/ui/server/public/shell.js +66 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +23 -0
- package/src/ui/server/session-routes.ts +52 -4
- package/src/ui/server/sudo-store.ts +36 -7
|
@@ -511,7 +511,35 @@
|
|
|
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
|
+
"ag.interrupted": "{n} agents were interrupted when {name} restarted",
|
|
538
|
+
"ag.interrupted.one": "{n} agent was interrupted when {name} restarted",
|
|
539
|
+
"ag.resumeAll": "Resume all",
|
|
540
|
+
"ag.review": "Review",
|
|
541
|
+
"ag.resumedAll": "Resumed {n} agents",
|
|
542
|
+
"ag.resumedAll.one": "Resumed {n} agent",
|
|
515
543
|
// The Agents tab (AG-4a-term, agents-page.js).
|
|
516
544
|
"n.agents": "Agents",
|
|
517
545
|
"kind.agent": "Agent",
|
|
@@ -1116,6 +1144,42 @@
|
|
|
1116
1144
|
if (el.remove) el.remove();
|
|
1117
1145
|
}, 2200);
|
|
1118
1146
|
}
|
|
1147
|
+
/**
|
|
1148
|
+
* A toast with one action (AG-14.1b: Remove's Undo): the message and a
|
|
1149
|
+
* small button, for `ms` (default 10 s). The action runs once, and the
|
|
1150
|
+
* toast goes.
|
|
1151
|
+
*/
|
|
1152
|
+
function toastAction(msg, label, onAction, ms) {
|
|
1153
|
+
if (!document.body || !document.createElement) return null;
|
|
1154
|
+
var old = document.querySelector && document.querySelector(".den-toast");
|
|
1155
|
+
if (old && old.remove) old.remove();
|
|
1156
|
+
var el = document.createElement("div");
|
|
1157
|
+
el.className = "den-toast den-toast-act";
|
|
1158
|
+
el.setAttribute("role", "status");
|
|
1159
|
+
var text = document.createElement("span");
|
|
1160
|
+
text.textContent = msg;
|
|
1161
|
+
var b = document.createElement("button");
|
|
1162
|
+
b.type = "button";
|
|
1163
|
+
b.className = "den-tbtn";
|
|
1164
|
+
b.textContent = label;
|
|
1165
|
+
var done = false;
|
|
1166
|
+
b.addEventListener("click", function () {
|
|
1167
|
+
if (done) return;
|
|
1168
|
+
done = true;
|
|
1169
|
+
clearTimeout(toastTimer);
|
|
1170
|
+
el.remove();
|
|
1171
|
+
onAction();
|
|
1172
|
+
});
|
|
1173
|
+
el.appendChild(text);
|
|
1174
|
+
el.appendChild(b);
|
|
1175
|
+
document.body.appendChild(el);
|
|
1176
|
+
clearTimeout(toastTimer);
|
|
1177
|
+
toastTimer = setTimeout(function () {
|
|
1178
|
+
done = true;
|
|
1179
|
+
if (el.remove) el.remove();
|
|
1180
|
+
}, ms || 10000);
|
|
1181
|
+
return el;
|
|
1182
|
+
}
|
|
1119
1183
|
|
|
1120
1184
|
// ── menus (.pop, R18): the scope parts' popovers ─────────────────────
|
|
1121
1185
|
// A menu is { label, items: [{ heading } | { hr: true } | { label, tag?,
|
|
@@ -3776,6 +3840,7 @@
|
|
|
3776
3840
|
unpin: unpin,
|
|
3777
3841
|
isPinned: isPinned,
|
|
3778
3842
|
toast: toast,
|
|
3843
|
+
toastAction: toastAction,
|
|
3779
3844
|
/** R26c: a call that resolves with its data or throws a DenApiError. */
|
|
3780
3845
|
api: api,
|
|
3781
3846
|
/** R26c: the error toast for a DenApiError (what an uncaught one gets). */
|
|
@@ -4168,6 +4233,7 @@
|
|
|
4168
4233
|
// the prototype's small buttons (.btn.sm).
|
|
4169
4234
|
".den-toast-err { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px));",
|
|
4170
4235
|
" border-color: color-mix(in srgb, var(--bad) 45%, var(--border-strong)); }",
|
|
4236
|
+
".den-toast-act { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px)); }",
|
|
4171
4237
|
".den-toast-err .den-te-ic { color: var(--bad); }",
|
|
4172
4238
|
".den-toast-err .den-te-msg { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }",
|
|
4173
4239
|
".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`.
|
|
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,27 @@ 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/agents/resume-interrupted",
|
|
324
|
+
"resumes interrupted agents: runtimes in PTYs (AG-14.3)",
|
|
325
|
+
),
|
|
305
326
|
S(
|
|
306
327
|
["POST"],
|
|
307
328
|
"/api/workspaces/:ws/agents",
|
|
@@ -378,6 +399,8 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
|
|
|
378
399
|
"/api/workspaces/:ws/agents",
|
|
379
400
|
"/api/workspaces/:ws/agents/:id/stop",
|
|
380
401
|
"/api/workspaces/:ws/agents/:id/resume",
|
|
402
|
+
"/api/workspaces/:ws/agents/:id/fresh",
|
|
403
|
+
"/api/workspaces/:ws/agents/:id/remove",
|
|
381
404
|
],
|
|
382
405
|
"agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
|
|
383
406
|
"session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
|
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import type * as http from "http";
|
|
37
37
|
import type { Duplex } from "stream";
|
|
38
|
-
import {
|
|
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";
|
|
@@ -49,7 +52,7 @@ import { parseSessionName } from "../../shared/sessions/names.js";
|
|
|
49
52
|
import { sessionPaths } from "../../shared/sessions/paths.js";
|
|
50
53
|
import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
|
|
51
54
|
import type { SessionInfo } from "../../shared/sessions/types.js";
|
|
52
|
-
import { agentRows } from "../cli/agent-ops.js";
|
|
55
|
+
import { agentRows, noteEnded } from "../cli/agent-ops.js";
|
|
53
56
|
import { state as sessionState } from "../cli/sessions-command.js";
|
|
54
57
|
import { recordAction } from "./audit.js";
|
|
55
58
|
import { sendJson } from "./http-helpers.js";
|
|
@@ -68,6 +71,8 @@ export interface SessionRouteDeps {
|
|
|
68
71
|
/** A client of the running host, or null (never starts one). */
|
|
69
72
|
connectExisting?: () => Promise<HostClient | null>;
|
|
70
73
|
activeWorkspaces?: () => string[];
|
|
74
|
+
/** Whether workspaces.yml names `ws` (active or not). */
|
|
75
|
+
workspaceExists?: (ws: string) => boolean;
|
|
71
76
|
pty?: () => PtyLoad;
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -76,9 +81,13 @@ interface Resolved {
|
|
|
76
81
|
dist: () => string;
|
|
77
82
|
connectExisting: () => Promise<HostClient | null>;
|
|
78
83
|
activeWorkspaces: () => string[];
|
|
84
|
+
workspaceExists: (ws: string) => boolean;
|
|
79
85
|
pty: () => PtyLoad;
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/** A workspace name as `?ws=` may give it. */
|
|
89
|
+
const WS_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
90
|
+
|
|
82
91
|
function resolve(d: SessionRouteDeps = {}): Resolved {
|
|
83
92
|
const stateDir = d.stateDir ?? (() => getStateDir());
|
|
84
93
|
return {
|
|
@@ -95,6 +104,13 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
|
|
|
95
104
|
}
|
|
96
105
|
}),
|
|
97
106
|
activeWorkspaces: d.activeWorkspaces ?? (() => getActiveWorkspaces()),
|
|
107
|
+
workspaceExists:
|
|
108
|
+
d.workspaceExists ??
|
|
109
|
+
((ws) =>
|
|
110
|
+
Object.prototype.hasOwnProperty.call(
|
|
111
|
+
readWorkspacesFile().workspaces ?? {},
|
|
112
|
+
ws,
|
|
113
|
+
)),
|
|
98
114
|
pty: d.pty ?? (() => loadPty()),
|
|
99
115
|
};
|
|
100
116
|
}
|
|
@@ -119,7 +135,7 @@ export interface ListedSession {
|
|
|
119
135
|
runtime?: string;
|
|
120
136
|
model?: string;
|
|
121
137
|
cwd?: string;
|
|
122
|
-
/** running, stopping, exited (<code>), stopped,
|
|
138
|
+
/** running, stopping, exited (<code>), stopped, interrupted (AG-14). */
|
|
123
139
|
state: string;
|
|
124
140
|
exited: boolean;
|
|
125
141
|
exitCode?: number;
|
|
@@ -127,6 +143,12 @@ export interface ListedSession {
|
|
|
127
143
|
/** The host has it (running, or ended with its scrollback): a pane can attach. */
|
|
128
144
|
attachable: boolean;
|
|
129
145
|
resumable?: boolean;
|
|
146
|
+
/** Why only Start fresh applies (AG-14.2): "<runtime> can't resume a conversation". */
|
|
147
|
+
noResume?: string;
|
|
148
|
+
/** When it was stopped, seen to end, or found interrupted (AG-14.1). */
|
|
149
|
+
stoppedAt?: string;
|
|
150
|
+
endedAt?: string;
|
|
151
|
+
interruptedAt?: string;
|
|
130
152
|
/** Its actor token was revoked (`web logout --all`): restart or resume it. */
|
|
131
153
|
tokenRevoked?: boolean;
|
|
132
154
|
/** An approval is pending (AG-6a; only when the host knows). */
|
|
@@ -137,9 +159,12 @@ export interface ListedSession {
|
|
|
137
159
|
export function listedSessions(
|
|
138
160
|
sessions: SessionInfo[],
|
|
139
161
|
d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces">,
|
|
162
|
+
/** A workspace asked for by name (`?ws=`), listed even when inactive. */
|
|
163
|
+
also?: string,
|
|
140
164
|
): ListedSession[] {
|
|
141
165
|
const dist = d.dist();
|
|
142
166
|
const active = new Set(d.activeWorkspaces());
|
|
167
|
+
if (also) active.add(also);
|
|
143
168
|
const mine = sessions.filter((s) => {
|
|
144
169
|
const p = parseSessionName(s.name, dist);
|
|
145
170
|
return p !== null && active.has(p.workspace);
|
|
@@ -150,6 +175,7 @@ export function listedSessions(
|
|
|
150
175
|
const revoked = revokedAgentIds(d.stateDir());
|
|
151
176
|
const out: ListedSession[] = [];
|
|
152
177
|
const agentSessions = new Set<string>();
|
|
178
|
+
noteEnded(d.stateDir(), mine, records);
|
|
153
179
|
for (const r of agentRows(mine, records)) {
|
|
154
180
|
const rec = records.find((x) => x.id === r.agent)!;
|
|
155
181
|
const s = r.session;
|
|
@@ -171,6 +197,10 @@ export function listedSessions(
|
|
|
171
197
|
...(r.startedAt ? { startedAt: r.startedAt } : {}),
|
|
172
198
|
attachable: Boolean(s),
|
|
173
199
|
resumable: r.resumable,
|
|
200
|
+
...(r.noResume ? { noResume: r.noResume } : {}),
|
|
201
|
+
...(r.stoppedAt ? { stoppedAt: r.stoppedAt } : {}),
|
|
202
|
+
...(r.endedAt ? { endedAt: r.endedAt } : {}),
|
|
203
|
+
...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
|
|
174
204
|
...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
|
|
175
205
|
});
|
|
176
206
|
}
|
|
@@ -235,9 +265,27 @@ export async function handleSessionList(
|
|
|
235
265
|
} catch (e) {
|
|
236
266
|
hostError = (e as Error).message;
|
|
237
267
|
}
|
|
268
|
+
// `?ws=<name>`: that workspace's too, active or not, and whether it is
|
|
269
|
+
// active and known (the page says so rather than show all of them).
|
|
270
|
+
const asked = new URL(req.url ?? "/", "http://localhost").searchParams.get(
|
|
271
|
+
"ws",
|
|
272
|
+
);
|
|
273
|
+
const wanted =
|
|
274
|
+
asked && WS_NAME.test(asked)
|
|
275
|
+
? {
|
|
276
|
+
workspace: asked,
|
|
277
|
+
active: d.activeWorkspaces().includes(asked),
|
|
278
|
+
known: d.workspaceExists(asked),
|
|
279
|
+
}
|
|
280
|
+
: null;
|
|
238
281
|
sendJson(res, {
|
|
239
|
-
sessions: listedSessions(
|
|
282
|
+
sessions: listedSessions(
|
|
283
|
+
sessions,
|
|
284
|
+
d,
|
|
285
|
+
wanted?.known ? wanted.workspace : undefined,
|
|
286
|
+
),
|
|
240
287
|
workspaces: d.activeWorkspaces(),
|
|
288
|
+
...(wanted ? { requested: wanted } : {}),
|
|
241
289
|
host,
|
|
242
290
|
...(hostError ? { hostError } : {}),
|
|
243
291
|
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 {
|
|
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:
|
|
67
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
execFileSync(realSudoPath(), ["-n", "true"], {
|
|
292
321
|
stdio: ["ignore", "ignore", "ignore"],
|
|
293
322
|
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
294
323
|
});
|