@monster-orange/den 0.3.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.
- package/CHANGELOG.md +593 -0
- package/docs/distributions.md +429 -18
- package/package.json +6 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +271 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +199 -0
- package/src/shared/agents/runtimes.ts +54 -0
- package/src/shared/den-config.ts +116 -0
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/profile.ts +15 -4
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/sessions/client.ts +284 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +89 -0
- package/src/shared/sessions/host.ts +690 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -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 +294 -0
- package/src/ui/cli/doctor-command.ts +138 -9
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +32 -3
- package/src/ui/cli/index.ts +128 -12
- package/src/ui/cli/sessions-command.ts +750 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/scope.ts +123 -19
- package/src/ui/mcp/server.ts +100 -7
- package/src/ui/mcp/tools.ts +44 -5
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +111 -22
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +820 -65
- package/src/ui/server/public/agents-page.js +648 -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/readonly-routes.ts +25 -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 +16 -22
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> sessions start|list|attach|kill|host` (AG-3a): terminals that
|
|
3
|
+
* outlive the web server, from the CLI. `<cli> agents` (agents-command.ts)
|
|
4
|
+
* builds on the helpers exported here.
|
|
5
|
+
*
|
|
6
|
+
* Every command but `host` is a client of the distribution's session host
|
|
7
|
+
* (src/shared/sessions/host.ts) and starts it on demand. The host is a
|
|
8
|
+
* detached process of its own (its own session and process group), so it
|
|
9
|
+
* survives the web server and the CLI that started it.
|
|
10
|
+
*
|
|
11
|
+
* Usage errors exit 2; a refusal (the cap, a name in use, den-pty
|
|
12
|
+
* unavailable, an unknown session) exits 1.
|
|
13
|
+
*
|
|
14
|
+
* The R8 lock is the web UI's (an in-memory admin password in the web
|
|
15
|
+
* server); CLI service actions (up, down) do not consult it, so neither do
|
|
16
|
+
* these. agents.max_concurrent caps every start.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from "child_process";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { readAgentsConfig } from "../../shared/den-config.js";
|
|
22
|
+
import { cliEntrypoint } from "../../shared/engine.js";
|
|
23
|
+
import { getDistRoot, getInvokeCwd, getStateDir } from "../../shared/paths.js";
|
|
24
|
+
import { product } from "../../shared/product.js";
|
|
25
|
+
import { ENV_NAME_RE, isCredentialShaped } from "../../shared/secret-env.js";
|
|
26
|
+
import { allScrubEnv } from "../../shared/agents/runtimes.js";
|
|
27
|
+
import {
|
|
28
|
+
ensureHost,
|
|
29
|
+
connectHost,
|
|
30
|
+
HostError,
|
|
31
|
+
noHost,
|
|
32
|
+
type HostClient,
|
|
33
|
+
} from "../../shared/sessions/client.js";
|
|
34
|
+
import {
|
|
35
|
+
ACTOR_TOKEN_ENV,
|
|
36
|
+
isPersonCredentialEnv,
|
|
37
|
+
} from "../../shared/actor-token.js";
|
|
38
|
+
import { DEN_PTY_ID } from "../../shared/sessions/den-pty.js";
|
|
39
|
+
import { AGENT_ACTOR_ENV, buildSessionEnv } from "../../shared/sessions/env.js";
|
|
40
|
+
import { runSessionHost } from "../../shared/sessions/host.js";
|
|
41
|
+
import {
|
|
42
|
+
inWorkspace,
|
|
43
|
+
resolveSession,
|
|
44
|
+
SESSION_NAME_RE,
|
|
45
|
+
sessionName,
|
|
46
|
+
} from "../../shared/sessions/names.js";
|
|
47
|
+
import { sessionPaths } from "../../shared/sessions/paths.js";
|
|
48
|
+
import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
|
|
49
|
+
import {
|
|
50
|
+
SESSION_SIGNALS,
|
|
51
|
+
type SessionInfo,
|
|
52
|
+
type SessionSignal,
|
|
53
|
+
} from "../../shared/sessions/types.js";
|
|
54
|
+
import * as os from "os";
|
|
55
|
+
import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
56
|
+
|
|
57
|
+
/** ctrl-]: leaves the session running (as telnet's escape does). */
|
|
58
|
+
export const DETACH_KEY = 0x1d;
|
|
59
|
+
|
|
60
|
+
export const SESSIONS_HELP = {
|
|
61
|
+
usage: "sessions <start|list|attach|kill|host> ...",
|
|
62
|
+
lines: [
|
|
63
|
+
"Terminals that keep running when the web server restarts, owned by a",
|
|
64
|
+
"session host (one per distribution, started on demand).",
|
|
65
|
+
"",
|
|
66
|
+
"{cli} sessions start --workspace <ws> --name <n> [--cwd <dir>] [--keep-env NAME…] -- <argv…>",
|
|
67
|
+
" run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by agents.max_concurrent",
|
|
68
|
+
" env: PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ,",
|
|
69
|
+
" TMPDIR only; --keep-env NAME (exact, repeatable; audited) adds one",
|
|
70
|
+
" (never a person's credential: DEN_PERSON_TOKEN, a web.secret path)",
|
|
71
|
+
"{cli} sessions list [-w <ws>] [--json]",
|
|
72
|
+
"{cli} sessions attach <name|id>",
|
|
73
|
+
" raw terminal; ctrl-] detaches and leaves it running",
|
|
74
|
+
"{cli} sessions kill <name|id> [--signal INT|TERM|KILL]",
|
|
75
|
+
" default TERM; on an exited session, removes it and its scrollback",
|
|
76
|
+
"{cli} sessions host [--stop]",
|
|
77
|
+
" run the host in the foreground, or stop it (its sessions end)",
|
|
78
|
+
"",
|
|
79
|
+
"A name is the full <dist>-<ws>:<n>, <ws>:<n>, a bare <n> only one session has, or the id.",
|
|
80
|
+
"Needs the optional PTY module: `{cli} doctor` says whether den-pty can run here.",
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** A usage error: exit 2, with the command's usage line. */
|
|
85
|
+
export class UsageError extends Error {}
|
|
86
|
+
|
|
87
|
+
export interface SessionsDeps {
|
|
88
|
+
/** The distribution's state dir (getStateDir). */
|
|
89
|
+
stateDir?: () => string;
|
|
90
|
+
/** The distribution's name: the `<dist>` of a session name. */
|
|
91
|
+
dist?: string;
|
|
92
|
+
/** The distribution's CLI, for messages. */
|
|
93
|
+
cli?: string;
|
|
94
|
+
/** A client of the running host, started when none answers. */
|
|
95
|
+
connect?: () => Promise<HostClient>;
|
|
96
|
+
/** A client of the host if one is running, else null (never starts one). */
|
|
97
|
+
connectExisting?: () => Promise<HostClient | null>;
|
|
98
|
+
pty?: () => PtyLoad;
|
|
99
|
+
workspaceExists?: (ws: string) => boolean;
|
|
100
|
+
maxConcurrent?: () => number;
|
|
101
|
+
invokeCwd?: () => string;
|
|
102
|
+
env?: NodeJS.ProcessEnv;
|
|
103
|
+
out?: (line: string) => void;
|
|
104
|
+
err?: (line: string) => void;
|
|
105
|
+
stdin?: NodeJS.ReadStream;
|
|
106
|
+
stdout?: NodeJS.WriteStream;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface Resolved {
|
|
110
|
+
stateDir: () => string;
|
|
111
|
+
dist: string;
|
|
112
|
+
cli: string;
|
|
113
|
+
connect: () => Promise<HostClient>;
|
|
114
|
+
connectExisting: () => Promise<HostClient | null>;
|
|
115
|
+
pty: () => PtyLoad;
|
|
116
|
+
workspaceExists: (ws: string) => boolean;
|
|
117
|
+
maxConcurrent: () => number;
|
|
118
|
+
invokeCwd: () => string;
|
|
119
|
+
env: NodeJS.ProcessEnv;
|
|
120
|
+
out: (line: string) => void;
|
|
121
|
+
err: (line: string) => void;
|
|
122
|
+
stdin: NodeJS.ReadStream;
|
|
123
|
+
stdout: NodeJS.WriteStream;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Start `<cli> sessions host` in the background, detached, logging to the state dir. */
|
|
127
|
+
function spawnHost(stateDir: string): void {
|
|
128
|
+
const cmd = cliEntrypoint();
|
|
129
|
+
const logDir = path.join(stateDir, "logs", product().cli);
|
|
130
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
131
|
+
const fd = fs.openSync(path.join(logDir, "sessions-host.log"), "a");
|
|
132
|
+
try {
|
|
133
|
+
const env = { ...process.env };
|
|
134
|
+
delete env.DEN_WORKSPACE_OVERRIDE;
|
|
135
|
+
const child = spawn(cmd.command, [...cmd.args, "sessions", "host"], {
|
|
136
|
+
cwd: getDistRoot(),
|
|
137
|
+
// Its own session and process group: a web server's or a terminal's
|
|
138
|
+
// end (and `down`'s group signals) never reach it.
|
|
139
|
+
detached: true,
|
|
140
|
+
stdio: ["ignore", fd, fd],
|
|
141
|
+
env,
|
|
142
|
+
});
|
|
143
|
+
child.unref();
|
|
144
|
+
} finally {
|
|
145
|
+
fs.closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveDeps(d: SessionsDeps): Resolved {
|
|
150
|
+
const stateDir = d.stateDir ?? (() => getStateDir());
|
|
151
|
+
return {
|
|
152
|
+
stateDir,
|
|
153
|
+
dist: d.dist ?? product().name,
|
|
154
|
+
cli: d.cli ?? product().cli,
|
|
155
|
+
connect:
|
|
156
|
+
d.connect ??
|
|
157
|
+
(() =>
|
|
158
|
+
ensureHost({
|
|
159
|
+
socket: sessionPaths(stateDir()).socket,
|
|
160
|
+
spawnHost: () => spawnHost(stateDir()),
|
|
161
|
+
logHint: path.join(
|
|
162
|
+
stateDir(),
|
|
163
|
+
"logs",
|
|
164
|
+
product().cli,
|
|
165
|
+
"sessions-host.log",
|
|
166
|
+
),
|
|
167
|
+
})),
|
|
168
|
+
connectExisting:
|
|
169
|
+
d.connectExisting ??
|
|
170
|
+
(d.connect
|
|
171
|
+
? d.connect
|
|
172
|
+
: async () => {
|
|
173
|
+
try {
|
|
174
|
+
return await connectHost(sessionPaths(stateDir()).socket);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (noHost(e)) return null;
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
pty: d.pty ?? (() => loadPty()),
|
|
181
|
+
workspaceExists: d.workspaceExists ?? defaultWorkspaceExists,
|
|
182
|
+
maxConcurrent: d.maxConcurrent ?? (() => readAgentsConfig().maxConcurrent),
|
|
183
|
+
invokeCwd: d.invokeCwd ?? getInvokeCwd,
|
|
184
|
+
env: d.env ?? process.env,
|
|
185
|
+
out: d.out ?? ((l) => console.log(l)),
|
|
186
|
+
err: d.err ?? ((l) => console.error(l)),
|
|
187
|
+
stdin: d.stdin ?? process.stdin,
|
|
188
|
+
stdout: d.stdout ?? process.stdout,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let workspacesModule:
|
|
193
|
+
| typeof import("../../shared/environment-manager/workspaces.js")
|
|
194
|
+
| null = null;
|
|
195
|
+
function defaultWorkspaceExists(ws: string): boolean {
|
|
196
|
+
if (!workspacesModule) throw new Error("workspaces not loaded");
|
|
197
|
+
return Boolean(workspacesModule.readWorkspacesFile().workspaces[ws]);
|
|
198
|
+
}
|
|
199
|
+
export async function loadWorkspaces(d: SessionsDeps): Promise<void> {
|
|
200
|
+
if (!d.workspaceExists && !workspacesModule)
|
|
201
|
+
workspacesModule =
|
|
202
|
+
await import("../../shared/environment-manager/workspaces.js");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── argument parsing ──────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
export interface FlagSpec {
|
|
208
|
+
/** Canonical name → takes a value ("repeat": a value, any number of times). */
|
|
209
|
+
[flag: string]: boolean | "repeat";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface Parsed {
|
|
213
|
+
flags: Record<string, string | true>;
|
|
214
|
+
/** Each "repeat" flag's values, in order. */
|
|
215
|
+
repeated: Record<string, string[]>;
|
|
216
|
+
positional: string[];
|
|
217
|
+
/** After `--`, or null when there was none. */
|
|
218
|
+
rest: string[] | null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const ALIASES: Record<string, string> = { "-w": "--workspace" };
|
|
222
|
+
|
|
223
|
+
export function parseArgs(
|
|
224
|
+
where: string,
|
|
225
|
+
args: string[],
|
|
226
|
+
spec: FlagSpec,
|
|
227
|
+
): Parsed {
|
|
228
|
+
const out: Parsed = { flags: {}, repeated: {}, positional: [], rest: null };
|
|
229
|
+
for (let i = 0; i < args.length; i++) {
|
|
230
|
+
const a = args[i];
|
|
231
|
+
if (a === "--") {
|
|
232
|
+
out.rest = args.slice(i + 1);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (!a.startsWith("-") || a === "-") {
|
|
236
|
+
out.positional.push(a);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const eq = a.indexOf("=");
|
|
240
|
+
const raw = eq === -1 ? a : a.slice(0, eq);
|
|
241
|
+
const flag = ALIASES[raw] ?? raw;
|
|
242
|
+
if (!(flag in spec)) throw new UsageError(`${where}: unknown flag ${raw}`);
|
|
243
|
+
if (!spec[flag]) {
|
|
244
|
+
if (eq !== -1) throw new UsageError(`${where}: ${raw} takes no value`);
|
|
245
|
+
out.flags[flag] = true;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const value = eq !== -1 ? a.slice(eq + 1) : args[++i];
|
|
249
|
+
if (
|
|
250
|
+
value === undefined ||
|
|
251
|
+
value === "" ||
|
|
252
|
+
(eq === -1 && value.startsWith("-"))
|
|
253
|
+
)
|
|
254
|
+
throw new UsageError(`${where}: ${raw} needs a value`);
|
|
255
|
+
if (spec[flag] === "repeat") (out.repeated[flag] ??= []).push(value);
|
|
256
|
+
else out.flags[flag] = value;
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function noExtra(where: string, p: Parsed, maxPositional = 0): void {
|
|
262
|
+
if (p.positional.length > maxPositional)
|
|
263
|
+
throw new UsageError(
|
|
264
|
+
`${where}: unexpected argument ${p.positional[maxPositional]}`,
|
|
265
|
+
);
|
|
266
|
+
if (p.rest !== null) throw new UsageError(`${where}: takes nothing after --`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function workspaceFlag(where: string, p: Parsed): string | undefined {
|
|
270
|
+
const ws = p.flags["--workspace"];
|
|
271
|
+
if (ws === undefined) return undefined;
|
|
272
|
+
const name = String(ws);
|
|
273
|
+
if (!WORKSPACE_NAME_RE.test(name))
|
|
274
|
+
throw new UsageError(`${where}: "${name}" is not a workspace name`);
|
|
275
|
+
return name;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── output ────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
export function state(s: SessionInfo): string {
|
|
281
|
+
if (!s.exited) return "running";
|
|
282
|
+
return s.exitCode === undefined ? "exited" : `exited (${s.exitCode})`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function columns(rows: string[][]): string[] {
|
|
286
|
+
const widths = rows[0].map((_, i) =>
|
|
287
|
+
Math.max(...rows.map((r) => r[i].length)),
|
|
288
|
+
);
|
|
289
|
+
return rows.map((r) =>
|
|
290
|
+
r
|
|
291
|
+
.map((c, i) => (i === r.length - 1 ? c : c.padEnd(widths[i])))
|
|
292
|
+
.join(" ")
|
|
293
|
+
.trimEnd(),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A record as the contract defines it: nothing more goes into --json. */
|
|
298
|
+
export function contractInfo(s: SessionInfo): SessionInfo {
|
|
299
|
+
return {
|
|
300
|
+
ref: { backend: s.ref.backend, id: s.ref.id },
|
|
301
|
+
name: s.name,
|
|
302
|
+
pid: s.pid,
|
|
303
|
+
startedAt: s.startedAt,
|
|
304
|
+
...(s.exited ? { exited: true } : {}),
|
|
305
|
+
...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── subcommands ───────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A plain session's env (AG spec §5a-env): the allowlist, the names the
|
|
313
|
+
* user kept, then every runtime's scrubEnv. `doctor --probe` checks it.
|
|
314
|
+
*/
|
|
315
|
+
export function plainSessionEnv(
|
|
316
|
+
caller: NodeJS.ProcessEnv,
|
|
317
|
+
keep: readonly string[] = [],
|
|
318
|
+
): Record<string, string> {
|
|
319
|
+
return buildSessionEnv({ caller, keep, scrub: allScrubEnv() });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Who ran the CLI, for an audit line: a login name, printable. */
|
|
323
|
+
function cliUser(env: NodeJS.ProcessEnv): string {
|
|
324
|
+
let name = env.USER ?? env.LOGNAME ?? "";
|
|
325
|
+
if (!name)
|
|
326
|
+
try {
|
|
327
|
+
name = os.userInfo().username;
|
|
328
|
+
} catch {
|
|
329
|
+
/* none */
|
|
330
|
+
}
|
|
331
|
+
return name.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64) || "unknown";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function withClient<T>(
|
|
335
|
+
d: Resolved,
|
|
336
|
+
fn: (c: HostClient) => Promise<T>,
|
|
337
|
+
): Promise<T> {
|
|
338
|
+
const c = await d.connect();
|
|
339
|
+
try {
|
|
340
|
+
return await fn(c);
|
|
341
|
+
} finally {
|
|
342
|
+
c.close();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function start(args: string[], d: Resolved): Promise<number> {
|
|
347
|
+
const where = "sessions start";
|
|
348
|
+
const p = parseArgs(where, args, {
|
|
349
|
+
"--workspace": true,
|
|
350
|
+
"--name": true,
|
|
351
|
+
"--cwd": true,
|
|
352
|
+
"--keep-env": "repeat",
|
|
353
|
+
});
|
|
354
|
+
if (p.positional.length)
|
|
355
|
+
throw new UsageError(
|
|
356
|
+
`${where}: unexpected argument ${p.positional[0]} (put the command after --)`,
|
|
357
|
+
);
|
|
358
|
+
const ws = workspaceFlag(where, p);
|
|
359
|
+
if (!ws) throw new UsageError(`${where} needs --workspace <ws>`);
|
|
360
|
+
const name = p.flags["--name"];
|
|
361
|
+
if (typeof name !== "string")
|
|
362
|
+
throw new UsageError(`${where} needs --name <n>`);
|
|
363
|
+
if (!SESSION_NAME_RE.test(name))
|
|
364
|
+
throw new UsageError(
|
|
365
|
+
`${where}: --name "${name}" must be letters, digits, ".", "_" or "-" (1-64, no ":")`,
|
|
366
|
+
);
|
|
367
|
+
if (!p.rest || !p.rest.length)
|
|
368
|
+
throw new UsageError(
|
|
369
|
+
`${where}: give the command to run after --: -- <argv…>`,
|
|
370
|
+
);
|
|
371
|
+
if (!d.workspaceExists(ws))
|
|
372
|
+
throw new UsageError(`${where}: workspace "${ws}" does not exist`);
|
|
373
|
+
const cwdFlag = p.flags["--cwd"];
|
|
374
|
+
const cwd =
|
|
375
|
+
typeof cwdFlag === "string"
|
|
376
|
+
? path.resolve(d.invokeCwd(), cwdFlag)
|
|
377
|
+
: d.invokeCwd();
|
|
378
|
+
if (!fs.statSync(cwd, { throwIfNoEntry: false })?.isDirectory())
|
|
379
|
+
throw new UsageError(`${where}: --cwd ${cwd} is not a directory`);
|
|
380
|
+
const keep = [...new Set(p.repeated["--keep-env"] ?? [])];
|
|
381
|
+
for (const k of keep)
|
|
382
|
+
if (!ENV_NAME_RE.test(k))
|
|
383
|
+
throw new UsageError(
|
|
384
|
+
`${where}: --keep-env "${k}" is not an environment variable name (one exact name per flag)`,
|
|
385
|
+
);
|
|
386
|
+
// An agent's env is its allowlist and its runtime's, never widened by
|
|
387
|
+
// the agent itself (`agents start` has no --keep-env).
|
|
388
|
+
const actor = d.env[AGENT_ACTOR_ENV];
|
|
389
|
+
if (keep.length && actor !== undefined) {
|
|
390
|
+
d.err(
|
|
391
|
+
`✗ --keep-env is refused for an agent (${AGENT_ACTOR_RE.test(actor) ? actor : "agent"}): its env is the allowlist and its runtime's`,
|
|
392
|
+
);
|
|
393
|
+
return 1;
|
|
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
|
+
}
|
|
422
|
+
const env = plainSessionEnv(d.env, keep);
|
|
423
|
+
// Names only, never values: what was left out, so it can be asked for.
|
|
424
|
+
const dropped = Object.keys(d.env)
|
|
425
|
+
.filter((k) => d.env[k] !== undefined && !(k in env))
|
|
426
|
+
.sort();
|
|
427
|
+
const kept = keep
|
|
428
|
+
.filter((k) => k in env)
|
|
429
|
+
.map((name) => ({
|
|
430
|
+
name,
|
|
431
|
+
credentialShaped: isCredentialShaped(name, d.env[name]),
|
|
432
|
+
}));
|
|
433
|
+
|
|
434
|
+
const pty = d.pty();
|
|
435
|
+
if (!pty.available) {
|
|
436
|
+
d.err(`✗ ${DEN_PTY_ID} is unavailable: ${pty.reason}`);
|
|
437
|
+
d.err(` \`${d.cli} doctor\` shows the session backend's state.`);
|
|
438
|
+
return 1;
|
|
439
|
+
}
|
|
440
|
+
const limit = d.maxConcurrent();
|
|
441
|
+
const full = sessionName(d.dist, ws, name);
|
|
442
|
+
const tty = d.stdout.isTTY;
|
|
443
|
+
return withClient(d, async (c) => {
|
|
444
|
+
try {
|
|
445
|
+
const info = await c.start(
|
|
446
|
+
{
|
|
447
|
+
name: full,
|
|
448
|
+
cwd,
|
|
449
|
+
argv: p.rest!,
|
|
450
|
+
env,
|
|
451
|
+
cols: (tty && d.stdout.columns) || 80,
|
|
452
|
+
rows: (tty && d.stdout.rows) || 24,
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
limit,
|
|
456
|
+
// Audited by the host: each name, and whether it is
|
|
457
|
+
// credential-shaped; never a value.
|
|
458
|
+
...(kept.length
|
|
459
|
+
? { keptEnv: kept, actor: `user ${cliUser(d.env)}` }
|
|
460
|
+
: {}),
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
d.out(`started ${info.name} (id ${info.ref.id}, pid ${info.pid})`);
|
|
464
|
+
// With an allowlist nearly everything is left out: the first few
|
|
465
|
+
// names, then a count, so the line stays readable.
|
|
466
|
+
if (dropped.length)
|
|
467
|
+
d.out(
|
|
468
|
+
` dropped from its environment: ${dropped.slice(0, 8).join(", ")}` +
|
|
469
|
+
(dropped.length > 8 ? ` and ${dropped.length - 8} more` : "") +
|
|
470
|
+
" (keep one with --keep-env NAME)",
|
|
471
|
+
);
|
|
472
|
+
for (const k of kept.filter((x) => x.credentialShaped))
|
|
473
|
+
d.err(
|
|
474
|
+
` ⚠ --keep-env ${k.name}: credential-shaped; passed because you named it`,
|
|
475
|
+
);
|
|
476
|
+
d.out(` attach: ${d.cli} sessions attach ${info.name}`);
|
|
477
|
+
return 0;
|
|
478
|
+
} catch (e) {
|
|
479
|
+
if (!(e instanceof HostError)) throw e;
|
|
480
|
+
d.err(`✗ ${e.message}`);
|
|
481
|
+
if (e.code === "cap")
|
|
482
|
+
d.err(
|
|
483
|
+
` Stop one first (\`${d.cli} sessions kill <name>\`), or raise agents.max_concurrent.`,
|
|
484
|
+
);
|
|
485
|
+
if (e.code === "exists")
|
|
486
|
+
d.err(
|
|
487
|
+
` Attach to it (\`${d.cli} sessions attach ${full}\`) or pick another --name.`,
|
|
488
|
+
);
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function listSessions(
|
|
495
|
+
d: Resolved,
|
|
496
|
+
ws?: string,
|
|
497
|
+
): Promise<SessionInfo[]> {
|
|
498
|
+
const all = await withClient(d, (c) => c.list());
|
|
499
|
+
return ws ? all.filter((s) => inWorkspace(s, d.dist, ws)) : all;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function list(args: string[], d: Resolved): Promise<number> {
|
|
503
|
+
const where = "sessions list";
|
|
504
|
+
const p = parseArgs(where, args, { "--workspace": true, "--json": false });
|
|
505
|
+
noExtra(where, p);
|
|
506
|
+
const ws = workspaceFlag(where, p);
|
|
507
|
+
const sessions = await listSessions(d, ws);
|
|
508
|
+
if (p.flags["--json"]) {
|
|
509
|
+
d.out(JSON.stringify(sessions.map(contractInfo), null, 2));
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
if (!sessions.length) {
|
|
513
|
+
d.out(ws ? `no sessions in ${ws}` : "no sessions");
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
for (const l of columns([
|
|
517
|
+
["NAME", "ID", "STATE", "PID", "STARTED"],
|
|
518
|
+
...sessions.map((s) => [
|
|
519
|
+
s.name,
|
|
520
|
+
s.ref.id,
|
|
521
|
+
state(s),
|
|
522
|
+
String(s.pid),
|
|
523
|
+
s.startedAt,
|
|
524
|
+
]),
|
|
525
|
+
]))
|
|
526
|
+
d.out(l);
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function target(
|
|
531
|
+
where: string,
|
|
532
|
+
p: Parsed,
|
|
533
|
+
d: Resolved,
|
|
534
|
+
c: HostClient,
|
|
535
|
+
): Promise<SessionInfo | null> {
|
|
536
|
+
const ws = workspaceFlag(where, p);
|
|
537
|
+
try {
|
|
538
|
+
return resolveSession(await c.list(), d.dist, p.positional[0], ws);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
d.err(`✗ ${(e as Error).message}`);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function kill(args: string[], d: Resolved): Promise<number> {
|
|
546
|
+
const where = "sessions kill";
|
|
547
|
+
const p = parseArgs(where, args, { "--workspace": true, "--signal": true });
|
|
548
|
+
if (!p.positional.length) throw new UsageError(`${where} needs <name|id>`);
|
|
549
|
+
noExtra(where, p, 1);
|
|
550
|
+
const raw = p.flags["--signal"];
|
|
551
|
+
const sig = (
|
|
552
|
+
typeof raw === "string" ? raw.toUpperCase().replace(/^SIG/, "") : "TERM"
|
|
553
|
+
) as SessionSignal;
|
|
554
|
+
if (!SESSION_SIGNALS.includes(sig))
|
|
555
|
+
throw new UsageError(
|
|
556
|
+
`${where}: --signal takes ${SESSION_SIGNALS.join("|")}`,
|
|
557
|
+
);
|
|
558
|
+
return withClient(d, async (c) => {
|
|
559
|
+
const s = await target(where, p, d, c);
|
|
560
|
+
if (!s) return 1;
|
|
561
|
+
if (s.exited) {
|
|
562
|
+
await c.forget(s.ref.id);
|
|
563
|
+
d.out(`removed ${s.name} (it had exited) and its scrollback`);
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
await c.kill(s.ref.id, sig);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
if (!(e instanceof HostError)) throw e;
|
|
570
|
+
d.err(`✗ ${e.message}`);
|
|
571
|
+
return 1;
|
|
572
|
+
}
|
|
573
|
+
d.out(`sent ${sig} to ${s.name} (pid ${s.pid})`);
|
|
574
|
+
return 0;
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function attach(args: string[], d: Resolved): Promise<number> {
|
|
579
|
+
const where = "sessions attach";
|
|
580
|
+
const p = parseArgs(where, args, { "--workspace": true });
|
|
581
|
+
if (!p.positional.length) throw new UsageError(`${where} needs <name|id>`);
|
|
582
|
+
noExtra(where, p, 1);
|
|
583
|
+
const c = await d.connect();
|
|
584
|
+
const s = await target(where, p, d, c);
|
|
585
|
+
if (!s) {
|
|
586
|
+
c.close();
|
|
587
|
+
return 1;
|
|
588
|
+
}
|
|
589
|
+
return attachTerminal(c, s, d);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Raw passthrough between this terminal and the session: keys go in as
|
|
594
|
+
* typed, output comes out as sent, SIGWINCH resizes, ctrl-] detaches.
|
|
595
|
+
*/
|
|
596
|
+
function attachTerminal(
|
|
597
|
+
c: HostClient,
|
|
598
|
+
s: SessionInfo,
|
|
599
|
+
d: Resolved,
|
|
600
|
+
): Promise<number> {
|
|
601
|
+
const { stdin, stdout } = d;
|
|
602
|
+
const id = s.ref.id;
|
|
603
|
+
return new Promise((resolve) => {
|
|
604
|
+
let done = false;
|
|
605
|
+
const raw = Boolean(stdin.isTTY && typeof stdin.setRawMode === "function");
|
|
606
|
+
const resize = () => {
|
|
607
|
+
if (stdout.isTTY && stdout.columns && stdout.rows)
|
|
608
|
+
c.resize(id, stdout.columns, stdout.rows);
|
|
609
|
+
};
|
|
610
|
+
const finish = (code: number, message: string) => {
|
|
611
|
+
if (done) return;
|
|
612
|
+
done = true;
|
|
613
|
+
stdin.off("data", onInput);
|
|
614
|
+
stdin.off("end", onEnd);
|
|
615
|
+
process.off("SIGWINCH", resize);
|
|
616
|
+
if (raw) stdin.setRawMode(false);
|
|
617
|
+
stdin.pause();
|
|
618
|
+
// A paused pipe still holds the event loop open: let go of it.
|
|
619
|
+
(stdin as { unref?: () => void }).unref?.();
|
|
620
|
+
d.err(message);
|
|
621
|
+
c.close();
|
|
622
|
+
resolve(code);
|
|
623
|
+
};
|
|
624
|
+
// The banner comes first: the replay that follows is the session's own.
|
|
625
|
+
if (!s.exited) d.err(`[attached to ${s.name}; detach: ctrl-]]`);
|
|
626
|
+
const att = c.attach(id, {
|
|
627
|
+
onData: (data) => stdout.write(data),
|
|
628
|
+
onExit: (code) =>
|
|
629
|
+
finish(
|
|
630
|
+
0,
|
|
631
|
+
`\r\n[${s.name} exited${code === undefined ? "" : ` (${code})`}]`,
|
|
632
|
+
),
|
|
633
|
+
});
|
|
634
|
+
const onInput = (chunk: Buffer) => {
|
|
635
|
+
const at = chunk.indexOf(DETACH_KEY);
|
|
636
|
+
if (at === -1) return c.write(id, chunk);
|
|
637
|
+
if (at > 0) c.write(id, chunk.subarray(0, at));
|
|
638
|
+
att.detach();
|
|
639
|
+
finish(0, `\r\n[detached from ${s.name}; it keeps running]`);
|
|
640
|
+
};
|
|
641
|
+
const onEnd = () => {
|
|
642
|
+
att.detach();
|
|
643
|
+
finish(0, `\r\n[detached from ${s.name}; it keeps running]`);
|
|
644
|
+
};
|
|
645
|
+
c.once("close", () => finish(1, `\r\n[the session host went away]`));
|
|
646
|
+
att.ready.then(
|
|
647
|
+
() => {
|
|
648
|
+
if (done) return;
|
|
649
|
+
resize();
|
|
650
|
+
},
|
|
651
|
+
(e: Error) => finish(1, `✗ ${e.message}`),
|
|
652
|
+
);
|
|
653
|
+
if (raw) stdin.setRawMode(true);
|
|
654
|
+
stdin.on("data", onInput);
|
|
655
|
+
stdin.on("end", onEnd);
|
|
656
|
+
stdin.resume();
|
|
657
|
+
process.on("SIGWINCH", resize);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function host(args: string[], d: Resolved): Promise<number> {
|
|
662
|
+
const where = "sessions host";
|
|
663
|
+
const p = parseArgs(where, args, { "--stop": false });
|
|
664
|
+
noExtra(where, p);
|
|
665
|
+
if (!p.flags["--stop"])
|
|
666
|
+
return runSessionHost({ stateDir: d.stateDir(), pty: () => loadPty() });
|
|
667
|
+
const c = await d.connectExisting();
|
|
668
|
+
if (!c) {
|
|
669
|
+
d.out("no session host is running");
|
|
670
|
+
return 0;
|
|
671
|
+
}
|
|
672
|
+
const pid = c.hostPid;
|
|
673
|
+
const running = (await c.list().catch(() => [])).filter(
|
|
674
|
+
(s) => !s.exited,
|
|
675
|
+
).length;
|
|
676
|
+
const closed = new Promise<void>((r) => c.once("close", () => r()));
|
|
677
|
+
await c.shutdown();
|
|
678
|
+
// The host drops every connection, then removes its socket.
|
|
679
|
+
const sock = sessionPaths(d.stateDir()).socket;
|
|
680
|
+
const deadline = Date.now() + 5000;
|
|
681
|
+
await Promise.race([closed, new Promise((r) => setTimeout(r, 5000))]);
|
|
682
|
+
while (fs.existsSync(sock) && Date.now() < deadline)
|
|
683
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
684
|
+
c.close();
|
|
685
|
+
d.out(
|
|
686
|
+
`stopped the session host (pid ${pid})` +
|
|
687
|
+
(running
|
|
688
|
+
? `; ${running} running session${running === 1 ? "" : "s"} ended`
|
|
689
|
+
: ""),
|
|
690
|
+
);
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function usageLine(
|
|
695
|
+
cli: string,
|
|
696
|
+
help: { usage: string },
|
|
697
|
+
cmd: string,
|
|
698
|
+
): string {
|
|
699
|
+
return `Usage: ${cli} ${help.usage} (\`${cli} ${cmd} --help\`)`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export async function guarded(
|
|
703
|
+
d: Resolved,
|
|
704
|
+
help: { usage: string },
|
|
705
|
+
cmd: string,
|
|
706
|
+
fn: () => Promise<number>,
|
|
707
|
+
): Promise<number> {
|
|
708
|
+
try {
|
|
709
|
+
return await fn();
|
|
710
|
+
} catch (e) {
|
|
711
|
+
if (e instanceof UsageError) {
|
|
712
|
+
d.err(`✗ ${e.message}`);
|
|
713
|
+
d.err(usageLine(d.cli, help, cmd));
|
|
714
|
+
return 2;
|
|
715
|
+
}
|
|
716
|
+
if (e instanceof HostError) {
|
|
717
|
+
d.err(`✗ ${e.message}`);
|
|
718
|
+
return 1;
|
|
719
|
+
}
|
|
720
|
+
throw e;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** `<cli> sessions ...`; returns the exit code. */
|
|
725
|
+
export async function cmdSessions(
|
|
726
|
+
args: string[],
|
|
727
|
+
deps: SessionsDeps = {},
|
|
728
|
+
): Promise<number> {
|
|
729
|
+
const d = resolveDeps(deps);
|
|
730
|
+
const [sub, ...rest] = args;
|
|
731
|
+
return guarded(d, SESSIONS_HELP, "sessions", async () => {
|
|
732
|
+
switch (sub) {
|
|
733
|
+
case "start":
|
|
734
|
+
await loadWorkspaces(deps);
|
|
735
|
+
return start(rest, d);
|
|
736
|
+
case "list":
|
|
737
|
+
return list(rest, d);
|
|
738
|
+
case "attach":
|
|
739
|
+
return attach(rest, d);
|
|
740
|
+
case "kill":
|
|
741
|
+
return kill(rest, d);
|
|
742
|
+
case "host":
|
|
743
|
+
return host(rest, d);
|
|
744
|
+
case undefined:
|
|
745
|
+
throw new UsageError("sessions needs a subcommand");
|
|
746
|
+
default:
|
|
747
|
+
throw new UsageError(`unknown sessions command "${sub}"`);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
}
|