@monster-orange/den 0.4.0 → 0.4.1

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 (53) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/docs/distributions.md +204 -14
  3. package/package.json +3 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/den-claude.ts +99 -1
  7. package/src/shared/agents/runtime.ts +7 -0
  8. package/src/shared/agents/runtimes.ts +9 -2
  9. package/src/shared/environment-manager/scope.ts +22 -0
  10. package/src/shared/environment-manager/service-runner.ts +94 -2
  11. package/src/shared/profile.ts +15 -4
  12. package/src/shared/sessions/client.ts +9 -2
  13. package/src/shared/sessions/env.ts +13 -3
  14. package/src/shared/sessions/host.ts +23 -0
  15. package/src/shared/web-secret.ts +181 -0
  16. package/src/shared/web-session.ts +491 -0
  17. package/src/ui/cli/agent-ops.ts +843 -0
  18. package/src/ui/cli/agents-command.ts +96 -614
  19. package/src/ui/cli/flag-parser.ts +21 -0
  20. package/src/ui/cli/index.ts +94 -10
  21. package/src/ui/cli/sessions-command.ts +32 -0
  22. package/src/ui/cli/target-workspace.ts +105 -0
  23. package/src/ui/cli/web-command.ts +536 -14
  24. package/src/ui/mcp/api.ts +7 -0
  25. package/src/ui/mcp/server.ts +74 -6
  26. package/src/ui/server/agent-routes.ts +323 -0
  27. package/src/ui/server/audit.ts +81 -27
  28. package/src/ui/server/http-errors.ts +21 -3
  29. package/src/ui/server/index.ts +814 -66
  30. package/src/ui/server/public/agents-page.js +648 -0
  31. package/src/ui/server/public/agents-section.js +299 -0
  32. package/src/ui/server/public/agents.html +439 -0
  33. package/src/ui/server/public/appearance.js +3 -0
  34. package/src/ui/server/public/error-page.js +103 -0
  35. package/src/ui/server/public/header.js +45 -11
  36. package/src/ui/server/public/i18n/es.js +90 -1
  37. package/src/ui/server/public/i18n/ja.js +86 -1
  38. package/src/ui/server/public/local-dev.html +53 -0
  39. package/src/ui/server/public/logs.html +3 -0
  40. package/src/ui/server/public/panel.js +1 -1
  41. package/src/ui/server/public/release.html +8 -0
  42. package/src/ui/server/public/settings-page.js +5 -0
  43. package/src/ui/server/public/settings-registry.js +33 -0
  44. package/src/ui/server/public/shell.js +248 -5
  45. package/src/ui/server/public/topbar.js +51 -2
  46. package/src/ui/server/request-guard.ts +670 -0
  47. package/src/ui/server/route-classes.ts +317 -0
  48. package/src/ui/server/session-routes.ts +445 -0
  49. package/src/ui/server/shutdown-guard.ts +91 -0
  50. package/src/ui/server/sudo-store.ts +29 -3
  51. package/src/ui/server/ui-assets.ts +10 -1
  52. package/src/ui/server/websocket.ts +326 -0
  53. package/src/ui/server/workspace-routes.ts +13 -7
@@ -170,6 +170,27 @@ function validateFlagScopes(parsed: ParsedCliArgs): void {
170
170
  }
171
171
  }
172
172
 
173
+ /**
174
+ * Subcommands that print their own `--help`: the dispatcher's
175
+ * `<command> --help` must not answer for them (`web sessions --help` printed
176
+ * the general web help). web-command.ts's WEB_SUBCOMMANDS_WITH_HELP and
177
+ * mcp-connect.ts's MCP_CONNECT_SUBCOMMANDS are the same lists (a test
178
+ * checks).
179
+ */
180
+ export const SUBCOMMAND_OWN_HELP: Readonly<Record<string, readonly string[]>> =
181
+ {
182
+ web: ["token", "login", "sessions", "logout"],
183
+ mcp: ["connect", "disconnect", "clients", "config"],
184
+ };
185
+
186
+ /** Whether `<command> <sub> --help` is the subcommand's own to answer. */
187
+ export function ownsSubcommandHelp(
188
+ command: string,
189
+ sub: string | undefined,
190
+ ): boolean {
191
+ return !!sub && (SUBCOMMAND_OWN_HELP[command] ?? []).includes(sub);
192
+ }
193
+
173
194
  // Dash-flags each mutation subcommand accepts (global flags — -w/--workspace,
174
195
  // -t/--tabs/--no-tabs, --skip-secrets — are consumed by parseCliArgs before
175
196
  // dispatch, and -h/--help is handled by the dispatcher). Commands not listed
@@ -215,10 +215,12 @@ import {
215
215
  import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
216
216
  import { cmdWeb, displayWebHelp } from "./web-command.js";
217
217
  import { cmdGraph } from "./graph-command.js";
218
+ import { resolveTargetWorkspace } from "./target-workspace.js";
218
219
  import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
219
220
  import {
220
221
  parseCliArgs,
221
222
  takeWorkspaceFlags,
223
+ ownsSubcommandHelp,
222
224
  unknownCommandFlags,
223
225
  workspaceFlagRequiresActive,
224
226
  type ParsedCliArgs,
@@ -311,19 +313,20 @@ const WORKSPACE_SUBCOMMANDS: {
311
313
 
312
314
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
313
315
  down: {
314
- usage: "down [repo] [flags]",
316
+ usage: "down [repo] [-w <workspace>] [flags]",
315
317
  lines: [
316
318
  "Stop the cwd (or named) repo; --with-deps also stops what it sits on (its",
317
319
  "derived closure, in reverse order), naming anything else that uses it.",
318
320
  "-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
319
321
  "every down script runs — no already-stopped skip).",
320
- "Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
322
+ "Flags: -w/--workspace <ws> (that active workspace, not the focused one; focus stays) · -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
321
323
  ],
322
324
  },
323
325
  restart: {
324
- usage: "restart [service...] [-A <app>] [-n|--dry-run]",
326
+ usage: "restart [service...] [-w <workspace>] [-A <app>] [-n|--dry-run]",
325
327
  lines: [
326
328
  `Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
329
+ "-w/--workspace <ws> acts in that active workspace instead of the focused one; focus stays.",
327
330
  ],
328
331
  },
329
332
  status: {
@@ -368,7 +371,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
368
371
  },
369
372
  setup: {
370
373
  usage:
371
- "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
374
+ "setup [service...] [-w <workspace>] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
372
375
  lines: [
373
376
  "Run 'make setup' once per system (clones missing managed repos first).",
374
377
  "Default targets MANAGED clones only — setup's codegen/installs",
@@ -378,6 +381,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
378
381
  "cloned, seeded or written (on a fresh machine: the blueprint's plan).",
379
382
  "It plans the focused workspace; -w <workspace> (repeatable) plans those,",
380
383
  "--all-active every active one. Repos only other workspaces hold are named.",
384
+ "Without --plan, -w <workspace> sets up that workspace's systems instead of the focused one's.",
381
385
  ],
382
386
  },
383
387
  org: {
@@ -794,10 +798,13 @@ class DenCLI {
794
798
 
795
799
  // `<command> -h/--help` → that command's help (every command supports it).
796
800
  // (`-h`/`--help` AS the command falls through to the switch → global help.)
801
+ // A subcommand that prints its own help (`web sessions --help`, `mcp
802
+ // connect --help`) is dispatched instead: its help wins.
797
803
  if (
798
804
  command &&
799
805
  !["-h", "--help", "-v", "--version"].includes(command) &&
800
- (ownArgs.includes("-h") || ownArgs.includes("--help"))
806
+ (ownArgs.includes("-h") || ownArgs.includes("--help")) &&
807
+ !ownsSubcommandHelp(command, ownArgs[1])
801
808
  ) {
802
809
  this.printCommandHelp(command, ownArgs[1]);
803
810
  return;
@@ -2731,6 +2738,10 @@ class DenCLI {
2731
2738
  "--manifest",
2732
2739
  `use legacy ${product().manifestYml} depends_on instead of the derived graph`,
2733
2740
  );
2741
+ row(
2742
+ "-w, --workspace <ws>",
2743
+ "act in that (active) workspace instead of the focused one; focus stays",
2744
+ );
2734
2745
  row("--skip-secrets", "skip the secrets-backend sign-in preflight");
2735
2746
  row("-h, --help", "show this help");
2736
2747
  console.log();
@@ -2976,7 +2987,44 @@ class DenCLI {
2976
2987
  }
2977
2988
  }
2978
2989
 
2990
+ /**
2991
+ * With no `-w`: the workspace the named services are in, when it isn't
2992
+ * the focus (target-workspace.ts), set as this run's workspace. False
2993
+ * (exit 1, said why) when a name is in no active workspace, in several,
2994
+ * or the names are in different ones.
2995
+ */
2996
+ private pickTargetWorkspace(command: string, args: string[]): boolean {
2997
+ if (process.env.DEN_WORKSPACE_OVERRIDE) return true;
2998
+ const terminator = args.indexOf("--");
2999
+ const own = terminator === -1 ? args : args.slice(0, terminator);
3000
+ let rest: string[];
3001
+ try {
3002
+ const taken = takeAppFlag(own);
3003
+ if (taken.app) return true; // -A names an application, not services
3004
+ rest = taken.rest;
3005
+ } catch {
3006
+ return true; // the command itself reports a bad -A
3007
+ }
3008
+ const names = rest.filter((a) => !a.startsWith("-"));
3009
+ const r = resolveTargetWorkspace(names, product().cli, command);
3010
+ if (!r.ok) {
3011
+ console.error(chalk.red(`\n ✗ ${r.error}\n`));
3012
+ process.exitCode = 1;
3013
+ return false;
3014
+ }
3015
+ if (r.workspace) {
3016
+ process.env.DEN_WORKSPACE_OVERRIDE = r.workspace;
3017
+ console.log(
3018
+ chalk.gray(
3019
+ `\n ${names.join(", ")}: in workspace ${r.workspace} (not the focus)`,
3020
+ ),
3021
+ );
3022
+ }
3023
+ return true;
3024
+ }
3025
+
2979
3026
  private async cmdServiceUp(args: string[]): Promise<void> {
3027
+ if (!this.pickTargetWorkspace("up", args)) return;
2980
3028
  // Tokens after a `--` terminator belong to a child command — they must
2981
3029
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2982
3030
  const terminator = args.indexOf("--");
@@ -3101,6 +3149,7 @@ class DenCLI {
3101
3149
  }
3102
3150
 
3103
3151
  private async cmdServiceDown(args: string[]): Promise<void> {
3152
+ if (!this.pickTargetWorkspace("down", args)) return;
3104
3153
  // Tokens after a `--` terminator belong to a child command — they must
3105
3154
  // never be read as down's own flags (`den down -- -a` must NOT tear
3106
3155
  // down the whole workspace).
@@ -3171,6 +3220,7 @@ class DenCLI {
3171
3220
  }
3172
3221
 
3173
3222
  private async cmdServiceRestart(argv: string[]): Promise<void> {
3223
+ if (!this.pickTargetWorkspace("restart", argv)) return;
3174
3224
  const { app, rest: args } = takeAppFlag(argv);
3175
3225
  const dryRun = args.includes("--dry-run") || args.includes("-n");
3176
3226
  if (!dryRun) preflightSecrets();
@@ -4068,15 +4118,33 @@ class DenCLI {
4068
4118
  console.log();
4069
4119
  return;
4070
4120
  }
4071
- if (planWs.length || allActive) {
4121
+ if (planWs.length > 1 || allActive) {
4072
4122
  console.error(
4073
4123
  chalk.red(
4074
- `\n -w and --all-active go with --plan: setup itself works on the focused workspace.\n`,
4124
+ `\n More than one -w, and --all-active, go with --plan: setup itself works on one workspace (-w <workspace>, or the one the named repos are in, else the focus).\n`,
4075
4125
  ),
4076
4126
  );
4077
4127
  process.exitCode = 1;
4078
4128
  return;
4079
4129
  }
4130
+ if (planWs.length === 1) {
4131
+ const ws = planWs[0];
4132
+ if (!readWorkspacesFile().workspaces[ws]) {
4133
+ console.error(chalk.red(`\n Workspace "${ws}" does not exist\n`));
4134
+ process.exitCode = 1;
4135
+ return;
4136
+ }
4137
+ if (!isWorkspaceActive(ws)) {
4138
+ console.error(
4139
+ chalk.red(
4140
+ `\n Workspace "${ws}" is not active — run \`${product().cli} workspace activate ${ws}\` first\n`,
4141
+ ),
4142
+ );
4143
+ process.exitCode = 1;
4144
+ return;
4145
+ }
4146
+ process.env.DEN_WORKSPACE_OVERRIDE = ws;
4147
+ } else if (!this.pickTargetWorkspace("setup", args)) return;
4080
4148
 
4081
4149
  // Clone any managed repos that aren't on disk yet before reading manifests.
4082
4150
  this.cloneMissingManagedRepos();
@@ -4091,11 +4159,27 @@ class DenCLI {
4091
4159
  return;
4092
4160
  }
4093
4161
 
4162
+ // A name the listing doesn't spell the same way (an alias, a bare repo
4163
+ // listed only by subsystem) is matched as resolveService resolves it.
4164
+ const matches = (name: string, s: (typeof services)[number]): boolean => {
4165
+ if (name === s.address || name === s.repoName) return true;
4166
+ const r = resolveService(name);
4167
+ return !!r && (r.address === s.address || r.repoName === s.address);
4168
+ };
4169
+ const unmatched = names.filter((n) => !services.some((s) => matches(n, s)));
4170
+ if (unmatched.length > 0) {
4171
+ const ws = getActiveWorkspace()?.name ?? "default";
4172
+ console.error(
4173
+ chalk.red(
4174
+ `\n ✗ Not in workspace ${ws}: ${unmatched.join(", ")}. Nothing was set up.\n`,
4175
+ ),
4176
+ );
4177
+ process.exitCode = 1;
4178
+ return;
4179
+ }
4094
4180
  let targets =
4095
4181
  names.length > 0
4096
- ? services.filter(
4097
- (s) => names.includes(s.address) || names.includes(s.repoName),
4098
- )
4182
+ ? services.filter((s) => names.some((n) => matches(n, s)))
4099
4183
  : services;
4100
4184
 
4101
4185
  // Workspace-wide default: MANAGED clones only. `make setup` runs
@@ -31,6 +31,10 @@ import {
31
31
  noHost,
32
32
  type HostClient,
33
33
  } from "../../shared/sessions/client.js";
34
+ import {
35
+ ACTOR_TOKEN_ENV,
36
+ isPersonCredentialEnv,
37
+ } from "../../shared/actor-token.js";
34
38
  import { DEN_PTY_ID } from "../../shared/sessions/den-pty.js";
35
39
  import { AGENT_ACTOR_ENV, buildSessionEnv } from "../../shared/sessions/env.js";
36
40
  import { runSessionHost } from "../../shared/sessions/host.js";
@@ -63,6 +67,7 @@ export const SESSIONS_HELP = {
63
67
  " run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by agents.max_concurrent",
64
68
  " env: PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ,",
65
69
  " TMPDIR only; --keep-env NAME (exact, repeatable; audited) adds one",
70
+ " (never a person's credential: DEN_PERSON_TOKEN, a web.secret path)",
66
71
  "{cli} sessions list [-w <ws>] [--json]",
67
72
  "{cli} sessions attach <name|id>",
68
73
  " raw terminal; ctrl-] detaches and leaves it running",
@@ -387,6 +392,33 @@ async function start(args: string[], d: Resolved): Promise<number> {
387
392
  );
388
393
  return 1;
389
394
  }
395
+ // SEC-1 §5: a person's credential (DEN_PERSON_TOKEN, a person token, a
396
+ // path to web.secret) or an actor token is never forwarded. Refused, and
397
+ // audited by the session host (names only).
398
+ const forbidden = keep.filter(
399
+ (k) => k === ACTOR_TOKEN_ENV || isPersonCredentialEnv(k, d.env[k]),
400
+ );
401
+ if (forbidden.length) {
402
+ d.err(
403
+ `✗ --keep-env ${forbidden.join(", ")}: a person's credential (or an actor token) is never forwarded to a session`,
404
+ );
405
+ await withClient(d, (c) =>
406
+ c.start(
407
+ {
408
+ name: sessionName(d.dist, ws, name),
409
+ cwd,
410
+ argv: p.rest!,
411
+ env: {},
412
+ cols: 80,
413
+ rows: 24,
414
+ },
415
+ { refusedEnv: forbidden, actor: `user ${cliUser(d.env)}` },
416
+ ),
417
+ ).catch((e: unknown) => {
418
+ if (!(e instanceof HostError)) throw e;
419
+ });
420
+ return 1;
421
+ }
390
422
  const env = plainSessionEnv(d.env, keep);
391
423
  // Names only, never values: what was left out, so it can be asked for.
392
424
  const dropped = Object.keys(d.env)
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Which workspace a lifecycle command (`setup`, `up`, `down`, `restart`)
3
+ * names its services in, when no `-w` says.
4
+ *
5
+ * The focused workspace is the default target (WS-6.1). A name it doesn't
6
+ * hold resolves across the other active workspaces, as the web API does
7
+ * with `&workspace=` (resolveService(address, workspace)):
8
+ *
9
+ * - in the focus: the focus (nothing changes);
10
+ * - in exactly one other active workspace: that one;
11
+ * - in several: an error that lists them (`-w <workspace>` picks one);
12
+ * - nowhere: an error (exit 1, never a silent exit 0).
13
+ *
14
+ * Every name must land in one workspace; names in different workspaces are
15
+ * an error (run them one workspace at a time, with `-w`).
16
+ */
17
+ import {
18
+ resolveService,
19
+ serviceAliasCandidates,
20
+ } from "../../shared/environment-manager/service-runner.js";
21
+ import {
22
+ getActiveWorkspaces,
23
+ getFocusedWorkspaceName,
24
+ } from "../../shared/environment-manager/workspaces.js";
25
+
26
+ export type TargetWorkspace =
27
+ | { ok: true; workspace: string | null }
28
+ | { ok: false; error: string };
29
+
30
+ /** Whether `name` names a service (a repo, an address or an alias) in `ws`. */
31
+ export function nameResolvesIn(name: string, ws: string): boolean {
32
+ return (
33
+ resolveService(name, ws) !== null ||
34
+ serviceAliasCandidates(name, ws).length > 0
35
+ );
36
+ }
37
+
38
+ /**
39
+ * The workspace `names` resolve in, given the focus and the active set:
40
+ * `workspace: null` when it is the focus (or there are no names). Pure
41
+ * apart from `resolves`.
42
+ */
43
+ export function targetWorkspaceFor(
44
+ names: readonly string[],
45
+ o: {
46
+ cli: string;
47
+ command: string;
48
+ focus: string | null;
49
+ active: readonly string[];
50
+ resolves: (name: string, ws: string) => boolean;
51
+ },
52
+ ): TargetWorkspace {
53
+ if (names.length === 0) return { ok: true, workspace: null };
54
+ const others = o.active.filter((w) => w !== o.focus);
55
+ const where = new Map<string, string>();
56
+ for (const name of names) {
57
+ if (o.focus && o.resolves(name, o.focus)) {
58
+ where.set(name, o.focus);
59
+ continue;
60
+ }
61
+ const hits = others.filter((w) => o.resolves(name, w));
62
+ if (hits.length === 0) {
63
+ const looked = [o.focus, ...others].filter(Boolean).join(", ");
64
+ return {
65
+ ok: false,
66
+ error: `"${name}" is not a service in any active workspace (${looked || "none active"}).`,
67
+ };
68
+ }
69
+ if (hits.length > 1)
70
+ return {
71
+ ok: false,
72
+ error:
73
+ `"${name}" is in more than one active workspace: ` +
74
+ `${hits.map((w) => `${w} (${o.cli} ${o.command} ${name} -w ${w})`).join(", ")}. ` +
75
+ "Name one with -w.",
76
+ };
77
+ where.set(name, hits[0]);
78
+ }
79
+ const workspaces = [...new Set(where.values())];
80
+ if (workspaces.length > 1)
81
+ return {
82
+ ok: false,
83
+ error:
84
+ "These names are in different workspaces: " +
85
+ `${[...where].map(([n, w]) => `${n} (${w})`).join(", ")}. ` +
86
+ `Run ${o.cli} ${o.command} once per workspace, with -w.`,
87
+ };
88
+ const ws = workspaces[0];
89
+ return { ok: true, workspace: ws === o.focus ? null : ws };
90
+ }
91
+
92
+ /** targetWorkspaceFor over this distribution's workspaces. */
93
+ export function resolveTargetWorkspace(
94
+ names: readonly string[],
95
+ cli: string,
96
+ command: string,
97
+ ): TargetWorkspace {
98
+ return targetWorkspaceFor(names, {
99
+ cli,
100
+ command,
101
+ focus: getFocusedWorkspaceName(),
102
+ active: getActiveWorkspaces(),
103
+ resolves: nameResolvesIn,
104
+ });
105
+ }