ac-teams 0.0.6 → 0.0.8
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/README.md +4 -3
- package/docs/access.md +103 -0
- package/docs/design/session-roles/README.md +343 -0
- package/docs/design/session-roles/coordinator.yaml +44 -0
- package/docs/design/session-roles/design.yaml +70 -0
- package/docs/design/session-roles/herdr.yaml +17 -0
- package/docs/design/session-roles/journey.md +139 -0
- package/docs/design/session-roles/main-session.yaml +34 -0
- package/docs/design/session-roles/member.yaml +28 -0
- package/docs/design/session-roles/read-guard.yaml +17 -0
- package/docs/design/session-roles/team-store.yaml +13 -0
- package/docs/design/session-roles/transcripts.yaml +23 -0
- package/docs/design/session-roles/user.yaml +17 -0
- package/docs/development.md +18 -0
- package/docs/operations.md +18 -7
- package/docs/usage.md +14 -3
- package/package.json +1 -1
- package/src/access.ts +175 -0
- package/src/commands.ts +1 -1
- package/src/config.ts +4 -2
- package/src/coordinator.ts +472 -38
- package/src/egress.ts +187 -0
- package/src/index.ts +253 -58
- package/src/mains.ts +41 -0
- package/src/panel.ts +9 -1
- package/src/sandbox-bridge.mjs +33 -0
- package/src/sandbox.Dockerfile +8 -0
- package/src/schemas.ts +10 -2
- package/src/store.ts +38 -11
- package/src/terminal-agent.ts +1 -1
- package/src/terminal-ui.ts +6 -3
- package/src/types.ts +24 -4
- package/src/ui-types.ts +4 -0
package/src/coordinator.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { createServer, type Server, type Socket } from "node:net";
|
|
2
|
-
import {
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import {
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { assertNarrower, assertStableGrants, mountSet, within, containerProblems, dockerCreateArgs, sandboxMounts, maxSandboxed, memberImage, packageRoot, readable, resolveAccess, sandboxStatus, type Access, type SandboxPlan, type SandboxStatus } from "./access.ts";
|
|
11
|
+
import { Egress, egressPort, providerRoutes, type Credentials } from "./egress.ts";
|
|
5
12
|
import { Store, processAlive, processIdentity } from "./store.ts";
|
|
13
|
+
import { stateRoot } from "./config.ts";
|
|
6
14
|
import { RpcAgent } from "./rpc-agent.ts";
|
|
7
15
|
import { TerminalAgent, tmux } from "./terminal-agent.ts";
|
|
8
16
|
import { authentic, isRecord, readLines, send } from "./connection.ts";
|
|
9
17
|
import { validate } from "./schemas.ts";
|
|
10
18
|
import { createWorktree, removeWorktree } from "./worktrees.ts";
|
|
11
|
-
import {
|
|
19
|
+
import { launchMain, mainEnv, teamEntryType, handoverPrompt, type MainLaunch, type TeamEntry } from "./mains.ts";
|
|
20
|
+
import { canControl, isDescendant, isLive, isMain, memberName, taskName, type AgentArgs, type Member, type MessageArgs, type TaskArgs, type TeamState, type UserAction, type Message, type ContextUsage } from "./types.ts";
|
|
12
21
|
|
|
13
22
|
export const taskRequestGuidance = [
|
|
14
23
|
"Treat user task requests as proposals to assess, not commands to create tasks.",
|
|
@@ -27,9 +36,30 @@ export function assistantEvent(message: unknown): { type: string; text: string }
|
|
|
27
36
|
return text ? { type: "summary", text } : undefined;
|
|
28
37
|
}
|
|
29
38
|
export interface CoordinatorOptions {
|
|
39
|
+
/** Team ID: the session ID of the session that created the team. */
|
|
30
40
|
id: string; cwd: string; sessionFile?: string; limit: number; depth: number;
|
|
41
|
+
/** This session's member ID in the team (default "root", the creator); sessionId defaults to the team ID. */
|
|
42
|
+
self?: string; sessionId?: string;
|
|
43
|
+
/** Add this session to an existing team as a new main session (/teams join with no coordinator running). */
|
|
44
|
+
join?: boolean;
|
|
45
|
+
/** Another main session asked this owner session to shut down. */
|
|
46
|
+
shutdown?: () => void;
|
|
47
|
+
/** Test hook: opens a main session's terminal (default: a Herdr tab, else tmux). */
|
|
48
|
+
launchMain?: (spec: MainLaunch) => Promise<string>;
|
|
49
|
+
/** Test hook: runs after each recorded promotion step. */
|
|
50
|
+
promotionStep?: (step: string) => Promise<void> | void;
|
|
31
51
|
extension: string; home?: string; command?: string;
|
|
32
52
|
interactive?: boolean; model?: string; thinking?: string;
|
|
53
|
+
/** Version of the running Pi; members use the image built for it. Defaults to the `pi` on PATH. */
|
|
54
|
+
piVersion?: string;
|
|
55
|
+
/** Test hook: provider token refresh interval (default one minute). */
|
|
56
|
+
refreshMs?: number;
|
|
57
|
+
/** Test hook: runs after a member container passes all checks and before it starts. */
|
|
58
|
+
beforeStart?: (member: string) => Promise<void>;
|
|
59
|
+
/** Main's session directory; member transcripts live under <it>/ac-teams/<team>/<member>. */
|
|
60
|
+
sessionDir?: string;
|
|
61
|
+
/** Parent-side provider credentials for sandboxed members; without it enforced access is unavailable. */
|
|
62
|
+
credentials?: Credentials;
|
|
33
63
|
notice: (through: number, messages: Message[]) => void;
|
|
34
64
|
warning: (message: string) => void;
|
|
35
65
|
status?: (kind: "busy" | "blocked", active: boolean) => void;
|
|
@@ -49,12 +79,20 @@ export class Coordinator {
|
|
|
49
79
|
private closed = false;
|
|
50
80
|
private status = { busy: false, blocked: false };
|
|
51
81
|
private closePromise?: Promise<void>;
|
|
82
|
+
private sandboxes = new Map<string, { egress: Egress; grants: { path: string; fd: number; dev: number; ino: number }[]; release: (go: boolean) => void; refresh: NodeJS.Timeout; token: string }>();
|
|
83
|
+
private plans = new Map<string, SandboxPlan>();
|
|
84
|
+
private releasing = new Map<string, Promise<void>>();
|
|
85
|
+
private sessionDirs = new Map<string, string>();
|
|
86
|
+
private sandbox?: { status: SandboxStatus; at: number };
|
|
52
87
|
|
|
53
88
|
private options: CoordinatorOptions;
|
|
89
|
+
/** The main session hosting this coordinator. */
|
|
90
|
+
readonly self: string;
|
|
54
91
|
private constructor(options: CoordinatorOptions) {
|
|
55
92
|
this.options = options;
|
|
56
|
-
|
|
57
|
-
const
|
|
93
|
+
this.self = options.self ?? "root";
|
|
94
|
+
const root: Member = { id: "root", role: "main", depth: 0, token: randomUUID(), cwd: options.cwd, status: "idle", notified: 0, sessionId: options.id, sessionFile: options.sessionFile, model: options.model, thinking: options.thinking };
|
|
95
|
+
const initial: TeamState = { version: 4, id: options.id, key: randomUUID(), limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
|
|
58
96
|
this.store = new Store(initial, options.home);
|
|
59
97
|
this.socketPath = join(this.store.dir, "coordinator.sock");
|
|
60
98
|
}
|
|
@@ -62,7 +100,7 @@ export class Coordinator {
|
|
|
62
100
|
const coordinator = new Coordinator(options);
|
|
63
101
|
try {
|
|
64
102
|
for (const member of Object.values(coordinator.store.state.members)) {
|
|
65
|
-
if (member
|
|
103
|
+
if (!isMain(member) && isLive(member) && member.pid && processAlive(member.pid, member.identity)) {
|
|
66
104
|
throw new Error(`Previous member ${member.id} is still live or uncertain; stop it explicitly before recovery`);
|
|
67
105
|
}
|
|
68
106
|
}
|
|
@@ -70,19 +108,32 @@ export class Coordinator {
|
|
|
70
108
|
state.limit = options.limit; state.depth = options.depth;
|
|
71
109
|
const names = new Set(Object.values(state.members).map(member => member.name));
|
|
72
110
|
let number = 0;
|
|
111
|
+
if (options.join) coordinator.addMain(state, coordinator.self, options.sessionId ?? options.id, options.sessionFile, options.cwd);
|
|
112
|
+
const self = state.members[coordinator.self];
|
|
113
|
+
if (!isMain(self)) throw new Error("This session is not a main session of the team");
|
|
73
114
|
for (const member of Object.values(state.members)) if (member.id !== "root" && !member.name) {
|
|
74
115
|
do { number++; } while (names.has(`teammate-${number}`) || Object.hasOwn(state.members, `teammate-${number}`));
|
|
75
116
|
member.name = `teammate-${number}`; names.add(member.name);
|
|
76
117
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
118
|
+
self.status = "idle"; delete self.error;
|
|
119
|
+
self.sessionFile = options.sessionFile ?? self.sessionFile;
|
|
120
|
+
self.model = options.model;
|
|
121
|
+
self.thinking = options.thinking;
|
|
81
122
|
for (const member of Object.values(state.members)) {
|
|
82
123
|
delete member.needsInput;
|
|
83
|
-
|
|
124
|
+
// Other main sessions reconnect as clients; until then they are not running here.
|
|
125
|
+
if (member.id !== coordinator.self && isLive(member)) member.status = "interrupted";
|
|
84
126
|
}
|
|
85
127
|
});
|
|
128
|
+
// A promotion cut short by a crash: return the member to where it started.
|
|
129
|
+
for (const member of Object.values(coordinator.store.state.members)) if (member.promotion) coordinator.rollback(member.id, "Promotion was interrupted");
|
|
130
|
+
// Owner lock held and no member process alive: containers left by a killed coordinator are orphans of this store.
|
|
131
|
+
for (const member of Object.values(coordinator.store.state.members)) if (member.access) {
|
|
132
|
+
const name = coordinator.containerName(member.id);
|
|
133
|
+
const owner = await docker(["inspect", "--format", '{{index .Config.Labels "ac-teams.store"}}', name]).catch(() => "");
|
|
134
|
+
if (owner.trim() === coordinator.store.dir) await docker(["rm", "-f", name]).catch(error => options.warning(`Cannot remove orphaned member container ${name}: ${String(error)}`));
|
|
135
|
+
rmSync(memberConfigDir(member.id), { recursive: true, force: true });
|
|
136
|
+
}
|
|
86
137
|
try { unlinkSync(coordinator.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
87
138
|
coordinator.server = createServer(socket => coordinator.accept(socket));
|
|
88
139
|
await new Promise<void>((done, fail) => {
|
|
@@ -90,7 +141,10 @@ export class Coordinator {
|
|
|
90
141
|
coordinator.server!.listen(coordinator.socketPath, () => { chmodSync(coordinator.socketPath, 0o600); done(); });
|
|
91
142
|
});
|
|
92
143
|
coordinator.server.on("error", error => options.warning(error.message));
|
|
93
|
-
coordinator.notify(
|
|
144
|
+
coordinator.notify(coordinator.self);
|
|
145
|
+
const pass = coordinator.resumePass();
|
|
146
|
+
coordinator.operations.add(pass);
|
|
147
|
+
void pass.finally(() => coordinator.operations.delete(pass));
|
|
94
148
|
return coordinator;
|
|
95
149
|
} catch (error) { coordinator.server?.close(); coordinator.store.close(); throw error; }
|
|
96
150
|
}
|
|
@@ -104,7 +158,12 @@ export class Coordinator {
|
|
|
104
158
|
if (bound && this.sockets.get(bound) === socket) {
|
|
105
159
|
this.sockets.delete(bound);
|
|
106
160
|
if (!this.closed) {
|
|
107
|
-
this.store.update(state => {
|
|
161
|
+
this.store.update(state => {
|
|
162
|
+
const member = state.members[bound!];
|
|
163
|
+
delete member.needsInput;
|
|
164
|
+
// A main session is not a coordinator process: its socket is its liveness.
|
|
165
|
+
if (isMain(member) && isLive(member)) member.status = member.status === "stopping" ? "stopped" : "interrupted";
|
|
166
|
+
});
|
|
108
167
|
this.publishStatus();
|
|
109
168
|
}
|
|
110
169
|
for (const [id, pending] of this.controls) if (pending.member === bound) {
|
|
@@ -114,8 +173,20 @@ export class Coordinator {
|
|
|
114
173
|
});
|
|
115
174
|
readLines(socket, value => {
|
|
116
175
|
if (!isRecord(value) || typeof value.id !== "string" || value.id.length > 128 || typeof value.member !== "string" || typeof value.token !== "string" || typeof value.operation !== "string") { socket.destroy(); return; }
|
|
176
|
+
if (value.operation === "add_main" && !bound && !this.closed && authentic(value.token, this.store.state.key)) {
|
|
177
|
+
// The team key sits in the private store, so only an unsandboxed session of this user can join as a main session.
|
|
178
|
+
const args = value.args;
|
|
179
|
+
void Promise.resolve().then(() => {
|
|
180
|
+
if (!isRecord(args) || typeof args.sessionId !== "string" || typeof args.cwd !== "string" || (args.sessionFile !== undefined && typeof args.sessionFile !== "string")) throw new Error("Invalid join request");
|
|
181
|
+
const id = randomUUID().slice(0, 16);
|
|
182
|
+
this.store.update(state => this.addMain(state, id, args.sessionId as string, args.sessionFile as string | undefined, args.cwd as string));
|
|
183
|
+
return { id, token: this.store.state.members[id].token };
|
|
184
|
+
}).then(data => send(socket, { id: value.id, data }), error => send(socket, { id: value.id, error: String(error instanceof Error ? error.message : error) }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
117
187
|
const member = this.store.state.members[value.member];
|
|
118
|
-
|
|
188
|
+
const main = isMain(member);
|
|
189
|
+
if ((this.closed && value.operation !== "control_result") || !member || !(main || isLive(member)) || !authentic(value.token, member.token) || member.id === this.self || (bound && bound !== member.id)) { socket.destroy(); return; }
|
|
119
190
|
bound = member.id;
|
|
120
191
|
const operation = value.operation;
|
|
121
192
|
const run = async () => {
|
|
@@ -124,6 +195,22 @@ export class Coordinator {
|
|
|
124
195
|
if (previous && previous !== socket && !previous.destroyed) throw new Error("Member already connected");
|
|
125
196
|
const args = value.args;
|
|
126
197
|
if (!isRecord(args) || typeof args.sessionId !== "string" || typeof args.sessionFile !== "string" || !Array.isArray(args.tools) || !["team_agent", "team_task", "team_message"].every(name => Array.isArray(args.tools) && args.tools.includes(name))) throw new Error("Incomplete ac-teams readiness handshake");
|
|
198
|
+
if (main) {
|
|
199
|
+
// A main session's identity never changes; its transcript may live anywhere the user keeps sessions.
|
|
200
|
+
if (member.sessionId && args.sessionId !== member.sessionId) throw new Error("This main session's record belongs to another Pi session");
|
|
201
|
+
this.store.update(state => {
|
|
202
|
+
const current = state.members[member.id];
|
|
203
|
+
current.status = "idle"; delete current.error;
|
|
204
|
+
current.sessionId = args.sessionId as string; current.sessionFile = args.sessionFile as string;
|
|
205
|
+
});
|
|
206
|
+
this.sockets.set(member.id, socket);
|
|
207
|
+
this.publishStatus();
|
|
208
|
+
this.notify(member.id);
|
|
209
|
+
return { id: this.store.state.id, member: this.publicMember(this.store.state.members[member.id]), limit: this.store.state.limit, depth: this.store.state.depth };
|
|
210
|
+
}
|
|
211
|
+
// The session file chooses a host mount on resume, so it must stay in the directory ac-teams assigned.
|
|
212
|
+
const assigned = this.sessionDirs.get(member.id) ?? this.memberSessionDir(member.id);
|
|
213
|
+
if (!assigned || !within(resolve(args.sessionFile as string), assigned)) throw new Error("Member session file is outside its assigned directory");
|
|
127
214
|
if (args.trusted !== true) throw new Error("Pi skipped protected project resources. Approve the member cwd with Pi's /trust command before retrying; ac-teams never auto-approves a project.");
|
|
128
215
|
this.store.update(state => {
|
|
129
216
|
state.members[member.id].sessionId = args.sessionId as string;
|
|
@@ -198,8 +285,8 @@ export class Coordinator {
|
|
|
198
285
|
else if (event.type === "model_error") current.error = event.text || "Model request failed";
|
|
199
286
|
});
|
|
200
287
|
this.publishStatus();
|
|
201
|
-
if (event.type === "model_error" &&
|
|
202
|
-
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent :
|
|
288
|
+
if (event.type === "model_error" && !isMain(member) && member.status !== "stopping") {
|
|
289
|
+
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : this.self;
|
|
203
290
|
this.store.message(actor, { action: "send", recipients: [recipient], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
|
|
204
291
|
this.notify(recipient);
|
|
205
292
|
}
|
|
@@ -211,7 +298,7 @@ export class Coordinator {
|
|
|
211
298
|
if (!member) throw new Error("Unknown member ID");
|
|
212
299
|
if (member.status === "stopping") throw new Error("Member is stopping; retry after it stops");
|
|
213
300
|
if (isLive(member)) return;
|
|
214
|
-
const launch = this.launch(
|
|
301
|
+
const launch = this.launch(this.self, { action: "resume", id, brief: "Resume the native session without replaying work.", model: member.model ?? args.model, thinking: member.thinking ?? args.thinking }, false);
|
|
215
302
|
this.operations.add(launch);
|
|
216
303
|
try { await launch; } finally { this.operations.delete(launch); }
|
|
217
304
|
};
|
|
@@ -219,28 +306,47 @@ export class Coordinator {
|
|
|
219
306
|
if (!args.id) throw new Error("A member ID is required");
|
|
220
307
|
await resume(args.id); return `Resumed ${args.id}`;
|
|
221
308
|
}
|
|
309
|
+
// Promoting and leaving change authority, so only the user, from a main session's TUI, can do them.
|
|
310
|
+
if (args.action === "promote" || args.action === "leave") {
|
|
311
|
+
if (!isMain(this.store.state.members[actor])) throw new Error("Only a main session can promote members or leave the team");
|
|
312
|
+
const run = args.action === "promote" ? this.promote(args.id ?? "", args.confirmed === true) : Promise.resolve(this.leave(actor));
|
|
313
|
+
this.operations.add(run);
|
|
314
|
+
try { return await run; } finally { this.operations.delete(run); }
|
|
315
|
+
}
|
|
222
316
|
if (!args.text?.trim()) throw new Error("Write a request or feedback before sending");
|
|
223
|
-
|
|
317
|
+
const main = isMain(this.store.state.members[actor]) ? actor : this.self;
|
|
318
|
+
let recipient = main, fallback = "";
|
|
224
319
|
let text = `User task request (review before changing tasks):\n${taskRequestGuidance}\n\nRequest:\n${args.text}`;
|
|
225
320
|
if (args.action === "feedback") {
|
|
226
321
|
const task = this.store.state.tasks[args.id ?? ""];
|
|
227
322
|
if (!task) throw new Error("Unknown task ID");
|
|
228
|
-
recipient = task.creator
|
|
323
|
+
recipient = task.creator && this.store.state.members[task.creator]?.role ? task.creator : main;
|
|
229
324
|
text = `User feedback on task ${taskName(task)} [${task.id}] (${task.title}):\n${args.text}`;
|
|
230
325
|
if (!isLive(this.store.state.members[recipient]) || this.store.state.members[recipient].status === "stopping") {
|
|
231
326
|
try { await resume(recipient); }
|
|
232
|
-
catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to
|
|
327
|
+
catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to ${memberName(this.store.state.members[main])} instead. `; recipient = main; }
|
|
233
328
|
}
|
|
234
329
|
}
|
|
235
330
|
const message = this.store.message(actor, { action: "send", recipients: [recipient], text }, true) as Message;
|
|
236
331
|
this.notify(recipient);
|
|
237
|
-
return `${fallback}Sent to ${recipient
|
|
332
|
+
return `${fallback}Sent to ${isMain(this.store.state.members[recipient]) ? memberName(this.store.state.members[recipient]) : recipient} · message ${message.seq}`;
|
|
238
333
|
}
|
|
239
334
|
private publicMember(member: Member) {
|
|
240
335
|
const { token: _token, identity: _identity, ...view } = member;
|
|
241
336
|
return view;
|
|
242
337
|
}
|
|
243
|
-
|
|
338
|
+
sandboxStatus(): SandboxStatus {
|
|
339
|
+
if (!this.options.credentials) return { available: false, backend: "docker", reason: "This Pi root cannot broker provider credentials" };
|
|
340
|
+
// Cache success; recheck failures so a fixed host is noticed without restarting.
|
|
341
|
+
if (!this.sandbox || (!this.sandbox.status.available && Date.now() - this.sandbox.at > 30000)) {
|
|
342
|
+
let status: SandboxStatus;
|
|
343
|
+
try { status = sandboxStatus(this.image()); } catch (error) { status = { available: false, backend: "docker", reason: String(error) }; }
|
|
344
|
+
this.sandbox = { status, at: Date.now() };
|
|
345
|
+
}
|
|
346
|
+
return this.sandbox.status;
|
|
347
|
+
}
|
|
348
|
+
/** Live members this coordinator runs; main sessions run in their own terminals and never count. */
|
|
349
|
+
live(): Member[] { return Object.values(this.store.state.members).filter(member => !isMain(member) && isLive(member)); }
|
|
244
350
|
ack(id: string, through: number): void {
|
|
245
351
|
const last = this.store.state.messages.at(-1)?.seq ?? 0;
|
|
246
352
|
if (!Number.isSafeInteger(through) || through < 0 || through > last) throw new Error("Invalid notification cursor");
|
|
@@ -256,7 +362,7 @@ export class Coordinator {
|
|
|
256
362
|
const through = pending.at(-1)?.seq;
|
|
257
363
|
if (!through) return;
|
|
258
364
|
try {
|
|
259
|
-
if (id ===
|
|
365
|
+
if (id === this.self) this.options.notice(through, pending.slice(-5));
|
|
260
366
|
else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages: pending.slice(-5) }); }
|
|
261
367
|
} catch (error) { this.options.warning(`Team notification remains in history: ${String(error)}`); }
|
|
262
368
|
}, 50));
|
|
@@ -267,7 +373,11 @@ export class Coordinator {
|
|
|
267
373
|
if (!member || !isLive(member)) throw new Error("Member is not active");
|
|
268
374
|
validate(operation, args);
|
|
269
375
|
if (operation === "event") return this.recordEvent(actor, args);
|
|
270
|
-
if (operation === "ui")
|
|
376
|
+
if (operation === "ui") {
|
|
377
|
+
// User actions can resume any member as Main, so sandboxed members cannot send them.
|
|
378
|
+
if (member.access) throw new Error("Sandboxed members cannot send user team actions; use Main");
|
|
379
|
+
return this.userAction(actor, args as UserAction);
|
|
380
|
+
}
|
|
271
381
|
if (operation === "task") return this.store.task(actor, args as TaskArgs);
|
|
272
382
|
if (operation === "message") {
|
|
273
383
|
const result = this.store.message(actor, args as MessageArgs);
|
|
@@ -278,12 +388,12 @@ export class Coordinator {
|
|
|
278
388
|
}
|
|
279
389
|
private async agent(actor: string, args: AgentArgs, authorizedLimit: boolean): Promise<unknown> {
|
|
280
390
|
const state = this.store.state;
|
|
281
|
-
if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
|
|
391
|
+
if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, sandbox: this.sandboxStatus(), lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
|
|
282
392
|
if (args.action === "limits") {
|
|
283
|
-
if (actor
|
|
393
|
+
if (!isMain(state.members[actor])) throw new Error("Only a main session can change team limits");
|
|
284
394
|
if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
|
|
285
395
|
this.store.update(next => { next.limit = args.limit ?? next.limit; next.depth = args.depth ?? next.depth; });
|
|
286
|
-
return { limit: this.store.state.limit, depth: this.store.state.depth };
|
|
396
|
+
return { limit: this.store.state.limit, depth: this.store.state.depth, sandbox: this.sandboxStatus() };
|
|
287
397
|
}
|
|
288
398
|
if (args.action === "launch" || args.action === "resume") {
|
|
289
399
|
const launch = this.launch(actor, args);
|
|
@@ -293,8 +403,9 @@ export class Coordinator {
|
|
|
293
403
|
const member = args.id ? state.members[args.id] : undefined;
|
|
294
404
|
if (!member) throw new Error("Unknown member ID");
|
|
295
405
|
if (args.action === "status") return this.publicMember(member);
|
|
296
|
-
if (
|
|
406
|
+
if (!canControl(state, actor, member.id)) throw new Error("Only a main session or a member's ancestor can stop or clean it up");
|
|
297
407
|
if (args.action === "cleanup") {
|
|
408
|
+
if (state.members[actor].access) throw new Error("Sandboxed members cannot remove worktrees");
|
|
298
409
|
if (isLive(member)) throw new Error("Stop the member before worktree cleanup");
|
|
299
410
|
if (!member.worktree) throw new Error("Member has no worktree");
|
|
300
411
|
if (this.cleaning.has(member.id)) throw new Error("Member cleanup is already running");
|
|
@@ -308,7 +419,8 @@ export class Coordinator {
|
|
|
308
419
|
this.operations.add(cleanup);
|
|
309
420
|
try { return await cleanup; } finally { this.cleaning.delete(member.id); this.operations.delete(cleanup); }
|
|
310
421
|
}
|
|
311
|
-
await this.
|
|
422
|
+
if (isMain(member)) await this.stopMain(member.id);
|
|
423
|
+
else await this.stopTree(member.id);
|
|
312
424
|
return this.publicMember(this.store.state.members[member.id]);
|
|
313
425
|
}
|
|
314
426
|
private async launch(actor: string, args: AgentArgs, prompt = true): Promise<unknown> {
|
|
@@ -317,7 +429,8 @@ export class Coordinator {
|
|
|
317
429
|
if (!args.brief?.trim()) throw new Error("A launch or resume brief is required");
|
|
318
430
|
if (!args.model || !args.model.includes("/")) throw new Error("A provider/model is required (normally inherited from the requesting member)");
|
|
319
431
|
const resumed = args.action === "resume" ? state.members[args.id ?? ""] : undefined;
|
|
320
|
-
if (args.action === "resume" && (!resumed ||
|
|
432
|
+
if (args.action === "resume" && (!resumed || !resumed.sessionFile || !canControl(state, actor, resumed.id))) throw new Error("Resume requires a stopped member you control with a native session file");
|
|
433
|
+
if (resumed && isMain(resumed)) return this.resumeMain(resumed.id);
|
|
321
434
|
if (resumed && this.cleaning.has(resumed.id)) throw new Error("Worktree cleanup is running; cannot resume this member");
|
|
322
435
|
if (resumed && (isLive(resumed) || (resumed.pid && processAlive(resumed.pid, resumed.identity)))) throw new Error("Cannot resume a live or uncertain member");
|
|
323
436
|
const depth = resumed ? resumed.depth : parent.depth + 1;
|
|
@@ -326,7 +439,22 @@ export class Coordinator {
|
|
|
326
439
|
const id = resumed?.id ?? randomUUID().slice(0, 16);
|
|
327
440
|
const cwd = realpathSync(resolve(args.cwd ?? resumed?.cwd ?? parent.cwd));
|
|
328
441
|
if (!statSync(cwd).isDirectory()) throw new Error("Agent cwd must be a directory");
|
|
329
|
-
|
|
442
|
+
// An access request without a network policy keeps the requester's; only an unsandboxed requester's default is public.
|
|
443
|
+
const access = resumed ? resumed.access : args.access ? resolveAccess({ ...args.access, network: args.access.network ?? parent.access?.network }, cwd) : parent.access;
|
|
444
|
+
assertNarrower(parent.access, access);
|
|
445
|
+
if (access) {
|
|
446
|
+
if (!readable(access, cwd)) throw new Error("The member cwd must be inside its granted directories");
|
|
447
|
+
if (args.worktree) throw new Error("Worktrees cannot be combined with enforced access");
|
|
448
|
+
// Container memory caps must stay within host RAM in total; unsandboxed members are not limited.
|
|
449
|
+
if (this.live().filter(other => other.access && other.id !== resumed?.id).length >= maxSandboxed()) throw new Error(`At most ${maxSandboxed()} enforced members can run at once on this host`);
|
|
450
|
+
const status = this.sandboxStatus();
|
|
451
|
+
if (!status.available) throw new Error(`Enforced access is unavailable: ${status.reason}`);
|
|
452
|
+
}
|
|
453
|
+
// The provider endpoint bypasses the allowlist, so a restricted member keeps its provider and its children inherit it.
|
|
454
|
+
const provider = args.model.slice(0, args.model.indexOf("/"));
|
|
455
|
+
const locked = resumed ? resumed.provider : parent.access?.network.allow ? parent.provider : undefined;
|
|
456
|
+
if (access?.network.allow && locked && provider !== locked) throw new Error(`A member with restricted network access can only use ${locked} models`);
|
|
457
|
+
const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined, handover: undefined } : { id, name: args.name, role: "member", parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
|
|
330
458
|
// Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
|
|
331
459
|
this.store.update(next => { next.members[id] = member; });
|
|
332
460
|
this.publishStatus();
|
|
@@ -344,16 +472,24 @@ export class Coordinator {
|
|
|
344
472
|
if (this.closed || this.store.state.members[id].status !== "starting") throw new Error("Launch was stopped before startup");
|
|
345
473
|
const readiness = new Promise<void>((done, fail) => {
|
|
346
474
|
this.ready.set(id, { resolve: done, reject: fail });
|
|
347
|
-
|
|
475
|
+
// Enforced members start with an empty compile cache, so Pi recompiles TypeScript extensions (about 20 s here).
|
|
476
|
+
timer = setTimeout(() => fail(new Error("ac-teams child readiness timed out")), access ? 90000 : 20000);
|
|
348
477
|
});
|
|
349
478
|
// Attach immediately: a spawn error must not leave a rejected readiness promise unobserved.
|
|
350
479
|
void readiness.catch(() => {});
|
|
351
480
|
const slash = args.model.indexOf("/");
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
481
|
+
const modelId = args.model.slice(slash + 1);
|
|
482
|
+
// Each member gets its own transcript directory, so a sandboxed member cannot read or alter others.
|
|
483
|
+
const sessions = resumed?.sessionFile ? dirname(resumed.sessionFile) : this.memberSessionDir(id);
|
|
484
|
+
mkdirSync(sessions, { recursive: true, mode: 0o700 });
|
|
485
|
+
this.sessionDirs.set(id, sessions);
|
|
486
|
+
const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", sessions, "-e", this.options.extension, "--provider", provider, "--model", modelId, "--thinking", args.thinking ?? "off"];
|
|
355
487
|
if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
|
|
356
|
-
|
|
488
|
+
// ACSTACK_AGENT_ROLE tells ACStack hooks this is not a main session; children inherit it.
|
|
489
|
+
const identity = { HERDR_ENV: "0", ACSTACK_AGENT_ROLE: "member", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_ACCESS: JSON.stringify(access ? { enforced: true, ...access } : { enforced: false }) };
|
|
490
|
+
const spawnOptions = access
|
|
491
|
+
? await this.sandboxed(id, access, member.cwd, provider, modelId, rpcArgs, identity, sessions)
|
|
492
|
+
: { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, ...identity, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
|
|
357
493
|
agent = this.options.interactive
|
|
358
494
|
? new TerminalAgent({ ...spawnOptions, id, dir: this.store.dir, request: (type, fields, timeout) => this.control(id, type, fields, timeout) })
|
|
359
495
|
: new RpcAgent(spawnOptions);
|
|
@@ -371,15 +507,21 @@ export class Coordinator {
|
|
|
371
507
|
});
|
|
372
508
|
agent.once("exit", () => {
|
|
373
509
|
this.agents.delete(id);
|
|
510
|
+
// Tracked so close() waits for container removal.
|
|
511
|
+
const release = this.releaseSandbox(id).catch(error => this.options.warning(String(error)));
|
|
512
|
+
this.operations.add(release); this.releasing.set(id, release);
|
|
513
|
+
void release.finally(() => { this.operations.delete(release); if (this.releasing.get(id) === release) this.releasing.delete(id); });
|
|
374
514
|
if (this.closed) return;
|
|
375
515
|
this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; delete next.members[id].needsInput; });
|
|
376
516
|
this.publishStatus();
|
|
377
|
-
|
|
517
|
+
// A promoted member keeps its descendants.
|
|
518
|
+
if (!this.store.state.members[id].promotion) void this.stopDescendants(id).catch(error => this.options.warning(String(error)));
|
|
378
519
|
});
|
|
379
520
|
if (agent instanceof TerminalAgent) await agent.start();
|
|
380
521
|
member.pid = agent.process.pid;
|
|
381
522
|
member.identity = member.pid ? processIdentity(member.pid) : undefined;
|
|
382
523
|
this.store.update(next => { next.members[id].pid = member.pid; next.members[id].identity = member.identity; });
|
|
524
|
+
if (access) await this.releaseMember(id);
|
|
383
525
|
try {
|
|
384
526
|
await Promise.race([readiness, agent.exited.then(() => { throw new Error("Child exited before readiness"); })]);
|
|
385
527
|
} finally { if (timer) clearTimeout(timer); this.ready.delete(id); }
|
|
@@ -393,11 +535,266 @@ export class Coordinator {
|
|
|
393
535
|
} catch (error) {
|
|
394
536
|
clearTimeout(timer); this.ready.delete(id);
|
|
395
537
|
if (agent) await agent.stop();
|
|
538
|
+
// The exit handler may already own the container removal; wait for it so a failed launch leaves nothing running.
|
|
539
|
+
await this.releaseSandbox(id); await this.releasing.get(id);
|
|
396
540
|
this.store.update(next => { next.members[id].status = "interrupted"; delete next.members[id].needsInput; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
|
|
397
541
|
this.publishStatus();
|
|
398
542
|
throw error;
|
|
399
543
|
}
|
|
400
544
|
}
|
|
545
|
+
/** Adds a main session record; the caller writes the team entry into its transcript. */
|
|
546
|
+
private addMain(state: TeamState, id: string, sessionId: string, sessionFile: string | undefined, cwd: string): void {
|
|
547
|
+
if (Object.values(state.members).some(member => member.sessionId === sessionId)) throw new Error("This Pi session is already in the team");
|
|
548
|
+
const names = new Set(Object.values(state.members).map(member => member.name));
|
|
549
|
+
let number = 1;
|
|
550
|
+
do { number++; } while (names.has(`main-${number}`));
|
|
551
|
+
state.members[id] = { id, name: `main-${number}`, role: "main", depth: 0, token: randomUUID(), cwd, status: "stopped", notified: state.messages.at(-1)?.seq ?? 0, sessionId, sessionFile };
|
|
552
|
+
}
|
|
553
|
+
/** Restores members a previous owner stopped to the state it recorded. */
|
|
554
|
+
private async resumePass(): Promise<void> {
|
|
555
|
+
for (const member of Object.values(this.store.state.members)) {
|
|
556
|
+
const state = member.handover;
|
|
557
|
+
if (!state || isLive(member)) continue;
|
|
558
|
+
try {
|
|
559
|
+
if (!member.model) throw new Error("no recorded model");
|
|
560
|
+
await this.launch(this.self, { action: "resume", id: member.id, brief: state === "busy" ? handoverPrompt : "Resume the native session without replaying work.", model: member.model, thinking: member.thinking }, state === "busy");
|
|
561
|
+
if (state === "input") this.recordEvent(member.id, { type: "blocked", active: true });
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (this.closed) return;
|
|
564
|
+
this.options.warning(`Could not resume ${memberName(member)} after the coordinator handover: ${error instanceof Error ? error.message : String(error)}`);
|
|
565
|
+
if (this.store.state.members[member.id]?.handover) this.store.update(next => { delete next.members[member.id].handover; });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** The top-level session folder: Main's session directory without ac-teams nesting. */
|
|
570
|
+
private topSessionDir(): string {
|
|
571
|
+
return resolve(this.options.sessionDir ?? join(this.store.dir, "sessions")).replace(/\/ac-teams\/[^/]+\/[^/]+$/, "");
|
|
572
|
+
}
|
|
573
|
+
/** Opens a main session's transcript in its own terminal with a clean environment. */
|
|
574
|
+
private async openMain(id: string): Promise<string> {
|
|
575
|
+
const member = this.store.state.members[id];
|
|
576
|
+
const notice = await (this.options.launchMain ?? launchMain)({
|
|
577
|
+
id, name: memberName(member), cwd: member.cwd, socket: join(this.store.dir, `m-${id}.sock`), env: mainEnv(),
|
|
578
|
+
command: [this.options.command ?? process.env.AC_TEAMS_PI ?? "pi", "--session", member.sessionFile!],
|
|
579
|
+
});
|
|
580
|
+
this.store.update(state => { state.members[id].status = "starting"; delete state.members[id].error; });
|
|
581
|
+
return notice;
|
|
582
|
+
}
|
|
583
|
+
private async resumeMain(id: string): Promise<string> {
|
|
584
|
+
const member = this.store.state.members[id];
|
|
585
|
+
if (isLive(member) || this.sockets.has(id)) throw new Error("That main session is already running");
|
|
586
|
+
return this.openMain(id);
|
|
587
|
+
}
|
|
588
|
+
private async stopMain(id: string): Promise<void> {
|
|
589
|
+
if (id === this.self) {
|
|
590
|
+
if (!this.options.shutdown) throw new Error("This main session cannot be stopped remotely");
|
|
591
|
+
this.options.shutdown(); return;
|
|
592
|
+
}
|
|
593
|
+
if (!this.sockets.has(id)) throw new Error("That main session is not running");
|
|
594
|
+
this.store.update(state => { state.members[id].status = "stopping"; });
|
|
595
|
+
await this.control(id, "shutdown");
|
|
596
|
+
}
|
|
597
|
+
/** Stops a member and relaunches its transcript as a main session. Each step is recorded so a failure can roll back. */
|
|
598
|
+
private async promote(id: string, confirmed: boolean): Promise<string> {
|
|
599
|
+
const member = this.store.state.members[id];
|
|
600
|
+
if (!member || member.role !== "member") throw new Error("Only a member can be promoted");
|
|
601
|
+
if (!member.sessionFile) throw new Error("The member has no native session file yet");
|
|
602
|
+
if (member.promotion) throw new Error("The member is already being promoted");
|
|
603
|
+
if (member.access && !confirmed) throw new Error("Promoting a sandboxed member runs it with your full access; confirm before promoting");
|
|
604
|
+
const original = member.sessionFile;
|
|
605
|
+
const step = async (name: NonNullable<Member["promotion"]>["step"], change: (current: Member) => void = () => {}) => {
|
|
606
|
+
this.store.update(state => { const current = state.members[id]; change(current); current.promotion = { ...current.promotion!, step: name }; });
|
|
607
|
+
await this.options.promotionStep?.(name);
|
|
608
|
+
};
|
|
609
|
+
this.store.update(state => { state.members[id].promotion = { step: "stopped", sessionFile: original, parent: member.parent, access: member.access, provider: member.provider }; });
|
|
610
|
+
try {
|
|
611
|
+
await this.stopOne(id);
|
|
612
|
+
await step("stopped");
|
|
613
|
+
const target = join(this.topSessionDir(), basename(original));
|
|
614
|
+
if (existsSync(target)) throw new Error(`A session file already exists at ${target}`);
|
|
615
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
616
|
+
moveFile(original, target);
|
|
617
|
+
await step("moved", current => { current.sessionFile = target; });
|
|
618
|
+
rmSync(memberConfigDir(id), { recursive: true, force: true });
|
|
619
|
+
await step("role", current => {
|
|
620
|
+
current.role = "main"; current.depth = 0; current.token = randomUUID();
|
|
621
|
+
delete current.parent; delete current.access; delete current.provider; delete current.handover;
|
|
622
|
+
});
|
|
623
|
+
this.store.update(recomputeDepths);
|
|
624
|
+
const entry: TeamEntry = { team: this.store.state.id, member: id };
|
|
625
|
+
SessionManager.open(target).appendCustomEntry(teamEntryType, entry);
|
|
626
|
+
await step("entry");
|
|
627
|
+
const notice = await this.openMain(id);
|
|
628
|
+
this.store.update(state => { delete state.members[id].promotion; });
|
|
629
|
+
return `Promoted ${memberName(member)} to a main session. ${notice}`;
|
|
630
|
+
} catch (error) {
|
|
631
|
+
this.rollback(id, error instanceof Error ? error.message : String(error));
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** Returns a partly promoted member to a stopped member with its original transcript path. */
|
|
636
|
+
private rollback(id: string, reason: string): void {
|
|
637
|
+
const promotion = this.store.state.members[id].promotion!;
|
|
638
|
+
const current = this.store.state.members[id].sessionFile;
|
|
639
|
+
try {
|
|
640
|
+
if (current && current !== promotion.sessionFile && existsSync(current)) {
|
|
641
|
+
// The moved transcript may already claim main-session membership; record the departure first.
|
|
642
|
+
if (promotion.step === "entry") SessionManager.open(current).appendCustomEntry(teamEntryType, { team: this.store.state.id, member: id, left: true } satisfies TeamEntry);
|
|
643
|
+
moveFile(current, promotion.sessionFile);
|
|
644
|
+
}
|
|
645
|
+
} catch (error) { this.options.warning(`Promotion rollback could not restore ${promotion.sessionFile}: ${String(error)}`); }
|
|
646
|
+
this.store.update(state => {
|
|
647
|
+
const member = state.members[id];
|
|
648
|
+
Object.assign(member, { role: "member", sessionFile: promotion.sessionFile, parent: promotion.parent, access: promotion.access, provider: promotion.provider, status: "stopped", error: `Promotion failed: ${reason}`.slice(0, 2000) });
|
|
649
|
+
for (const key of ["parent", "access", "provider"] as const) if (member[key] === undefined) delete member[key];
|
|
650
|
+
if (member.parent && !state.members[member.parent]) delete member.parent;
|
|
651
|
+
delete member.promotion;
|
|
652
|
+
recomputeDepths(state);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
/** A main session leaves: its in-progress tasks return to todo and its record keeps history without a role. */
|
|
656
|
+
private leave(actor: string): string {
|
|
657
|
+
const state = this.store.state;
|
|
658
|
+
const others = Object.values(state.members).filter(member => isMain(member) && member.id !== actor);
|
|
659
|
+
if (!others.length && this.live().length) throw new Error("You are the only main session and members are still live; stop them first");
|
|
660
|
+
this.store.update(next => {
|
|
661
|
+
for (const task of Object.values(next.tasks)) if (task.owner === actor && task.status !== "done") { task.status = "todo"; delete task.owner; }
|
|
662
|
+
const member = next.members[actor];
|
|
663
|
+
delete member.role;
|
|
664
|
+
member.status = "stopped";
|
|
665
|
+
});
|
|
666
|
+
return `Left team ${state.id}`;
|
|
667
|
+
}
|
|
668
|
+
private image() {
|
|
669
|
+
return memberImage(this.options.piVersion ?? JSON.parse(readFileSync(join(packageRoot(command("pi")), "package.json"), "utf8")).version);
|
|
670
|
+
}
|
|
671
|
+
/** Transcript root: Main's session directory, without any ac-teams nesting if Main itself resumed a member transcript. */
|
|
672
|
+
private memberSessionDir(id: string): string {
|
|
673
|
+
const root = resolve(this.options.sessionDir ?? join(this.store.dir, "sessions")).replace(/\/ac-teams\/[^/]+\/[^/]+$/, "");
|
|
674
|
+
return join(root, "ac-teams", this.store.state.id, id);
|
|
675
|
+
}
|
|
676
|
+
private containerName(id: string): string { return `ac-teams-${this.store.state.id}-${id}`; }
|
|
677
|
+
/** Prepares a member container whose only outside channels are the coordinator socket and this member's egress proxy. */
|
|
678
|
+
private async sandboxed(id: string, access: Access, cwd: string, provider: string, model: string, args: string[], identity: Record<string, string>, sessionDir: string) {
|
|
679
|
+
const credentials = this.options.credentials, route = providerRoutes[provider];
|
|
680
|
+
if (!credentials || !route) throw new Error(`Enforced access supports only ${Object.keys(providerRoutes).join("/")} models with Main's Pi login`);
|
|
681
|
+
const first = await credentials(provider, model); // Fails closed for unsupported providers before anything starts.
|
|
682
|
+
// A resume reuses the member ID, so the previous run's cleanup must finish before its files are recreated.
|
|
683
|
+
await this.releasing.get(id);
|
|
684
|
+
const home = homedir(), agentDir = resolve(process.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent"));
|
|
685
|
+
const dir = memberConfigDir(id);
|
|
686
|
+
rmSync(dir, { recursive: true, force: true });
|
|
687
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
688
|
+
const read = (name: string) => { try { return JSON.parse(readFileSync(join(agentDir, name), "utf8")) as Record<string, unknown>; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; } };
|
|
689
|
+
const write = (name: string, value: unknown) => writeFileSync(join(dir, name), JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
690
|
+
// WebSocket clients may ignore proxy settings, so pin the SSE transport.
|
|
691
|
+
write("settings.json", { ...read("settings.json"), transport: "sse" });
|
|
692
|
+
// Only the selected provider's current access token: no refresh token and no other providers. The parent refreshes it.
|
|
693
|
+
const auth = (credential: { token: string; expires: number }) => ({ [provider]: { type: "oauth", access: credential.token, refresh: "", expires: credential.expires } });
|
|
694
|
+
write("auth.json", auth(first));
|
|
695
|
+
// Only trust decisions for the member's cwd and its ancestors; the full file lists every trusted project.
|
|
696
|
+
write("trust.json", Object.fromEntries(Object.entries(read("trust.json")).filter(([path]) => cwd === path || cwd.startsWith(path.endsWith("/") ? path : path + "/"))));
|
|
697
|
+
// pi-web-access ignores proxy environment variables but honours its own proxy setting. The container has no DNS,
|
|
698
|
+
// so it must leave address checks to the proxy, which refuses private and host addresses itself.
|
|
699
|
+
const proxy = `http://127.0.0.1:${egressPort}`, search = read("web-search.json");
|
|
700
|
+
write("web-search.json", { ...search, proxy, ssrf: { ...(search.ssrf as object | undefined), trustEnvProxy: true } });
|
|
701
|
+
// The image's Pi runs members; a test command is mounted from its own package instead.
|
|
702
|
+
const custom = this.options.command ?? process.env.AC_TEAMS_PI;
|
|
703
|
+
const pi = custom ? command(custom) : "pi";
|
|
704
|
+
const env: Record<string, string> = { PATH: "/usr/local/bin:/usr/bin:/bin", HOME: home, TMPDIR: "/tmp", PI_CODING_AGENT_DIR: agentDir, PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", ...identity,
|
|
705
|
+
HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy, NO_PROXY: "localhost,127.0.0.1,::1", no_proxy: "localhost,127.0.0.1,::1", NODE_USE_ENV_PROXY: "1",
|
|
706
|
+
// billion-context's spawned proxy reads its own upstream setting rather than the inherited environment.
|
|
707
|
+
BILI_UPSTREAM_PROXY: proxy };
|
|
708
|
+
for (const key of ["USER", "LANG", "LC_ALL", "TERM", "COLORTERM"]) if (process.env[key]) env[key] = process.env[key]!;
|
|
709
|
+
const launch = join(dir, "launch.json");
|
|
710
|
+
// The bridge waits on this before running any member code; it opens only after the mounts are verified.
|
|
711
|
+
let release!: (go: boolean) => void;
|
|
712
|
+
const gate = new Promise<boolean>(resolve => { release = resolve; });
|
|
713
|
+
const egress = await Egress.open(join(this.store.dir, `e-${id}.sock`), access.network, { host: new URL(route.baseUrl).hostname, port: 443 }, gate);
|
|
714
|
+
// Rewrites in place (same inode), because the file is bind-mounted; the member never sees the refresh token.
|
|
715
|
+
const refresh = setInterval(() => void credentials(provider, model).then(next => {
|
|
716
|
+
const current = this.sandboxes.get(id);
|
|
717
|
+
if (current && next.token !== current.token) { writeFileSync(join(dir, "auth.json"), JSON.stringify(auth(next), null, 2) + "\n"); current.token = next.token; }
|
|
718
|
+
}, () => this.options.warning(`Member ${id}: provider credential refresh failed`)), this.options.refreshMs ?? 60000);
|
|
719
|
+
refresh.unref();
|
|
720
|
+
const entry = { egress, grants: [] as { path: string; fd: number; dev: number; ino: number }[], release, refresh, token: first.token };
|
|
721
|
+
this.sandboxes.set(id, entry);
|
|
722
|
+
// Registered first so releaseSandbox closes the proxy if this fails. The coordinator token travels in this private file, never in container configuration that docker inspect shows.
|
|
723
|
+
write("launch.json", { socket: egress.socket, port: egressPort, command: pi, args, env });
|
|
724
|
+
// Hold each grant open so the started container's mounts can be compared with exactly these directories.
|
|
725
|
+
for (const path of [...access.read, ...access.write]) {
|
|
726
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
727
|
+
entry.grants.push({ path, fd, ...(({ dev, ino }) => ({ dev, ino }))(fstatSync(fd)) });
|
|
728
|
+
if (readlinkSync(`/proc/self/fd/${fd}`) !== path) throw new Error(`Grant ${path} changed while opening`);
|
|
729
|
+
}
|
|
730
|
+
const plan: SandboxPlan = {
|
|
731
|
+
name: this.containerName(id), labels: { "ac-teams.team": this.store.state.id, "ac-teams.member": id, "ac-teams.store": this.store.dir },
|
|
732
|
+
image: this.image().tag, access, cwd, home, terminal: this.options.interactive === true, agentDir, configDir: dir, sessionDir,
|
|
733
|
+
coordinatorSocket: this.socketPath, egressSocket: egress.socket, launch,
|
|
734
|
+
runtime: [...new Set([packageRoot(this.options.extension), packageRoot(fileURLToPath(import.meta.url)), ...(custom ? [packageRoot(pi)] : [])])],
|
|
735
|
+
bridge: fileURLToPath(new URL("./sandbox-bridge.mjs", import.meta.url)),
|
|
736
|
+
};
|
|
737
|
+
this.plans.set(id, plan);
|
|
738
|
+
await docker(["rm", "-f", plan.name]).catch(() => {});
|
|
739
|
+
const created = (await docker(dockerCreateArgs(plan))).trim();
|
|
740
|
+
// Register first, then check. Grants are identity-checked at start, but runtime, transcript, and socket mounts are
|
|
741
|
+
// not, so none may sit strictly inside another live member's writable grant, and no writable grant may strictly
|
|
742
|
+
// contain another member's such mounts, across all teams.
|
|
743
|
+
const others = (await docker(["ps", "-aq", "--no-trunc", "--filter", "label=ac-teams.member", ...["created", "running", "paused", "restarting"].flatMap(state => ["--filter", `status=${state}`])])).trim().split("\n").filter(other => other && other !== created);
|
|
744
|
+
const sets = others.length ? (JSON.parse(await docker(["inspect", ...others])) as { Mounts?: { Type: string; Source: string; RW: boolean }[]; Config: { Labels?: Record<string, string> } }[])
|
|
745
|
+
.map(container => mountSet((container.Mounts ?? []).filter(mount => mount.Type === "bind").map(mount => ({ source: mount.Source, rw: mount.RW })), JSON.parse(container.Config.Labels?.["ac-teams.grants"] ?? "[]"))) : [];
|
|
746
|
+
assertStableGrants(mountSet(sandboxMounts(plan).map(mount => ({ source: mount.source, rw: !mount.readonly })), [...access.read, ...access.write]), sets);
|
|
747
|
+
// Admission across every coordinator on this host: count after creating, so concurrent launches cannot both slip under the cap.
|
|
748
|
+
const admitted = (await docker(["ps", "-aq", "--filter", "label=ac-teams.member", "--filter", "status=created", "--filter", "status=running"])).trim().split("\n").filter(Boolean).length;
|
|
749
|
+
if (admitted > maxSandboxed()) throw new Error(`At most ${maxSandboxed()} enforced members can run at once on this host`);
|
|
750
|
+
await this.verifyContainer(id, false);
|
|
751
|
+
await this.options.beforeStart?.(id);
|
|
752
|
+
return { cwd, command: "docker", args: ["start", "--attach", "--interactive", plan.name], env: { ...process.env } };
|
|
753
|
+
}
|
|
754
|
+
/** Refuses to run a container whose effective configuration differs from the policy. */
|
|
755
|
+
private async verifyContainer(id: string, started: boolean): Promise<void> {
|
|
756
|
+
const plan = this.plans.get(id)!;
|
|
757
|
+
const [inspect] = JSON.parse(await docker(["inspect", plan.name]));
|
|
758
|
+
const problems = containerProblems(inspect, plan, started);
|
|
759
|
+
if (problems.length) throw new Error(`Member container failed policy checks: ${problems.join("; ")}`);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* After start, before any member code runs: each grant mounted in the container must be the directory held open
|
|
763
|
+
* before creation. Docker resolves bind sources at start, so this is what pins grants against symlink swaps.
|
|
764
|
+
*/
|
|
765
|
+
private async releaseMember(id: string): Promise<void> {
|
|
766
|
+
const sandbox = this.sandboxes.get(id)!, plan = this.plans.get(id)!;
|
|
767
|
+
try {
|
|
768
|
+
let pid = 0;
|
|
769
|
+
for (const end = Date.now() + 30000; !pid; await new Promise(done => setTimeout(done, 100))) {
|
|
770
|
+
if (Date.now() > end) throw new Error("Member container did not start");
|
|
771
|
+
const [inspect] = JSON.parse(await docker(["inspect", plan.name]));
|
|
772
|
+
if (inspect?.State?.Running) pid = inspect.State.Pid;
|
|
773
|
+
else if (inspect?.State?.Status === "exited") throw new Error("Member container exited before release");
|
|
774
|
+
}
|
|
775
|
+
await this.verifyContainer(id, true);
|
|
776
|
+
for (const grant of sandbox.grants) {
|
|
777
|
+
const mounted = lstatSync(`/proc/${pid}/root${grant.path}`);
|
|
778
|
+
if (mounted.dev !== grant.dev || mounted.ino !== grant.ino) throw new Error(`Grant ${grant.path} is not the directory that was checked`);
|
|
779
|
+
}
|
|
780
|
+
sandbox.release(true);
|
|
781
|
+
} catch (error) { sandbox.release(false); throw error; }
|
|
782
|
+
finally { for (const grant of sandbox.grants) closeSync(grant.fd); sandbox.grants = []; }
|
|
783
|
+
}
|
|
784
|
+
private async releaseSandbox(id: string): Promise<void> {
|
|
785
|
+
const sandbox = this.sandboxes.get(id), plan = this.plans.get(id);
|
|
786
|
+
this.sandboxes.delete(id); this.plans.delete(id);
|
|
787
|
+
if (sandbox) {
|
|
788
|
+
sandbox.release(false); clearInterval(sandbox.refresh);
|
|
789
|
+
// releaseMember may still hold this entry; emptying the list keeps it from closing reused descriptors.
|
|
790
|
+
for (const grant of sandbox.grants) closeSync(grant.fd);
|
|
791
|
+
sandbox.grants = [];
|
|
792
|
+
}
|
|
793
|
+
if (plan) await docker(["rm", "-f", plan.name]).catch(error => this.options.warning(`Cannot remove member container ${plan.name}: ${String(error)}`));
|
|
794
|
+
await sandbox?.egress.close();
|
|
795
|
+
// The member's files hold its coordinator token and provider access token.
|
|
796
|
+
rmSync(memberConfigDir(id), { recursive: true, force: true });
|
|
797
|
+
}
|
|
401
798
|
private async stopDescendants(id: string): Promise<void> {
|
|
402
799
|
const descendants = this.live().filter(member => isDescendant(this.store.state, member.id, id)).sort((a, b) => b.depth - a.depth);
|
|
403
800
|
for (const member of descendants) await this.stopOne(member.id);
|
|
@@ -408,7 +805,8 @@ export class Coordinator {
|
|
|
408
805
|
this.store.update(next => { next.members[id].status = "stopping"; delete next.members[id].needsInput; });
|
|
409
806
|
this.publishStatus();
|
|
410
807
|
const agent = this.agents.get(id);
|
|
411
|
-
|
|
808
|
+
// A stopped member's container and credential files are gone before stop returns.
|
|
809
|
+
if (agent) { await agent.stop(); await this.releasing.get(id); }
|
|
412
810
|
else if (member.pid && processAlive(member.pid, member.identity)) throw new Error("No owned transport for live process; refusing to kill by PID");
|
|
413
811
|
this.store.update(next => { next.members[id].status = "stopped"; });
|
|
414
812
|
}
|
|
@@ -417,12 +815,20 @@ export class Coordinator {
|
|
|
417
815
|
private async closeOnce(): Promise<void> {
|
|
418
816
|
this.closed = true;
|
|
419
817
|
for (const timer of this.notices.values()) clearTimeout(timer);
|
|
818
|
+
// The next owner's resume pass restores each member to the state recorded here.
|
|
819
|
+
this.store.update(state => {
|
|
820
|
+
for (const member of Object.values(state.members)) if (!isMain(member) && isLive(member) && member.status !== "stopping") {
|
|
821
|
+
member.handover = member.needsInput ? "input" : member.status === "idle" ? "idle" : "busy";
|
|
822
|
+
}
|
|
823
|
+
if (state.members[this.self]?.status !== undefined && isLive(state.members[this.self])) state.members[this.self].status = "stopped";
|
|
824
|
+
});
|
|
420
825
|
for (const member of this.live().sort((a, b) => b.depth - a.depth)) await this.stopOne(member.id);
|
|
421
826
|
// Finish recording worktree operations already in flight before the store closes.
|
|
422
827
|
await Promise.allSettled([...this.operations]);
|
|
423
828
|
for (const socket of this.connections) socket.destroy();
|
|
424
829
|
if (this.server) await new Promise<void>(done => this.server!.close(() => done()));
|
|
425
830
|
try { unlinkSync(this.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
831
|
+
for (const id of [...this.sandboxes.keys()]) await this.releaseSandbox(id);
|
|
426
832
|
for (const pending of this.controls.values()) { clearTimeout(pending.timer); pending.reject(new Error("Team stopped")); }
|
|
427
833
|
this.controls.clear();
|
|
428
834
|
if (this.options.interactive) {
|
|
@@ -433,3 +839,31 @@ export class Coordinator {
|
|
|
433
839
|
this.store.close();
|
|
434
840
|
}
|
|
435
841
|
}
|
|
842
|
+
|
|
843
|
+
const memberConfigDir = (id: string) => join(stateRoot(), "agents", id);
|
|
844
|
+
|
|
845
|
+
function moveFile(from: string, to: string): void {
|
|
846
|
+
try { renameSync(from, to); }
|
|
847
|
+
catch (error) {
|
|
848
|
+
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
|
|
849
|
+
copyFileSync(from, to, constants.COPYFILE_EXCL); unlinkSync(from);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/** Depth follows ancestry, so a promotion moves the promoted member's descendants up. */
|
|
853
|
+
function recomputeDepths(state: TeamState): void {
|
|
854
|
+
const depth = (member: Member, seen = 0): number => member.parent && state.members[member.parent] && seen < 64 ? depth(state.members[member.parent], seen + 1) + 1 : 0;
|
|
855
|
+
for (const member of Object.values(state.members)) member.depth = depth(member);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function command(name: string): string {
|
|
859
|
+
if (name.includes("/")) return realpathSync(name);
|
|
860
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
861
|
+
if (dir && existsSync(join(dir, name))) return realpathSync(join(dir, name));
|
|
862
|
+
}
|
|
863
|
+
throw new Error(`Cannot find ${name} on PATH`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const execFileAsync = promisify(execFile);
|
|
867
|
+
async function docker(args: string[]): Promise<string> {
|
|
868
|
+
return (await execFileAsync("docker", args, { timeout: 30000, maxBuffer: 4 * 1024 * 1024 })).stdout;
|
|
869
|
+
}
|