@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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which environment variables are credentials, by name or by value. One
|
|
3
|
+
* set of patterns for everything that checks an environment: the test
|
|
4
|
+
* workers (tests/scrub-env.ts), the audit of `sessions start --keep-env`,
|
|
5
|
+
* and `<cli> doctor --probe` (with the text redactor's own patterns,
|
|
6
|
+
* redact.ts). They err on the side of flagging: a harmless variable called
|
|
7
|
+
* a credential costs less than a token called harmless.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here returns or logs a value.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { redactSecrets, SECRET_PATTERNS } from "./redact.js";
|
|
13
|
+
|
|
14
|
+
/** Variable names that hold credentials. */
|
|
15
|
+
const SECRET_NAME =
|
|
16
|
+
/(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|PRIVATE_KEY|ACCESS_KEY|AUTH)/i;
|
|
17
|
+
|
|
18
|
+
/** Whole families of credential variables. */
|
|
19
|
+
const SECRET_PREFIX = /^(OP_|DOPPLER_|AWS_|GH_|GITHUB_|NPM_|ANTHROPIC_)/i;
|
|
20
|
+
|
|
21
|
+
/** Values shaped like well-known tokens, whatever the variable is called. */
|
|
22
|
+
const SECRET_VALUE =
|
|
23
|
+
/(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|ops_[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|dp\.(st|ct|sa|scim)\.[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,})/;
|
|
24
|
+
|
|
25
|
+
/** Whether a variable looks like a credential, by name or by value. */
|
|
26
|
+
export function isCredentialEnv(
|
|
27
|
+
name: string,
|
|
28
|
+
value: string | undefined,
|
|
29
|
+
): boolean {
|
|
30
|
+
return (
|
|
31
|
+
SECRET_NAME.test(name) ||
|
|
32
|
+
SECRET_PREFIX.test(name) ||
|
|
33
|
+
(value !== undefined && SECRET_VALUE.test(value))
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Credential-shaped: by name or value (isCredentialEnv), or a value the
|
|
39
|
+
* text redactor would redact.
|
|
40
|
+
*/
|
|
41
|
+
export function isCredentialShaped(
|
|
42
|
+
name: string,
|
|
43
|
+
value: string | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
return (
|
|
46
|
+
isCredentialEnv(name, value) ||
|
|
47
|
+
(value !== undefined && value !== "" && redactSecrets(value) !== value)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every pattern isCredentialShaped uses, as plain data (source and flags,
|
|
53
|
+
* global flag dropped), for a check run in another process (the probe).
|
|
54
|
+
*/
|
|
55
|
+
export function credentialPatternData(): {
|
|
56
|
+
name: { source: string; flags: string }[];
|
|
57
|
+
value: { source: string; flags: string }[];
|
|
58
|
+
} {
|
|
59
|
+
const data = (re: RegExp) => ({
|
|
60
|
+
source: re.source,
|
|
61
|
+
flags: re.flags.replace("g", ""),
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
name: [SECRET_NAME, SECRET_PREFIX].map(data),
|
|
65
|
+
value: [SECRET_VALUE, ...SECRET_PATTERNS.map((p) => p.re)].map(data),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An environment variable name (`--keep-env NAME`). */
|
|
70
|
+
export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A client of the session host (protocol.ts): one socket connection, many
|
|
3
|
+
* requests and attachments over it. ensureHost starts a host on demand when
|
|
4
|
+
* none answers (the first client starts it; later ones find it).
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from "events";
|
|
7
|
+
import * as net from "net";
|
|
8
|
+
import {
|
|
9
|
+
frame,
|
|
10
|
+
LineReader,
|
|
11
|
+
PROTOCOL_VERSION,
|
|
12
|
+
type HostErrorCode,
|
|
13
|
+
type HostEvent,
|
|
14
|
+
} from "./protocol.js";
|
|
15
|
+
import type {
|
|
16
|
+
AttachHandlers,
|
|
17
|
+
Attachment,
|
|
18
|
+
SessionInfo,
|
|
19
|
+
SessionSignal,
|
|
20
|
+
SessionSpec,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** A request the host refused. */
|
|
24
|
+
export class HostError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
readonly code: HostErrorCode | "closed",
|
|
27
|
+
message: string,
|
|
28
|
+
) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "HostError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Pending {
|
|
35
|
+
resolve(v: Record<string, unknown>): void;
|
|
36
|
+
reject(e: Error): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let aidSeq = 0;
|
|
40
|
+
|
|
41
|
+
export class HostClient extends EventEmitter {
|
|
42
|
+
private nextId = 1;
|
|
43
|
+
private readonly pending = new Map<number, Pending>();
|
|
44
|
+
private readonly attachments = new Map<string, AttachHandlers>();
|
|
45
|
+
hostPid = 0;
|
|
46
|
+
hostStartedAt = "";
|
|
47
|
+
|
|
48
|
+
constructor(private readonly socket: net.Socket) {
|
|
49
|
+
super();
|
|
50
|
+
const reader = new LineReader((line) => this.onLine(line));
|
|
51
|
+
socket.on("data", (c) => {
|
|
52
|
+
if (!reader.push(c)) socket.destroy();
|
|
53
|
+
});
|
|
54
|
+
socket.on("error", () => undefined);
|
|
55
|
+
socket.on("close", () => {
|
|
56
|
+
for (const p of this.pending.values())
|
|
57
|
+
p.reject(
|
|
58
|
+
new HostError("closed", "the session host closed the connection"),
|
|
59
|
+
);
|
|
60
|
+
this.pending.clear();
|
|
61
|
+
this.attachments.clear();
|
|
62
|
+
this.emit("close");
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private onLine(line: string): void {
|
|
67
|
+
let msg: Record<string, unknown>;
|
|
68
|
+
try {
|
|
69
|
+
msg = JSON.parse(line) as Record<string, unknown>;
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (typeof msg.ev === "string") {
|
|
74
|
+
const ev = msg as unknown as HostEvent;
|
|
75
|
+
const h = this.attachments.get(ev.aid);
|
|
76
|
+
if (!h) return;
|
|
77
|
+
if (ev.ev === "data") h.onData(Buffer.from(ev.data, "base64"));
|
|
78
|
+
else {
|
|
79
|
+
this.attachments.delete(ev.aid);
|
|
80
|
+
h.onExit?.(ev.exitCode);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const p = this.pending.get(msg.id as number);
|
|
85
|
+
if (!p) return;
|
|
86
|
+
this.pending.delete(msg.id as number);
|
|
87
|
+
if (msg.ok) p.resolve(msg);
|
|
88
|
+
else
|
|
89
|
+
p.reject(
|
|
90
|
+
new HostError(
|
|
91
|
+
msg.code as HostErrorCode,
|
|
92
|
+
String(msg.error ?? "refused"),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Send one request; resolves with the host's reply. */
|
|
98
|
+
request(
|
|
99
|
+
op: string,
|
|
100
|
+
body: Record<string, unknown> = {},
|
|
101
|
+
): Promise<Record<string, unknown>> {
|
|
102
|
+
if (this.socket.destroyed)
|
|
103
|
+
return Promise.reject(
|
|
104
|
+
new HostError("closed", "not connected to the session host"),
|
|
105
|
+
);
|
|
106
|
+
const id = this.nextId++;
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
this.pending.set(id, { resolve, reject });
|
|
109
|
+
this.socket.write(frame({ v: PROTOCOL_VERSION, id, op, ...body }));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Fire and forget: errors are dropped (write, resize, signal). */
|
|
114
|
+
private send(op: string, body: Record<string, unknown>): void {
|
|
115
|
+
this.request(op, body).catch(() => undefined);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async hello(): Promise<void> {
|
|
119
|
+
const r = await this.request("hello");
|
|
120
|
+
this.hostPid = Number(r.pid);
|
|
121
|
+
this.hostStartedAt = String(r.startedAt ?? "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async start(
|
|
125
|
+
spec: SessionSpec,
|
|
126
|
+
opts: {
|
|
127
|
+
limit?: number;
|
|
128
|
+
/** `--keep-env` names, for the host's audit line (never values). */
|
|
129
|
+
keptEnv?: { name: string; credentialShaped: boolean }[];
|
|
130
|
+
/** Who asked, for that line. */
|
|
131
|
+
actor?: string;
|
|
132
|
+
/**
|
|
133
|
+
* `--keep-env` names Den refuses to forward (SEC-1 §5: a person's
|
|
134
|
+
* credential): the host records the refusal and starts nothing.
|
|
135
|
+
*/
|
|
136
|
+
refusedEnv?: string[];
|
|
137
|
+
} = {},
|
|
138
|
+
): Promise<SessionInfo> {
|
|
139
|
+
const r = await this.request("start", {
|
|
140
|
+
spec,
|
|
141
|
+
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
142
|
+
...(opts.keptEnv?.length || opts.refusedEnv?.length
|
|
143
|
+
? { actor: opts.actor ?? "unknown" }
|
|
144
|
+
: {}),
|
|
145
|
+
...(opts.keptEnv?.length ? { keptEnv: opts.keptEnv } : {}),
|
|
146
|
+
...(opts.refusedEnv?.length ? { refusedEnv: opts.refusedEnv } : {}),
|
|
147
|
+
});
|
|
148
|
+
return r.info as SessionInfo;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async list(): Promise<SessionInfo[]> {
|
|
152
|
+
return (await this.request("list")).sessions as SessionInfo[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Attach to session `id`: the replay (the newest `replay` bytes, all of
|
|
157
|
+
* the scrollback by default) arrives first, then live output.
|
|
158
|
+
*/
|
|
159
|
+
attach(
|
|
160
|
+
id: string,
|
|
161
|
+
handlers: AttachHandlers,
|
|
162
|
+
opts: { replay?: number } = {},
|
|
163
|
+
): Attachment {
|
|
164
|
+
const aid = `${process.pid}.${++aidSeq}`;
|
|
165
|
+
this.attachments.set(aid, handlers);
|
|
166
|
+
const ready = this.request("attach", {
|
|
167
|
+
ref: id,
|
|
168
|
+
aid,
|
|
169
|
+
...(opts.replay !== undefined ? { replay: opts.replay } : {}),
|
|
170
|
+
}).then(
|
|
171
|
+
(r) => r.info as SessionInfo,
|
|
172
|
+
(e: Error) => {
|
|
173
|
+
this.attachments.delete(aid);
|
|
174
|
+
throw e;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
return {
|
|
178
|
+
ready,
|
|
179
|
+
detach: () => {
|
|
180
|
+
this.attachments.delete(aid);
|
|
181
|
+
this.send("detach", { aid });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
write(id: string, bytes: Uint8Array): void {
|
|
187
|
+
this.send("write", {
|
|
188
|
+
ref: id,
|
|
189
|
+
data: Buffer.from(bytes).toString("base64"),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
resize(id: string, cols: number, rows: number): void {
|
|
194
|
+
this.send("resize", { ref: id, cols, rows });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
signal(id: string, signal: SessionSignal): void {
|
|
198
|
+
this.send("signal", { ref: id, signal });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Like signal, but says when the host refused (exited, unknown). */
|
|
202
|
+
async kill(id: string, signal: SessionSignal): Promise<void> {
|
|
203
|
+
await this.request("signal", { ref: id, signal });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async scrollback(id: string, max: number): Promise<Buffer> {
|
|
207
|
+
const r = await this.request("scrollback", { ref: id, max });
|
|
208
|
+
return Buffer.from(String(r.data ?? ""), "base64");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async forget(id: string): Promise<void> {
|
|
212
|
+
await this.request("forget", { ref: id });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Stop the host: every running session ends. */
|
|
216
|
+
async shutdown(): Promise<void> {
|
|
217
|
+
await this.request("shutdown");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
close(): void {
|
|
221
|
+
this.socket.end();
|
|
222
|
+
this.socket.destroy();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether a connect error means no host is serving on the socket. */
|
|
227
|
+
export function noHost(e: unknown): boolean {
|
|
228
|
+
const code = (e as NodeJS.ErrnoException)?.code;
|
|
229
|
+
return code === "ENOENT" || code === "ECONNREFUSED";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Connect to the host on `socket` and say hello (checks the protocol). */
|
|
233
|
+
export function connectHost(socket: string): Promise<HostClient> {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const s = net.connect(socket);
|
|
236
|
+
const fail = (e: Error) => reject(e);
|
|
237
|
+
s.once("error", fail);
|
|
238
|
+
s.once("connect", () => {
|
|
239
|
+
s.off("error", fail);
|
|
240
|
+
const c = new HostClient(s);
|
|
241
|
+
c.hello().then(
|
|
242
|
+
() => resolve(c),
|
|
243
|
+
(e: Error) => {
|
|
244
|
+
c.close();
|
|
245
|
+
reject(e);
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface EnsureHostOptions {
|
|
253
|
+
socket: string;
|
|
254
|
+
/** Start a host in the background (detached, its own process group). */
|
|
255
|
+
spawnHost: () => void;
|
|
256
|
+
/** How long a new host gets to answer (default 15 s). */
|
|
257
|
+
timeoutMs?: number;
|
|
258
|
+
/** Named in the timeout error: where the host writes its log. */
|
|
259
|
+
logHint?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A client of the running host, started first when none answers. */
|
|
263
|
+
export async function ensureHost(o: EnsureHostOptions): Promise<HostClient> {
|
|
264
|
+
try {
|
|
265
|
+
return await connectHost(o.socket);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (!noHost(e)) throw e;
|
|
268
|
+
}
|
|
269
|
+
o.spawnHost();
|
|
270
|
+
const deadline = Date.now() + (o.timeoutMs ?? 15_000);
|
|
271
|
+
for (;;) {
|
|
272
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
273
|
+
try {
|
|
274
|
+
return await connectHost(o.socket);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
if (!noHost(e)) throw e;
|
|
277
|
+
if (Date.now() > deadline)
|
|
278
|
+
throw new Error(
|
|
279
|
+
`the session host did not start within ${Math.round((o.timeoutMs ?? 15_000) / 1000)}s` +
|
|
280
|
+
(o.logHint ? `: see ${o.logHint}` : ""),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* den-pty: Den's built-in session backend (AG-3a). Its sessions live in the
|
|
3
|
+
* session host (host.ts), and this side is a client of it, so whoever holds
|
|
4
|
+
* a DenPtyBackend (the CLI now, the web server from AG-4a) can restart
|
|
5
|
+
* without ending a session.
|
|
6
|
+
*
|
|
7
|
+
* Registered as strategy kind `session-backend`, so `<cli> doctor` shows
|
|
8
|
+
* whether it can work here and why not. A workspace picking a backend in
|
|
9
|
+
* config (`sessions.backend`) comes with the second backend (tmux, AG-3b);
|
|
10
|
+
* until then den-pty is the implicit choice.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
registerStrategy,
|
|
14
|
+
type StrategyEntry,
|
|
15
|
+
} from "../strategies/registry.js";
|
|
16
|
+
import type { HostClient } from "./client.js";
|
|
17
|
+
import { BACKEND_ID } from "./host.js";
|
|
18
|
+
import { loadPty, type PtyLoad } from "./pty.js";
|
|
19
|
+
import type {
|
|
20
|
+
AttachHandlers,
|
|
21
|
+
Attachment,
|
|
22
|
+
SessionBackend,
|
|
23
|
+
SessionInfo,
|
|
24
|
+
SessionRef,
|
|
25
|
+
SessionSignal,
|
|
26
|
+
SessionSpec,
|
|
27
|
+
} from "./types.js";
|
|
28
|
+
|
|
29
|
+
export const SESSION_BACKEND_KIND = "session-backend";
|
|
30
|
+
export { BACKEND_ID as DEN_PTY_ID };
|
|
31
|
+
|
|
32
|
+
export interface DenPtyOptions {
|
|
33
|
+
/** A client of the running host (ensureHost). */
|
|
34
|
+
client: HostClient;
|
|
35
|
+
/** Applied to every start: agents.max_concurrent. */
|
|
36
|
+
limit?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class DenPtyBackend implements SessionBackend {
|
|
40
|
+
readonly id = BACKEND_ID;
|
|
41
|
+
constructor(private readonly opts: DenPtyOptions) {}
|
|
42
|
+
|
|
43
|
+
private own(ref: SessionRef): string {
|
|
44
|
+
if (ref.backend !== BACKEND_ID)
|
|
45
|
+
throw new Error(
|
|
46
|
+
`session ${ref.id} belongs to ${ref.backend}, not ${BACKEND_ID}`,
|
|
47
|
+
);
|
|
48
|
+
return ref.id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async start(spec: SessionSpec): Promise<SessionRef> {
|
|
52
|
+
return (await this.opts.client.start(spec, { limit: this.opts.limit })).ref;
|
|
53
|
+
}
|
|
54
|
+
list(): Promise<SessionInfo[]> {
|
|
55
|
+
return this.opts.client.list();
|
|
56
|
+
}
|
|
57
|
+
attach(ref: SessionRef, handlers: AttachHandlers): Attachment {
|
|
58
|
+
return this.opts.client.attach(this.own(ref), handlers);
|
|
59
|
+
}
|
|
60
|
+
write(ref: SessionRef, bytes: Uint8Array): void {
|
|
61
|
+
this.opts.client.write(this.own(ref), bytes);
|
|
62
|
+
}
|
|
63
|
+
resize(ref: SessionRef, cols: number, rows: number): void {
|
|
64
|
+
this.opts.client.resize(this.own(ref), cols, rows);
|
|
65
|
+
}
|
|
66
|
+
signal(ref: SessionRef, signal: SessionSignal): void {
|
|
67
|
+
this.opts.client.signal(this.own(ref), signal);
|
|
68
|
+
}
|
|
69
|
+
async scrollback(ref: SessionRef, maxBytes: number): Promise<Uint8Array> {
|
|
70
|
+
return this.opts.client.scrollback(this.own(ref), maxBytes);
|
|
71
|
+
}
|
|
72
|
+
externalAttach(): string | null {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SessionBackendCapabilities {
|
|
78
|
+
/** Whether the backend can start sessions here (the PTY module). */
|
|
79
|
+
pty(): PtyLoad;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const denPty: StrategyEntry<
|
|
83
|
+
typeof SESSION_BACKEND_KIND,
|
|
84
|
+
SessionBackendCapabilities
|
|
85
|
+
> = {
|
|
86
|
+
id: BACKEND_ID,
|
|
87
|
+
kind: SESSION_BACKEND_KIND,
|
|
88
|
+
detect() {
|
|
89
|
+
const load = loadPty();
|
|
90
|
+
return load.available
|
|
91
|
+
? { available: true, reason: "PTY module loaded" }
|
|
92
|
+
: { available: false, reason: load.reason };
|
|
93
|
+
},
|
|
94
|
+
capabilities: { pty: () => loadPty() },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
registerStrategy(denPty);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment a session starts with (AG spec §5a-env). The host passes
|
|
3
|
+
* `env` whole (it inherits nothing), so what is built here is all a session
|
|
4
|
+
* gets. There is no denylist: every session's env starts empty.
|
|
5
|
+
*
|
|
6
|
+
* 1. the allowlist, from the caller's env (SESSION_BASE_ENV, LC_*)
|
|
7
|
+
* 2. `keep`: names the user asked for (`sessions start --keep-env NAME`;
|
|
8
|
+
* exact names, never for an agent)
|
|
9
|
+
* 3. `passthrough`: names a runtime declares (Runtime.envPassthrough)
|
|
10
|
+
* 4. `launch`: the runtime's launch env (LaunchOutput.env)
|
|
11
|
+
* 5. `extra`: the persona's or runtime's declared env (none can declare
|
|
12
|
+
* one yet)
|
|
13
|
+
* 6. `den`: Den's own markers (DEN_AGENT, and SEC-1c's DEN_ACTOR_TOKEN
|
|
14
|
+
* for an agent: the only way an actor token reaches a session; one
|
|
15
|
+
* from anywhere above is dropped first)
|
|
16
|
+
* 7. then `scrub` removes every name it matches, whatever put it back:
|
|
17
|
+
* each runtime's scrubEnv (den-claude: CLAUDECODE, CLAUDE_CODE_*).
|
|
18
|
+
* 8. and always (SEC-1 §5): a person's credential, DEN_PERSON_TOKEN or
|
|
19
|
+
* any value that is a person token or names web.secret
|
|
20
|
+
* (actor-token.ts isPersonCredentialEnv), whatever put it there.
|
|
21
|
+
*
|
|
22
|
+
* The session backend knows none of this: it is handed the result. Nothing
|
|
23
|
+
* here logs or returns a value other than the env itself.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { ACTOR_TOKEN_ENV, isPersonCredentialEnv } from "../actor-token.js";
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_TERM = "xterm-256color";
|
|
29
|
+
|
|
30
|
+
/** Set in an agent's env: `agent:<id>`, so Den's CLI run inside knows. */
|
|
31
|
+
export const AGENT_ACTOR_ENV = "DEN_AGENT";
|
|
32
|
+
|
|
33
|
+
/** What a terminal program needs to run as the user: no more. */
|
|
34
|
+
export const SESSION_BASE_ENV = [
|
|
35
|
+
"PATH",
|
|
36
|
+
"HOME",
|
|
37
|
+
"USER",
|
|
38
|
+
"LOGNAME",
|
|
39
|
+
"SHELL",
|
|
40
|
+
"TERM",
|
|
41
|
+
"COLORTERM",
|
|
42
|
+
"LANG",
|
|
43
|
+
"TZ",
|
|
44
|
+
"TMPDIR",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/** Whether the allowlist takes `name` (the list, and every LC_*). */
|
|
48
|
+
export function inSessionBase(name: string): boolean {
|
|
49
|
+
return (
|
|
50
|
+
(SESSION_BASE_ENV as readonly string[]).includes(name) ||
|
|
51
|
+
name.startsWith("LC_")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a scrubEnv pattern (an exact name, or a prefix ending in *) matches. */
|
|
56
|
+
export function matchesScrub(
|
|
57
|
+
name: string,
|
|
58
|
+
patterns: readonly string[],
|
|
59
|
+
): boolean {
|
|
60
|
+
return patterns.some((p) =>
|
|
61
|
+
p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : name === p,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SessionEnvInput {
|
|
66
|
+
caller: NodeJS.ProcessEnv;
|
|
67
|
+
keep?: readonly string[];
|
|
68
|
+
passthrough?: readonly string[];
|
|
69
|
+
launch?: Record<string, string>;
|
|
70
|
+
extra?: Record<string, string>;
|
|
71
|
+
den?: Record<string, string>;
|
|
72
|
+
scrub?: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A session's environment, built as the header says. */
|
|
76
|
+
export function buildSessionEnv(i: SessionEnvInput): Record<string, string> {
|
|
77
|
+
const named = new Set([...(i.keep ?? []), ...(i.passthrough ?? [])]);
|
|
78
|
+
const env: Record<string, string> = {};
|
|
79
|
+
for (const [k, v] of Object.entries(i.caller))
|
|
80
|
+
if (v !== undefined && (inSessionBase(k) || named.has(k))) env[k] = v;
|
|
81
|
+
if (!env.TERM) env.TERM = DEFAULT_TERM;
|
|
82
|
+
Object.assign(env, i.launch ?? {}, i.extra ?? {});
|
|
83
|
+
delete env[ACTOR_TOKEN_ENV];
|
|
84
|
+
Object.assign(env, i.den ?? {});
|
|
85
|
+
const scrub = i.scrub ?? [];
|
|
86
|
+
for (const [k, v] of Object.entries(env))
|
|
87
|
+
if (matchesScrub(k, scrub) || isPersonCredentialEnv(k, v)) delete env[k];
|
|
88
|
+
return env;
|
|
89
|
+
}
|