@monster-orange/den 0.4.0 → 0.4.2
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 +457 -0
- package/README.md +88 -158
- package/docs/distributions.md +216 -16
- package/docs/reference.md +177 -0
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/den-config.ts +52 -3
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +16 -2
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +56 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +97 -615
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +127 -11
- package/src/ui/cli/sessions-command.ts +49 -5
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +542 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +324 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +821 -66
- package/src/ui/server/public/agents-page.js +654 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- 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
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -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;
|
|
@@ -2704,8 +2711,11 @@ class DenCLI {
|
|
|
2704
2711
|
);
|
|
2705
2712
|
console.log();
|
|
2706
2713
|
console.log(chalk.bold(" Flags:"));
|
|
2714
|
+
// Two spaces at least between a flag and its text, a long one too.
|
|
2707
2715
|
const row = (f: string, d: string) =>
|
|
2708
|
-
console.log(
|
|
2716
|
+
console.log(
|
|
2717
|
+
` ${chalk.cyan(f.padEnd(Math.max(20, f.length + 2)))}${d}`,
|
|
2718
|
+
);
|
|
2709
2719
|
row(
|
|
2710
2720
|
"-x, --exclude",
|
|
2711
2721
|
"start only the dependencies, not the current/seed repo",
|
|
@@ -2731,6 +2741,10 @@ class DenCLI {
|
|
|
2731
2741
|
"--manifest",
|
|
2732
2742
|
`use legacy ${product().manifestYml} depends_on instead of the derived graph`,
|
|
2733
2743
|
);
|
|
2744
|
+
row(
|
|
2745
|
+
"-w, --workspace <ws>",
|
|
2746
|
+
"act in that (active) workspace instead of the focused one; focus stays",
|
|
2747
|
+
);
|
|
2734
2748
|
row("--skip-secrets", "skip the secrets-backend sign-in preflight");
|
|
2735
2749
|
row("-h, --help", "show this help");
|
|
2736
2750
|
console.log();
|
|
@@ -2976,7 +2990,44 @@ class DenCLI {
|
|
|
2976
2990
|
}
|
|
2977
2991
|
}
|
|
2978
2992
|
|
|
2993
|
+
/**
|
|
2994
|
+
* With no `-w`: the workspace the named services are in, when it isn't
|
|
2995
|
+
* the focus (target-workspace.ts), set as this run's workspace. False
|
|
2996
|
+
* (exit 1, said why) when a name is in no active workspace, in several,
|
|
2997
|
+
* or the names are in different ones.
|
|
2998
|
+
*/
|
|
2999
|
+
private pickTargetWorkspace(command: string, args: string[]): boolean {
|
|
3000
|
+
if (process.env.DEN_WORKSPACE_OVERRIDE) return true;
|
|
3001
|
+
const terminator = args.indexOf("--");
|
|
3002
|
+
const own = terminator === -1 ? args : args.slice(0, terminator);
|
|
3003
|
+
let rest: string[];
|
|
3004
|
+
try {
|
|
3005
|
+
const taken = takeAppFlag(own);
|
|
3006
|
+
if (taken.app) return true; // -A names an application, not services
|
|
3007
|
+
rest = taken.rest;
|
|
3008
|
+
} catch {
|
|
3009
|
+
return true; // the command itself reports a bad -A
|
|
3010
|
+
}
|
|
3011
|
+
const names = rest.filter((a) => !a.startsWith("-"));
|
|
3012
|
+
const r = resolveTargetWorkspace(names, product().cli, command);
|
|
3013
|
+
if (!r.ok) {
|
|
3014
|
+
console.error(chalk.red(`\n ✗ ${r.error}\n`));
|
|
3015
|
+
process.exitCode = 1;
|
|
3016
|
+
return false;
|
|
3017
|
+
}
|
|
3018
|
+
if (r.workspace) {
|
|
3019
|
+
process.env.DEN_WORKSPACE_OVERRIDE = r.workspace;
|
|
3020
|
+
console.log(
|
|
3021
|
+
chalk.gray(
|
|
3022
|
+
`\n ${names.join(", ")}: in workspace ${r.workspace} (not the focus)`,
|
|
3023
|
+
),
|
|
3024
|
+
);
|
|
3025
|
+
}
|
|
3026
|
+
return true;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
2979
3029
|
private async cmdServiceUp(args: string[]): Promise<void> {
|
|
3030
|
+
if (!this.pickTargetWorkspace("up", args)) return;
|
|
2980
3031
|
// Tokens after a `--` terminator belong to a child command — they must
|
|
2981
3032
|
// never be read as up's own flags (`den up -- -a` is NOT `up -a`).
|
|
2982
3033
|
const terminator = args.indexOf("--");
|
|
@@ -3101,6 +3152,7 @@ class DenCLI {
|
|
|
3101
3152
|
}
|
|
3102
3153
|
|
|
3103
3154
|
private async cmdServiceDown(args: string[]): Promise<void> {
|
|
3155
|
+
if (!this.pickTargetWorkspace("down", args)) return;
|
|
3104
3156
|
// Tokens after a `--` terminator belong to a child command — they must
|
|
3105
3157
|
// never be read as down's own flags (`den down -- -a` must NOT tear
|
|
3106
3158
|
// down the whole workspace).
|
|
@@ -3171,6 +3223,7 @@ class DenCLI {
|
|
|
3171
3223
|
}
|
|
3172
3224
|
|
|
3173
3225
|
private async cmdServiceRestart(argv: string[]): Promise<void> {
|
|
3226
|
+
if (!this.pickTargetWorkspace("restart", argv)) return;
|
|
3174
3227
|
const { app, rest: args } = takeAppFlag(argv);
|
|
3175
3228
|
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
|
3176
3229
|
if (!dryRun) preflightSecrets();
|
|
@@ -4068,15 +4121,33 @@ class DenCLI {
|
|
|
4068
4121
|
console.log();
|
|
4069
4122
|
return;
|
|
4070
4123
|
}
|
|
4071
|
-
if (planWs.length || allActive) {
|
|
4124
|
+
if (planWs.length > 1 || allActive) {
|
|
4072
4125
|
console.error(
|
|
4073
4126
|
chalk.red(
|
|
4074
|
-
`\n -w and --all-active go with --plan: setup itself works on the
|
|
4127
|
+
`\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
4128
|
),
|
|
4076
4129
|
);
|
|
4077
4130
|
process.exitCode = 1;
|
|
4078
4131
|
return;
|
|
4079
4132
|
}
|
|
4133
|
+
if (planWs.length === 1) {
|
|
4134
|
+
const ws = planWs[0];
|
|
4135
|
+
if (!readWorkspacesFile().workspaces[ws]) {
|
|
4136
|
+
console.error(chalk.red(`\n Workspace "${ws}" does not exist\n`));
|
|
4137
|
+
process.exitCode = 1;
|
|
4138
|
+
return;
|
|
4139
|
+
}
|
|
4140
|
+
if (!isWorkspaceActive(ws)) {
|
|
4141
|
+
console.error(
|
|
4142
|
+
chalk.red(
|
|
4143
|
+
`\n Workspace "${ws}" is not active — run \`${product().cli} workspace activate ${ws}\` first\n`,
|
|
4144
|
+
),
|
|
4145
|
+
);
|
|
4146
|
+
process.exitCode = 1;
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
process.env.DEN_WORKSPACE_OVERRIDE = ws;
|
|
4150
|
+
} else if (!this.pickTargetWorkspace("setup", args)) return;
|
|
4080
4151
|
|
|
4081
4152
|
// Clone any managed repos that aren't on disk yet before reading manifests.
|
|
4082
4153
|
this.cloneMissingManagedRepos();
|
|
@@ -4091,11 +4162,27 @@ class DenCLI {
|
|
|
4091
4162
|
return;
|
|
4092
4163
|
}
|
|
4093
4164
|
|
|
4165
|
+
// A name the listing doesn't spell the same way (an alias, a bare repo
|
|
4166
|
+
// listed only by subsystem) is matched as resolveService resolves it.
|
|
4167
|
+
const matches = (name: string, s: (typeof services)[number]): boolean => {
|
|
4168
|
+
if (name === s.address || name === s.repoName) return true;
|
|
4169
|
+
const r = resolveService(name);
|
|
4170
|
+
return !!r && (r.address === s.address || r.repoName === s.address);
|
|
4171
|
+
};
|
|
4172
|
+
const unmatched = names.filter((n) => !services.some((s) => matches(n, s)));
|
|
4173
|
+
if (unmatched.length > 0) {
|
|
4174
|
+
const ws = getActiveWorkspace()?.name ?? "default";
|
|
4175
|
+
console.error(
|
|
4176
|
+
chalk.red(
|
|
4177
|
+
`\n ✗ Not in workspace ${ws}: ${unmatched.join(", ")}. Nothing was set up.\n`,
|
|
4178
|
+
),
|
|
4179
|
+
);
|
|
4180
|
+
process.exitCode = 1;
|
|
4181
|
+
return;
|
|
4182
|
+
}
|
|
4094
4183
|
let targets =
|
|
4095
4184
|
names.length > 0
|
|
4096
|
-
? services.filter(
|
|
4097
|
-
(s) => names.includes(s.address) || names.includes(s.repoName),
|
|
4098
|
-
)
|
|
4185
|
+
? services.filter((s) => names.some((n) => matches(n, s)))
|
|
4099
4186
|
: services;
|
|
4100
4187
|
|
|
4101
4188
|
// Workspace-wide default: MANAGED clones only. `make setup` runs
|
|
@@ -4576,6 +4663,35 @@ class DenCLI {
|
|
|
4576
4663
|
if (!d.available) warn(`edge.tls ${edge.tls.strategy}: ${d.reason}`);
|
|
4577
4664
|
}
|
|
4578
4665
|
}
|
|
4666
|
+
// The web keys: a wrong value would stop `web up`; one read and
|
|
4667
|
+
// ignored (the distribution's web.allow_plain_http_remote: only
|
|
4668
|
+
// config/local/user.yml counts) is flagged, as `web up` says it.
|
|
4669
|
+
{
|
|
4670
|
+
const { ignoredConfigWarning, readWebAuthConfig } =
|
|
4671
|
+
await import("../../shared/web-session.js");
|
|
4672
|
+
try {
|
|
4673
|
+
const w = ignoredConfigWarning(
|
|
4674
|
+
readWebAuthConfig(getDistRoot(), product().configFile),
|
|
4675
|
+
);
|
|
4676
|
+
if (w) console.warn(chalk.yellow(`\n ⚠ ${w}`));
|
|
4677
|
+
} catch (e) {
|
|
4678
|
+
console.error(chalk.red(`\n ✗ web: ${(e as Error).message}`));
|
|
4679
|
+
process.exitCode = 1;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
// The caps: agents.max_concurrent (agents) and sessions.max_concurrent
|
|
4683
|
+
// (plain sessions). A wrong value would refuse every start.
|
|
4684
|
+
{
|
|
4685
|
+
const { readAgentsConfig, readSessionsConfig } =
|
|
4686
|
+
await import("../../shared/den-config.js");
|
|
4687
|
+
for (const read of [readAgentsConfig, readSessionsConfig])
|
|
4688
|
+
try {
|
|
4689
|
+
read();
|
|
4690
|
+
} catch (e) {
|
|
4691
|
+
console.error(chalk.red(`\n ✗ ${(e as Error).message}`));
|
|
4692
|
+
process.exitCode = 1;
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4579
4695
|
const positional = args.filter((a) => !a.startsWith("-"));
|
|
4580
4696
|
const held = args.includes("--held");
|
|
4581
4697
|
// Bare `validate` in the distribution root (which has no manifest of its
|
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The R8 lock is the web UI's (an in-memory admin password in the web
|
|
15
15
|
* server); CLI service actions (up, down) do not consult it, so neither do
|
|
16
|
-
* these.
|
|
16
|
+
* these. sessions.max_concurrent caps the plain sessions running at once;
|
|
17
|
+
* agents count against agents.max_concurrent instead, never this one.
|
|
17
18
|
*/
|
|
18
19
|
import { spawn } from "child_process";
|
|
19
20
|
import * as fs from "fs";
|
|
20
21
|
import * as path from "path";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
readAgentsConfig,
|
|
24
|
+
readSessionsConfig,
|
|
25
|
+
} from "../../shared/den-config.js";
|
|
22
26
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
23
27
|
import { getDistRoot, getInvokeCwd, getStateDir } from "../../shared/paths.js";
|
|
24
28
|
import { product } from "../../shared/product.js";
|
|
@@ -31,6 +35,10 @@ import {
|
|
|
31
35
|
noHost,
|
|
32
36
|
type HostClient,
|
|
33
37
|
} from "../../shared/sessions/client.js";
|
|
38
|
+
import {
|
|
39
|
+
ACTOR_TOKEN_ENV,
|
|
40
|
+
isPersonCredentialEnv,
|
|
41
|
+
} from "../../shared/actor-token.js";
|
|
34
42
|
import { DEN_PTY_ID } from "../../shared/sessions/den-pty.js";
|
|
35
43
|
import { AGENT_ACTOR_ENV, buildSessionEnv } from "../../shared/sessions/env.js";
|
|
36
44
|
import { runSessionHost } from "../../shared/sessions/host.js";
|
|
@@ -60,9 +68,10 @@ export const SESSIONS_HELP = {
|
|
|
60
68
|
"session host (one per distribution, started on demand).",
|
|
61
69
|
"",
|
|
62
70
|
"{cli} sessions start --workspace <ws> --name <n> [--cwd <dir>] [--keep-env NAME…] -- <argv…>",
|
|
63
|
-
" run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by
|
|
71
|
+
" run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by sessions.max_concurrent",
|
|
64
72
|
" env: PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ,",
|
|
65
73
|
" TMPDIR only; --keep-env NAME (exact, repeatable; audited) adds one",
|
|
74
|
+
" (never a person's credential: DEN_PERSON_TOKEN, a web.secret path)",
|
|
66
75
|
"{cli} sessions list [-w <ws>] [--json]",
|
|
67
76
|
"{cli} sessions attach <name|id>",
|
|
68
77
|
" raw terminal; ctrl-] detaches and leaves it running",
|
|
@@ -92,7 +101,10 @@ export interface SessionsDeps {
|
|
|
92
101
|
connectExisting?: () => Promise<HostClient | null>;
|
|
93
102
|
pty?: () => PtyLoad;
|
|
94
103
|
workspaceExists?: (ws: string) => boolean;
|
|
104
|
+
/** agents.max_concurrent: the agents running at once. */
|
|
95
105
|
maxConcurrent?: () => number;
|
|
106
|
+
/** sessions.max_concurrent: the plain sessions running at once. */
|
|
107
|
+
maxSessions?: () => number;
|
|
96
108
|
invokeCwd?: () => string;
|
|
97
109
|
env?: NodeJS.ProcessEnv;
|
|
98
110
|
out?: (line: string) => void;
|
|
@@ -109,7 +121,10 @@ export interface Resolved {
|
|
|
109
121
|
connectExisting: () => Promise<HostClient | null>;
|
|
110
122
|
pty: () => PtyLoad;
|
|
111
123
|
workspaceExists: (ws: string) => boolean;
|
|
124
|
+
/** agents.max_concurrent: the agents running at once. */
|
|
112
125
|
maxConcurrent: () => number;
|
|
126
|
+
/** sessions.max_concurrent: the plain sessions running at once. */
|
|
127
|
+
maxSessions: () => number;
|
|
113
128
|
invokeCwd: () => string;
|
|
114
129
|
env: NodeJS.ProcessEnv;
|
|
115
130
|
out: (line: string) => void;
|
|
@@ -175,6 +190,7 @@ export function resolveDeps(d: SessionsDeps): Resolved {
|
|
|
175
190
|
pty: d.pty ?? (() => loadPty()),
|
|
176
191
|
workspaceExists: d.workspaceExists ?? defaultWorkspaceExists,
|
|
177
192
|
maxConcurrent: d.maxConcurrent ?? (() => readAgentsConfig().maxConcurrent),
|
|
193
|
+
maxSessions: d.maxSessions ?? (() => readSessionsConfig().maxConcurrent),
|
|
178
194
|
invokeCwd: d.invokeCwd ?? getInvokeCwd,
|
|
179
195
|
env: d.env ?? process.env,
|
|
180
196
|
out: d.out ?? ((l) => console.log(l)),
|
|
@@ -387,6 +403,33 @@ async function start(args: string[], d: Resolved): Promise<number> {
|
|
|
387
403
|
);
|
|
388
404
|
return 1;
|
|
389
405
|
}
|
|
406
|
+
// SEC-1 §5: a person's credential (DEN_PERSON_TOKEN, a person token, a
|
|
407
|
+
// path to web.secret) or an actor token is never forwarded. Refused, and
|
|
408
|
+
// audited by the session host (names only).
|
|
409
|
+
const forbidden = keep.filter(
|
|
410
|
+
(k) => k === ACTOR_TOKEN_ENV || isPersonCredentialEnv(k, d.env[k]),
|
|
411
|
+
);
|
|
412
|
+
if (forbidden.length) {
|
|
413
|
+
d.err(
|
|
414
|
+
`✗ --keep-env ${forbidden.join(", ")}: a person's credential (or an actor token) is never forwarded to a session`,
|
|
415
|
+
);
|
|
416
|
+
await withClient(d, (c) =>
|
|
417
|
+
c.start(
|
|
418
|
+
{
|
|
419
|
+
name: sessionName(d.dist, ws, name),
|
|
420
|
+
cwd,
|
|
421
|
+
argv: p.rest!,
|
|
422
|
+
env: {},
|
|
423
|
+
cols: 80,
|
|
424
|
+
rows: 24,
|
|
425
|
+
},
|
|
426
|
+
{ refusedEnv: forbidden, actor: `user ${cliUser(d.env)}` },
|
|
427
|
+
),
|
|
428
|
+
).catch((e: unknown) => {
|
|
429
|
+
if (!(e instanceof HostError)) throw e;
|
|
430
|
+
});
|
|
431
|
+
return 1;
|
|
432
|
+
}
|
|
390
433
|
const env = plainSessionEnv(d.env, keep);
|
|
391
434
|
// Names only, never values: what was left out, so it can be asked for.
|
|
392
435
|
const dropped = Object.keys(d.env)
|
|
@@ -405,7 +448,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
|
|
|
405
448
|
d.err(` \`${d.cli} doctor\` shows the session backend's state.`);
|
|
406
449
|
return 1;
|
|
407
450
|
}
|
|
408
|
-
const limit = d.
|
|
451
|
+
const limit = d.maxSessions();
|
|
409
452
|
const full = sessionName(d.dist, ws, name);
|
|
410
453
|
const tty = d.stdout.isTTY;
|
|
411
454
|
return withClient(d, async (c) => {
|
|
@@ -421,6 +464,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
|
|
|
421
464
|
},
|
|
422
465
|
{
|
|
423
466
|
limit,
|
|
467
|
+
pool: "sessions",
|
|
424
468
|
// Audited by the host: each name, and whether it is
|
|
425
469
|
// credential-shaped; never a value.
|
|
426
470
|
...(kept.length
|
|
@@ -448,7 +492,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
|
|
|
448
492
|
d.err(`✗ ${e.message}`);
|
|
449
493
|
if (e.code === "cap")
|
|
450
494
|
d.err(
|
|
451
|
-
` Stop one first (\`${d.cli} sessions kill <name>\`), or raise
|
|
495
|
+
` Stop one first (\`${d.cli} sessions kill <name>\`), or raise sessions.max_concurrent.`,
|
|
452
496
|
);
|
|
453
497
|
if (e.code === "exists")
|
|
454
498
|
d.err(
|
|
@@ -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
|
+
}
|