@monster-orange/den 0.4.3 → 0.4.4

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 (58) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +215 -65
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +14 -18
  8. package/src/shared/agents/runtime-search.ts +142 -15
  9. package/src/shared/agents/runtime.ts +18 -1
  10. package/src/shared/agents/runtimes.ts +23 -1
  11. package/src/shared/auth/sudo-vault.ts +16 -0
  12. package/src/shared/den-config.ts +57 -3
  13. package/src/shared/edge/status.ts +134 -0
  14. package/src/shared/environment-manager/health.ts +82 -8
  15. package/src/shared/environment-manager/workspaces.ts +54 -0
  16. package/src/shared/health/not-checked.ts +42 -0
  17. package/src/shared/health/rollup.ts +4 -0
  18. package/src/shared/process/spawn-streaming.ts +8 -3
  19. package/src/shared/sessions/client.ts +5 -1
  20. package/src/shared/sessions/host.ts +29 -1
  21. package/src/shared/sessions/protocol.ts +6 -1
  22. package/src/shared/sessions/types.ts +6 -0
  23. package/src/shared/stacks/status.ts +24 -0
  24. package/src/shared/strategies/registry.ts +5 -0
  25. package/src/shared/types/index.ts +6 -0
  26. package/src/ui/cli/agent-ops.ts +210 -19
  27. package/src/ui/cli/agents-command.ts +50 -21
  28. package/src/ui/cli/edge-command.ts +16 -3
  29. package/src/ui/cli/health-display.ts +4 -1
  30. package/src/ui/mcp/scope.ts +23 -3
  31. package/src/ui/mcp/server.ts +31 -7
  32. package/src/ui/mcp/tools.ts +327 -20
  33. package/src/ui/mcp/views.ts +450 -0
  34. package/src/ui/server/agent-routes.ts +118 -28
  35. package/src/ui/server/index.ts +77 -6
  36. package/src/ui/server/public/agent-start.js +46 -7
  37. package/src/ui/server/public/agents-page.js +232 -97
  38. package/src/ui/server/public/agents-section.js +3 -5
  39. package/src/ui/server/public/agents.html +12 -28
  40. package/src/ui/server/public/graph-model.js +2 -0
  41. package/src/ui/server/public/header.js +2 -3
  42. package/src/ui/server/public/health-rollup.js +20 -8
  43. package/src/ui/server/public/i18n/es.js +15 -13
  44. package/src/ui/server/public/i18n/ja.js +14 -13
  45. package/src/ui/server/public/index.html +17 -4
  46. package/src/ui/server/public/local-dev.html +28 -17
  47. package/src/ui/server/public/services.html +21 -5
  48. package/src/ui/server/public/settings-page.js +3 -2
  49. package/src/ui/server/public/settings-registry.js +225 -0
  50. package/src/ui/server/public/settings.html +90 -1
  51. package/src/ui/server/public/shell.js +28 -23
  52. package/src/ui/server/public/topbar.js +166 -206
  53. package/src/ui/server/readonly-routes.ts +29 -5
  54. package/src/ui/server/request-guard.ts +1 -1
  55. package/src/ui/server/route-classes.ts +14 -2
  56. package/src/ui/server/session-routes.ts +9 -1
  57. package/src/ui/server/sudo-store.ts +170 -6
  58. package/src/ui/server/workspace-routes.ts +10 -3
@@ -145,6 +145,7 @@ export const ROUTES: readonly RouteEntry[] = [
145
145
  "/api/graph",
146
146
  "/api/applications",
147
147
  "/api/personas",
148
+ "/api/agents/runtimes",
148
149
  "/api/workspaces/:ws/agents",
149
150
  ].map((p) => R(p)),
150
151
  R(
@@ -209,8 +210,13 @@ export const ROUTES: readonly RouteEntry[] = [
209
210
  M(["POST", "DELETE"], "/api/release/deploy", "starts or dismisses a deploy"),
210
211
  M(["POST"], "/api/manifest/validate", "validates posted content (no write)"),
211
212
  M(["POST"], "/api/local-dev"),
212
- M(["POST"], "/api/sudo-password", "opens the lock (R8)"),
213
- M(["POST"], "/api/sudo-password/load-from-vault", "opens the lock (R8)"),
213
+ M(["POST"], "/api/sudo-password", "sets the admin password, for sudo (R8)"),
214
+ M(
215
+ ["POST"],
216
+ "/api/sudo-password/load-from-vault",
217
+ "sets the admin password from the vault, for sudo (R8)",
218
+ ),
219
+ M(["POST"], "/api/sudo-password/forget", "forgets the admin password (R8)"),
214
220
  M(["POST"], "/api/scope"),
215
221
  M(["POST"], "/api/settings"),
216
222
  M(
@@ -241,6 +247,11 @@ export const ROUTES: readonly RouteEntry[] = [
241
247
  "/api/workspaces/:ws/agents/:id/resume",
242
248
  "resumes an agent: a runtime in a PTY",
243
249
  ),
250
+ S(
251
+ ["POST"],
252
+ "/api/agents/runtimes/:id/path",
253
+ "runs the path given (its version command) before saving it as a runtime's CLI (AG-0.5)",
254
+ ),
244
255
  S(
245
256
  ["GET", "HEAD"],
246
257
  "/api/sessions/:id/io",
@@ -272,6 +283,7 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
272
283
  "/api/workspaces/:ws/agents/:id/stop",
273
284
  "/api/workspaces/:ws/agents/:id/resume",
274
285
  ],
286
+ "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
275
287
  "session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
276
288
  };
277
289
 
@@ -18,6 +18,10 @@
18
18
  * - on attach, the scrollback replay, then live output: binary frames;
19
19
  * - a text frame `{"type":"session",…}` first (what is attached), and
20
20
  * `{"type":"exit","exitCode":n}` when the session ends;
21
+ * - `{"type":"size","cols":c,"rows":r}`: the PTY's size, on attach and
22
+ * each time any viewer (another browser, a CLI attach) resizes it. The
23
+ * size is last-writer-wins; a pane that learns it is out of step says
24
+ * its own again when it is used (focused, typed in, shown);
21
25
  * - in: binary frames are input; a text frame is JSON control:
22
26
  * `{"type":"resize","cols":c,"rows":r}` (or `{"type":"input","data":s}`).
23
27
  * Closing the socket detaches. Nothing here ever signals a session: leaving
@@ -391,6 +395,9 @@ export async function handleSessionUpgrade(
391
395
  let exited = s.exited === true;
392
396
  const att = client.attach(s.ref.id, {
393
397
  onData: (data) => ws.send(data),
398
+ onSize: (cols, rows) => {
399
+ if (ws.open) ws.send(JSON.stringify({ type: "size", cols, rows }));
400
+ },
394
401
  onExit: (exitCode) => {
395
402
  exited = true;
396
403
  ws.send(
@@ -417,8 +424,9 @@ export async function handleSessionUpgrade(
417
424
  return;
418
425
  }
419
426
  if (msg?.type === "resize") {
427
+ // Never below 2x2: a pane measured before it was laid out.
420
428
  const ok = (n: unknown): n is number =>
421
- typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 1000;
429
+ typeof n === "number" && Number.isInteger(n) && n >= 2 && n <= 1000;
422
430
  if (!exited && ok(msg.cols) && ok(msg.rows))
423
431
  client.resize(s.ref.id, msg.cols, msg.rows);
424
432
  } else if (msg?.type === "input" && typeof msg.data === "string") {
@@ -4,6 +4,16 @@
4
4
  * Keeps the user's sudo password in memory so spawned service commands
5
5
  * (make up / down / etc.) can authenticate without a terminal.
6
6
  *
7
+ * LK-0 (Ward, 2026-11-06): the password is for sudo, and nothing else. It
8
+ * is never an access check. Den asks for it only at the moment a command
9
+ * it runs is about to run sudo: every service command gets a `sudo` shim
10
+ * first on its PATH (getSudoEnv). With a password held, the shim feeds it
11
+ * to the real sudo. Without one, sudo that needs no password (NOPASSWD, a
12
+ * warm cache) still runs; anything else never prompts, it prints one
13
+ * needs-sudo line naming the command and fails. The route turns that line
14
+ * (or sudo's own "a password is required") into a `needs-sudo` event
15
+ * (watchSudo), and the page asks for the password, then runs it again.
16
+ *
7
17
  * Strategy: pre-warm the sudo credential cache immediately before every
8
18
  * service spawn. This is more reliable than SUDO_ASKPASS env-var injection
9
19
  * because intermediate launchers (opctl, make, etc.) may reset the environment
@@ -45,22 +55,95 @@ export function lockClosedHere(): boolean {
45
55
  return lockScope.getStore()?.closed === true;
46
56
  }
47
57
  let _sudoDir: string | null = null;
58
+ let _askDir: string | null = null;
59
+
60
+ /** Den's own sudo shim dirs (never the real sudo). */
61
+ function shimDirPrefix(): string {
62
+ return path.join(os.tmpdir(), `${product().cli}-sudo-`);
63
+ }
64
+
65
+ /**
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.
68
+ */
69
+ export function realSudoPath(envPath: string = process.env.PATH ?? ""): string {
70
+ const prefix = shimDirPrefix();
71
+ for (const dir of envPath.split(path.delimiter)) {
72
+ if (!dir || dir.startsWith(prefix)) continue;
73
+ const file = path.join(dir, "sudo");
74
+ try {
75
+ fs.accessSync(file, fs.constants.X_OK);
76
+ if (fs.statSync(file).isFile()) return file;
77
+ } catch {
78
+ /* not here */
79
+ }
80
+ }
81
+ return "/usr/bin/sudo";
82
+ }
83
+
84
+ /** A path inside single quotes, for a shim. */
85
+ function shq(s: string): string {
86
+ return `'${s.replace(/'/g, `'\\''`)}'`;
87
+ }
48
88
 
49
89
  export function setSudoPassword(password: string): void {
50
90
  _sudoPassword = password;
51
- const dir = path.join(os.tmpdir(), `${product().cli}-sudo-${process.pid}`);
91
+ const dir = `${shimDirPrefix()}${process.pid}`;
52
92
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
93
  const pwFile = path.join(dir, "pw");
54
94
  fs.writeFileSync(pwFile, password + "\n", { mode: 0o600 });
55
95
  const wrapper = path.join(dir, "sudo");
56
96
  fs.writeFileSync(
57
97
  wrapper,
58
- `#!/bin/sh\nexec /usr/bin/sudo -S "$@" < '${pwFile}'\n`,
98
+ `#!/bin/sh\nexec ${shq(realSudoPath())} -S "$@" < ${shq(pwFile)}\n`,
59
99
  { mode: 0o700 },
60
100
  );
61
101
  _sudoDir = dir;
62
102
  }
63
103
 
104
+ /** What the no-password shim prints before it fails (LK-0). */
105
+ export const NEEDS_SUDO_MARK = "needs-sudo:";
106
+
107
+ /**
108
+ * The shim for when no password is held (LK-0): sudo that needs none runs
109
+ * as it would; anything else prints `<cli>: needs-sudo: sudo <args>` and
110
+ * fails, never prompting. It holds no secret.
111
+ */
112
+ function askShim(): string {
113
+ const real = shq(realSudoPath());
114
+ const mark = `${product().cli}: ${NEEDS_SUDO_MARK}`;
115
+ return [
116
+ "#!/bin/sh",
117
+ `# ${product().title}: no admin password is held (LK-0).`,
118
+ `REAL=${real}`,
119
+ // A caller feeding its own password, or a passwordless sudo: as is.
120
+ 'case " $* " in *" -S "*) exec "$REAL" "$@";; esac',
121
+ 'if "$REAL" -n true >/dev/null 2>&1; then exec "$REAL" -n "$@"; fi',
122
+ 'err=$(mktemp "${TMPDIR:-/tmp}/sudo-err.XXXXXX") || exit 1',
123
+ '"$REAL" -n "$@" 2>"$err"; rc=$?',
124
+ 'if [ "$rc" -ne 0 ] && grep -qi -e "password is required" -e "terminal is required" "$err"; then',
125
+ ' rm -f "$err"',
126
+ ` printf '%s sudo %s\\n' ${shq(mark)} "$*" >&2`,
127
+ " exit 1",
128
+ "fi",
129
+ 'cat "$err" >&2; rm -f "$err"; exit "$rc"',
130
+ "",
131
+ ].join("\n");
132
+ }
133
+
134
+ function ensureAskDir(): string | null {
135
+ if (_askDir) return _askDir;
136
+ try {
137
+ const dir = `${shimDirPrefix()}${process.pid}-ask`;
138
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
139
+ fs.writeFileSync(path.join(dir, "sudo"), askShim(), { mode: 0o700 });
140
+ _askDir = dir;
141
+ } catch {
142
+ return null;
143
+ }
144
+ return _askDir;
145
+ }
146
+
64
147
  export function clearSudoPassword(): void {
65
148
  _sudoPassword = null;
66
149
  if (_sudoDir) {
@@ -73,14 +156,95 @@ export function clearSudoPassword(): void {
73
156
  }
74
157
  }
75
158
 
159
+ /** On exit: the password and the shims go. */
160
+ export function clearSudoShims(): void {
161
+ clearSudoPassword();
162
+ if (_askDir) {
163
+ try {
164
+ fs.rmSync(_askDir, { recursive: true });
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ _askDir = null;
169
+ }
170
+ }
171
+
76
172
  /**
77
- * Returns env additions that prepend our sudo wrapper to PATH so that any
78
- * 'sudo' call in the spawned process tree uses the stored password.
173
+ * Returns env additions that prepend our sudo shim to PATH so that any
174
+ * 'sudo' call in the spawned process tree uses the stored password, or,
175
+ * with none, says it needs one instead of prompting (LK-0).
79
176
  */
80
177
  export function getSudoEnv(): NodeJS.ProcessEnv {
81
- if (!_sudoDir || lockClosedHere()) return {};
178
+ if (lockClosedHere()) return {};
179
+ const dir = _sudoDir ?? ensureAskDir();
180
+ if (!dir) return {};
82
181
  const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
83
- return { PATH: `${_sudoDir}:${currentPath}` };
182
+ return { PATH: `${dir}:${currentPath}` };
183
+ }
184
+
185
+ /** A run's needs-sudo event (LK-0): what needed sudo, and in which step. */
186
+ export interface NeedsSudo {
187
+ type: "needs-sudo";
188
+ /** The sudo command, when the shim saw it (`sudo <args>`). */
189
+ command?: string;
190
+ /** The service address (or step) that ran it. */
191
+ step?: string;
192
+ }
193
+
194
+ const MAX_COMMAND = 200;
195
+ const NATIVE_ASK =
196
+ /^\s*sudo: (?:a password is required|a terminal is required to read the password)/;
197
+
198
+ /**
199
+ * Whether one output line says sudo needed the password: the shim's own
200
+ * line (with the command), or sudo's own refusal when a launcher reset the
201
+ * PATH (no command). Null otherwise.
202
+ */
203
+ export function needsSudoOf(line: string): { command?: string } | null {
204
+ const i = line.indexOf(NEEDS_SUDO_MARK);
205
+ if (i >= 0) {
206
+ const command = line
207
+ .slice(i + NEEDS_SUDO_MARK.length)
208
+ .trim()
209
+ .slice(0, MAX_COMMAND);
210
+ return command ? { command } : {};
211
+ }
212
+ return NATIVE_ASK.test(line) ? {} : null;
213
+ }
214
+
215
+ /**
216
+ * Wrap a run's SSE `emit`: a `line` that says sudo needed the password is
217
+ * followed by one `needs-sudo` event (once per step and command). The step
218
+ * is the last `label` event's text (a workspace run's service), else
219
+ * `step`.
220
+ */
221
+ export function watchSudo(
222
+ emit: (obj: unknown) => void,
223
+ step?: string,
224
+ ): (obj: unknown) => void {
225
+ let current = step;
226
+ const said = new Set<string>();
227
+ return (obj) => {
228
+ emit(obj);
229
+ const o = obj as { type?: unknown; text?: unknown } | null;
230
+ if (!o || typeof o.text !== "string") return;
231
+ if (o.type === "label") {
232
+ current = o.text;
233
+ return;
234
+ }
235
+ if (o.type !== "line" && o.type !== "error") return;
236
+ const hit = needsSudoOf(o.text);
237
+ if (!hit) return;
238
+ const key = `${current ?? ""}\u0000${hit.command ?? ""}`;
239
+ if (said.has(key)) return;
240
+ said.add(key);
241
+ const ev: NeedsSudo = {
242
+ type: "needs-sudo",
243
+ ...(hit.command ? { command: hit.command } : {}),
244
+ ...(current ? { step: current } : {}),
245
+ };
246
+ emit(ev);
247
+ };
84
248
  }
85
249
 
86
250
  /**
@@ -97,7 +97,7 @@ import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
97
97
  import { redactRemote } from "../../shared/environment-manager/git-remote.js";
98
98
  import { getDistRoot } from "../../shared/paths.js";
99
99
  import { recordRemovedFromWorkspace } from "../../shared/environment-manager/default-workspace.js";
100
- import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
100
+ import { getSudoEnv, prewarmSudo, watchSudo } from "./sudo-store.js";
101
101
  import {
102
102
  devManagedPaths,
103
103
  selectSyncTargets,
@@ -1155,7 +1155,9 @@ export async function handleWorkspaceRoutes(
1155
1155
  }
1156
1156
  });
1157
1157
 
1158
- const emit = (obj: unknown): void => {
1158
+ // LK-0: a service's sudo without the password is a needs-sudo event,
1159
+ // naming the command and the service (the last label).
1160
+ const emit = watchSudo((obj: unknown): void => {
1159
1161
  if (cancelled) return;
1160
1162
  try {
1161
1163
  res.write(`data: ${JSON.stringify(obj)}\n\n`);
@@ -1163,7 +1165,7 @@ export async function handleWorkspaceRoutes(
1163
1165
  // Socket closed mid-write — mark cancelled so subsequent loops bail.
1164
1166
  cancelled = true;
1165
1167
  }
1166
- };
1168
+ });
1167
1169
 
1168
1170
  const wsParam = url.searchParams.get("workspace");
1169
1171
  if (wsParam && !isWorkspaceActive(wsParam)) {
@@ -1959,6 +1961,8 @@ export async function handleWorkspaceRoutes(
1959
1961
  emit: (o: unknown) => void,
1960
1962
  isCancelled: () => boolean,
1961
1963
  ): Promise<boolean> {
1964
+ // LK-0: this step's sudo without the password is a needs-sudo event.
1965
+ emit = watchSudo(emit, `${repoName} ${command}`);
1962
1966
  emit({ type: "line", text: ` ${repoName}: ${command}…` });
1963
1967
  return new Promise<boolean>((resolve) => {
1964
1968
  let settled = false;
@@ -1972,6 +1976,9 @@ export async function handleWorkspaceRoutes(
1972
1976
  const result = runServiceCommand({
1973
1977
  address,
1974
1978
  command,
1979
+ // The sudo shim (LK-0): the password when one is held, else a
1980
+ // needs-sudo line instead of a prompt nobody can answer.
1981
+ env: getSudoEnv(),
1975
1982
  onLine: (line) => {
1976
1983
  if (!isCancelled()) emit({ type: "line", text: ` ${line}` });
1977
1984
  },