@ours.network/fleet 0.11.0 → 0.12.0

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.
@@ -0,0 +1,55 @@
1
+ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import type { OwnerChannelConfig } from '../config.js';
3
+ import type { SessionHandle } from '../session/types.js';
4
+ import { type OursToolClient } from './mcp.js';
5
+ export interface OwnerChannelOptions {
6
+ role: string;
7
+ config: OwnerChannelConfig;
8
+ session: SessionHandle;
9
+ stateDir: string;
10
+ env?: Record<string, string>;
11
+ command?: string;
12
+ log(line: string): void;
13
+ client?: OursToolClient;
14
+ /** Test seam; production uses `ours-mcp watch <identity>`. */
15
+ watch?: (identity: string) => ChildProcessWithoutNullStreams;
16
+ }
17
+ export interface OwnerChannelHandle {
18
+ start(): Promise<void>;
19
+ drain(): Promise<void>;
20
+ close(): Promise<void>;
21
+ }
22
+ /**
23
+ * Fleet-owned trusted ingress. The agent never binds this identity and never
24
+ * chooses its reply recipient; both are fixed from authenticated message data.
25
+ */
26
+ export declare class OwnerChannel implements OwnerChannelHandle {
27
+ private readonly options;
28
+ private readonly client;
29
+ private readonly state;
30
+ private stopping;
31
+ private watchProcess?;
32
+ private watchTask?;
33
+ private drainTask?;
34
+ private drainRequested;
35
+ private readonly inFlight;
36
+ private readonly completionTasks;
37
+ constructor(options: OwnerChannelOptions);
38
+ start(): Promise<void>;
39
+ drain(): Promise<void>;
40
+ close(): Promise<void>;
41
+ private drainAll;
42
+ private handle;
43
+ private complete;
44
+ private ownerPrompt;
45
+ private outboxDir;
46
+ private send;
47
+ private sendAttachments;
48
+ /** Bound message size without splitting Unicode code points. */
49
+ private sendFinal;
50
+ private wireId;
51
+ private sender;
52
+ private watchLoop;
53
+ private errorText;
54
+ private logError;
55
+ }
@@ -0,0 +1,295 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdir, readdir, rm } from 'node:fs/promises';
4
+ import { createInterface } from 'node:readline';
5
+ import { join } from 'node:path';
6
+ import { OursMcpClient } from './mcp.js';
7
+ import { OwnerChannelState } from './state.js';
8
+ /**
9
+ * Fleet-owned trusted ingress. The agent never binds this identity and never
10
+ * chooses its reply recipient; both are fixed from authenticated message data.
11
+ */
12
+ export class OwnerChannel {
13
+ options;
14
+ client;
15
+ state;
16
+ stopping = false;
17
+ watchProcess;
18
+ watchTask;
19
+ drainTask;
20
+ drainRequested = false;
21
+ inFlight = new Set();
22
+ completionTasks = new Set();
23
+ constructor(options) {
24
+ this.options = options;
25
+ this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
26
+ this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
27
+ }
28
+ async start() {
29
+ this.stopping = false;
30
+ await this.client.start();
31
+ await this.client.callTool('choose_identity', { name: this.options.config.identity });
32
+ this.watchTask = this.watchLoop();
33
+ // Do not make role startup wait for an old owner request to finish a turn.
34
+ void this.drain().catch(error => this.logError('initial drain failed', error));
35
+ }
36
+ drain() {
37
+ this.drainRequested = true;
38
+ if (this.drainTask)
39
+ return this.drainTask;
40
+ this.drainTask = (async () => {
41
+ while (this.drainRequested && !this.stopping) {
42
+ this.drainRequested = false;
43
+ await this.drainAll();
44
+ }
45
+ })().finally(() => { this.drainTask = undefined; });
46
+ return this.drainTask;
47
+ }
48
+ async close() {
49
+ this.stopping = true;
50
+ const watch = this.watchProcess;
51
+ this.watchProcess = undefined;
52
+ if (watch && watch.exitCode === null)
53
+ watch.kill('SIGTERM');
54
+ await this.client.close();
55
+ }
56
+ async drainAll() {
57
+ // A finite cap protects the supervisor if a broken daemon repeats unread
58
+ // messages forever. A watch notification will resume draining later.
59
+ for (let pass = 0; pass < 100 && !this.stopping; pass++) {
60
+ const raw = await this.client.callTool('get_messages');
61
+ const messages = Array.isArray(raw?.messages)
62
+ ? raw.messages.filter(message => message && typeof message === 'object')
63
+ : [];
64
+ if (!messages.length)
65
+ return;
66
+ // get_messages marks the batch processed. Requeue allowed, unhandled
67
+ // inputs before executing them so a mid-turn process crash can replay.
68
+ const deferred = messages.filter(message => {
69
+ const wireId = this.wireId(message);
70
+ return wireId && !this.state.has(wireId)
71
+ && this.options.config.owners.includes(this.sender(message).id)
72
+ && Number.isInteger(message.msg_id);
73
+ }).map(message => message.msg_id);
74
+ if (deferred.length)
75
+ await this.client.callTool('defer_messages', { msg_ids: deferred });
76
+ let advanced = false;
77
+ for (const message of messages)
78
+ advanced = await this.handle(message) || advanced;
79
+ // Deferred in-flight messages are intentionally visible again until
80
+ // their correlated response is delivered. Do not spin on those replay
81
+ // copies; a new watch event or completion-triggered drain will resume.
82
+ if (!advanced)
83
+ return;
84
+ }
85
+ this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
86
+ }
87
+ async handle(message) {
88
+ const wireId = this.wireId(message);
89
+ if (!wireId || this.state.has(wireId) || this.inFlight.has(wireId))
90
+ return false;
91
+ const sender = this.sender(message);
92
+ if (!this.options.config.owners.includes(sender.id)) {
93
+ // Do not answer an unauthorized sender and thereby disclose that this is
94
+ // a privileged control address. Authenticated CID, never display name or
95
+ // message wording, is the authority boundary.
96
+ this.options.log(`[${this.options.role}] owner channel ignored unauthorized sender ${sender.id || '<unknown>'}`);
97
+ this.state.remember(wireId);
98
+ return true;
99
+ }
100
+ const text = String(message.text ?? '').trim();
101
+ if (text.toLowerCase() === '/status') {
102
+ const snapshot = this.options.session.snapshot();
103
+ await this.send(sender.id, `[fleet] ${this.options.role}: ${snapshot.readiness}; `
104
+ + `${snapshot.alive ? 'session alive' : 'session offline'}.`, wireId);
105
+ this.state.remember(wireId);
106
+ return true;
107
+ }
108
+ if (text.toLowerCase() === '/interrupt') {
109
+ await this.options.session.interrupt();
110
+ await this.send(sender.id, `[fleet] Interrupted ${this.options.role}'s active turn.`, wireId);
111
+ this.state.remember(wireId);
112
+ return true;
113
+ }
114
+ const outbox = this.outboxDir(wireId);
115
+ await mkdir(outbox, { recursive: true, mode: 0o700 });
116
+ let queued;
117
+ try {
118
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
119
+ interrupt: this.options.config.interrupt,
120
+ });
121
+ }
122
+ catch (error) {
123
+ await rm(outbox, { recursive: true, force: true });
124
+ await this.send(sender.id, `[fleet] Could not deliver this request: ${this.errorText(error)}.`, wireId);
125
+ this.state.remember(wireId);
126
+ return true;
127
+ }
128
+ const accepted = this.options.config.interrupt
129
+ ? "ℹ️ Message received. The agent's previous task was interrupted to prioritize "
130
+ + 'this request, and it is now working on a response. '
131
+ + 'The response will arrive in this channel when ready.'
132
+ : queued.queuedBehind > 0
133
+ ? `ℹ️ Message received. The agent is finishing ${queued.queuedBehind} earlier `
134
+ + 'request(s) first; this request will start as soon as they complete. '
135
+ + 'The response will arrive in this channel when ready.'
136
+ : 'ℹ️ Message received. The agent has started working on this request now. '
137
+ + 'The response will arrive in this channel when ready.';
138
+ this.inFlight.add(wireId);
139
+ const task = this.complete(sender.id, wireId, outbox, accepted, queued.completion)
140
+ .catch(error => this.logError(`request ${wireId} completion failed`, error))
141
+ .finally(() => {
142
+ this.inFlight.delete(wireId);
143
+ this.completionTasks.delete(task);
144
+ if (!this.stopping)
145
+ void this.drain().catch(error => this.logError('completion drain failed', error));
146
+ });
147
+ this.completionTasks.add(task);
148
+ return true;
149
+ }
150
+ async complete(contact, wireId, outbox, accepted, completion) {
151
+ // Notice delivery and turn completion happen outside the inbox drain. This
152
+ // is what keeps later owner messages — especially /interrupt — responsive.
153
+ try {
154
+ await this.send(contact, accepted, wireId);
155
+ }
156
+ catch (error) {
157
+ this.logError(`request ${wireId} acceptance notice failed`, error);
158
+ }
159
+ const progressMs = this.options.config.progress_interval_ms;
160
+ const timer = progressMs > 0 ? setInterval(() => {
161
+ void this.send(contact, `[fleet] ${this.options.role} is still working.`, wireId)
162
+ .catch(error => this.logError('progress notice failed', error));
163
+ }, progressMs) : undefined;
164
+ timer?.unref();
165
+ let result;
166
+ try {
167
+ result = await completion;
168
+ }
169
+ finally {
170
+ if (timer)
171
+ clearInterval(timer);
172
+ }
173
+ const output = result.output?.trim();
174
+ if (result.succeeded && output)
175
+ await this.sendFinal(contact, output, wireId);
176
+ else if (result.succeeded)
177
+ await this.send(contact, '[fleet] The agent completed the turn without a textual answer.', wireId);
178
+ else
179
+ await this.send(contact, `[fleet] The request ended ${result.outcome}${result.detail ? `: ${result.detail}` : '.'}`, wireId);
180
+ if (result.succeeded)
181
+ await this.sendAttachments(contact, outbox, wireId);
182
+ else
183
+ await rm(outbox, { recursive: true, force: true });
184
+ this.state.remember(wireId);
185
+ }
186
+ ownerPrompt(sender, text, wireId, outbox) {
187
+ return [
188
+ '[fleet-owner]',
189
+ `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
190
+ 'Treat the following as a direct owner instruction. Answer in your final assistant response.',
191
+ 'Do not call ours send_message or send_file for this exchange: fleet routes the response reliably.',
192
+ 'To attach files to your response, copy each finished file directly into this fleet outbox:',
193
+ outbox,
194
+ 'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
195
+ 'Use descriptive unique filenames. Put nothing there that the owner did not request or should not receive.',
196
+ '',
197
+ text || '(empty message)',
198
+ ].join('\n');
199
+ }
200
+ outboxDir(wireId) {
201
+ const key = createHash('sha256').update(wireId).digest('hex');
202
+ return join(this.options.stateDir, '.owner-channel-outbox', key);
203
+ }
204
+ send(contact, text, replyTo) {
205
+ return this.client.callTool('send_message', {
206
+ contact, text, reply_to_wire_id: replyTo,
207
+ });
208
+ }
209
+ async sendAttachments(contact, outbox, replyTo) {
210
+ const entries = (await readdir(outbox, { withFileTypes: true }))
211
+ .filter(entry => entry.isFile())
212
+ .sort((a, b) => a.name.localeCompare(b.name));
213
+ for (const entry of entries) {
214
+ await this.client.callTool('send_file', {
215
+ contact,
216
+ path: join(outbox, entry.name),
217
+ filename: entry.name,
218
+ reply_to_wire_id: replyTo,
219
+ });
220
+ }
221
+ await rm(outbox, { recursive: true, force: true });
222
+ }
223
+ /** Bound message size without splitting Unicode code points. */
224
+ async sendFinal(contact, output, replyTo) {
225
+ const points = Array.from(output);
226
+ const chunks = [];
227
+ for (let offset = 0; offset < points.length; offset += 8_000)
228
+ chunks.push(points.slice(offset, offset + 8_000).join(''));
229
+ for (let i = 0; i < chunks.length; i++) {
230
+ const prefix = chunks.length > 1 ? `[${i + 1}/${chunks.length}] ` : '';
231
+ await this.send(contact, prefix + chunks[i], replyTo);
232
+ }
233
+ }
234
+ wireId(message) {
235
+ return String(message.wire_id ?? message.msg_id ?? '');
236
+ }
237
+ sender(message) {
238
+ const source = message.from ?? message.sender;
239
+ if (typeof source === 'string')
240
+ return { id: source, name: source };
241
+ const id = String(source?.id ?? message.sender_id ?? '');
242
+ return { id, name: String(source?.name ?? message.sender_name ?? id) };
243
+ }
244
+ async watchLoop() {
245
+ let delayMs = 1_000;
246
+ while (!this.stopping) {
247
+ try {
248
+ const child = this.options.watch?.(this.options.config.identity) ?? spawn(this.options.command ?? 'ours-mcp', ['watch', this.options.config.identity], {
249
+ env: { ...process.env, ...(this.options.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'],
250
+ });
251
+ this.watchProcess = child;
252
+ await new Promise((resolve, reject) => {
253
+ if (child.pid) {
254
+ resolve();
255
+ return;
256
+ }
257
+ child.once('spawn', resolve);
258
+ child.once('error', reject);
259
+ });
260
+ createInterface({ input: child.stderr }).on('line', line => this.options.log(`[${this.options.role}] owner watch: ${line}`));
261
+ delayMs = 1_000;
262
+ // Drain at every (re)attachment, not only after a future notification:
263
+ // a failed send/turn leaves the input deferred and may not emit another
264
+ // watch line by itself.
265
+ await this.drain();
266
+ for await (const _line of createInterface({ input: child.stdout })) {
267
+ if (this.stopping)
268
+ break;
269
+ await this.drain();
270
+ }
271
+ if (!this.stopping)
272
+ throw new Error('watch exited');
273
+ }
274
+ catch (error) {
275
+ if (!this.stopping) {
276
+ this.logError('watch failed; retrying', error);
277
+ await new Promise(resolve => setTimeout(resolve, delayMs));
278
+ delayMs = Math.min(delayMs * 2, 30_000);
279
+ }
280
+ }
281
+ finally {
282
+ const child = this.watchProcess;
283
+ this.watchProcess = undefined;
284
+ if (child && child.exitCode === null)
285
+ child.kill('SIGTERM');
286
+ }
287
+ }
288
+ }
289
+ errorText(error) {
290
+ return error?.message ?? String(error);
291
+ }
292
+ logError(context, error) {
293
+ this.options.log(`[${this.options.role}] owner channel ${context}: ${this.errorText(error)}`);
294
+ }
295
+ }
@@ -0,0 +1,24 @@
1
+ export declare class OursMcpError extends Error {
2
+ }
3
+ export interface OursToolClient {
4
+ start(): Promise<void>;
5
+ callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
6
+ close(): Promise<void>;
7
+ }
8
+ /** Minimal MCP stdio client. One instance owns exactly one ours identity binding. */
9
+ export declare class OursMcpClient implements OursToolClient {
10
+ private readonly command;
11
+ private readonly env;
12
+ private readonly log;
13
+ private child?;
14
+ private nextId;
15
+ private tail;
16
+ constructor(command?: string, env?: Record<string, string>, log?: (line: string) => void);
17
+ start(): Promise<void>;
18
+ callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
19
+ close(): Promise<void>;
20
+ private request;
21
+ private requestNow;
22
+ private notify;
23
+ private write;
24
+ }
@@ -0,0 +1,123 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createInterface } from 'node:readline';
4
+ export class OursMcpError extends Error {
5
+ }
6
+ /** Minimal MCP stdio client. One instance owns exactly one ours identity binding. */
7
+ export class OursMcpClient {
8
+ command;
9
+ env;
10
+ log;
11
+ child;
12
+ nextId = 0;
13
+ tail = Promise.resolve();
14
+ constructor(command = 'ours-mcp', env = {}, log = () => undefined) {
15
+ this.command = command;
16
+ this.env = env;
17
+ this.log = log;
18
+ }
19
+ async start() {
20
+ if (this.child && this.child.exitCode === null)
21
+ return;
22
+ const child = spawn(this.command, ['proxy'], {
23
+ env: {
24
+ ...process.env,
25
+ ...this.env,
26
+ // Bindings are keyed by this value. Sharing it would silently rebind a
27
+ // role's normal mailbox or another owner channel.
28
+ CLAUDE_CODE_SESSION_ID: `ours-fleet-owner-${process.pid}-${randomUUID()}`,
29
+ },
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ });
32
+ await new Promise((resolve, reject) => {
33
+ child.once('spawn', resolve);
34
+ child.once('error', reject);
35
+ });
36
+ this.child = child;
37
+ child.stdin.on('error', error => this.log(`ours-mcp stdin: ${error.message}`));
38
+ createInterface({ input: child.stderr }).on('line', line => this.log(`ours-mcp: ${line}`));
39
+ try {
40
+ await this.request('initialize', {
41
+ protocolVersion: '2025-03-26', capabilities: {},
42
+ clientInfo: { name: 'ours-fleet-owner-channel', version: '1' },
43
+ });
44
+ await this.notify('notifications/initialized', {});
45
+ }
46
+ catch (error) {
47
+ await this.close();
48
+ throw error;
49
+ }
50
+ }
51
+ async callTool(name, args = {}) {
52
+ const result = await this.request('tools/call', { name, arguments: args });
53
+ const text = (result.content ?? [])
54
+ .filter(item => item.type === 'text').map(item => item.text ?? '').join('\n').trim();
55
+ if (result.isError)
56
+ throw new OursMcpError(text || `ours tool ${name} failed`);
57
+ if (result.structuredContent !== undefined)
58
+ return result.structuredContent;
59
+ if (!text)
60
+ return {};
61
+ try {
62
+ return JSON.parse(text);
63
+ }
64
+ catch {
65
+ return text;
66
+ }
67
+ }
68
+ async close() {
69
+ const child = this.child;
70
+ this.child = undefined;
71
+ if (!child || child.exitCode !== null)
72
+ return;
73
+ child.kill('SIGTERM');
74
+ await new Promise(resolve => {
75
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 5_000);
76
+ child.once('exit', () => { clearTimeout(timer); resolve(); });
77
+ });
78
+ }
79
+ request(method, params) {
80
+ const run = this.tail.then(() => this.requestNow(method, params));
81
+ this.tail = run.then(() => undefined, () => undefined);
82
+ return run;
83
+ }
84
+ async requestNow(method, params) {
85
+ const child = this.child;
86
+ if (!child || child.exitCode !== null)
87
+ throw new OursMcpError('ours-mcp proxy is not running');
88
+ const id = ++this.nextId;
89
+ await this.write(child, { jsonrpc: '2.0', id, method, params });
90
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
91
+ try {
92
+ for await (const line of lines) {
93
+ let response;
94
+ try {
95
+ response = JSON.parse(line);
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (response.id !== id)
101
+ continue;
102
+ if (response.error !== undefined)
103
+ throw new OursMcpError(JSON.stringify(response.error));
104
+ return response.result ?? {};
105
+ }
106
+ throw new OursMcpError('ours-mcp proxy closed its output');
107
+ }
108
+ finally {
109
+ lines.close();
110
+ }
111
+ }
112
+ async notify(method, params) {
113
+ const child = this.child;
114
+ if (!child || child.exitCode !== null)
115
+ throw new OursMcpError('ours-mcp proxy is not running');
116
+ await this.write(child, { jsonrpc: '2.0', method, params });
117
+ }
118
+ write(child, value) {
119
+ return new Promise((resolve, reject) => {
120
+ child.stdin.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve());
121
+ });
122
+ }
123
+ }
@@ -0,0 +1,10 @@
1
+ /** Durable bounded dedupe containing wire IDs only — never message or reply plaintext. */
2
+ export declare class OwnerChannelState {
3
+ private readonly path;
4
+ private readonly limit;
5
+ private handled;
6
+ private readonly seen;
7
+ constructor(path: string, limit?: number);
8
+ has(wireId: string): boolean;
9
+ remember(wireId: string): void;
10
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ /** Durable bounded dedupe containing wire IDs only — never message or reply plaintext. */
4
+ export class OwnerChannelState {
5
+ path;
6
+ limit;
7
+ handled = [];
8
+ seen = new Set();
9
+ constructor(path, limit = 5_000) {
10
+ this.path = path;
11
+ this.limit = limit;
12
+ try {
13
+ if (!existsSync(path))
14
+ return;
15
+ const state = JSON.parse(readFileSync(path, 'utf8'));
16
+ if (state.version !== 1 || !Array.isArray(state.handled))
17
+ return;
18
+ this.handled = state.handled.filter(id => typeof id === 'string').slice(-limit);
19
+ for (const id of this.handled)
20
+ this.seen.add(id);
21
+ }
22
+ catch { /* a corrupt cache safely degrades to at-least-once delivery */ }
23
+ }
24
+ has(wireId) { return this.seen.has(wireId); }
25
+ remember(wireId) {
26
+ if (this.seen.has(wireId))
27
+ return;
28
+ this.handled.push(wireId);
29
+ this.seen.add(wireId);
30
+ while (this.handled.length > this.limit)
31
+ this.seen.delete(this.handled.shift());
32
+ mkdirSync(dirname(this.path), { recursive: true });
33
+ const tmp = `${this.path}.tmp-${process.pid}`;
34
+ writeFileSync(tmp, JSON.stringify({ version: 1, handled: this.handled }) + '\n', { mode: 0o600 });
35
+ renameSync(tmp, this.path);
36
+ }
37
+ }
@@ -26,6 +26,7 @@ export function resolvedPlan(cfg) {
26
26
  harness: w.harness, session: w.session, model: w.model ?? null,
27
27
  identity: w.identity, timeoutMs: w.timeoutMs, keepReports: w.keepReports,
28
28
  alertCooldownMs: w.alertCooldownMs, promptFile: w.promptFile ?? null,
29
+ isolation: w.isolation ?? null,
29
30
  })),
30
31
  };
31
32
  }
@@ -59,6 +60,7 @@ export function resolvedRolePlan(role) {
59
60
  ],
60
61
  },
61
62
  monitor: role.monitor,
63
+ ownerChannel: role.owner_channel ?? null,
62
64
  isolation: role.isolation ?? null,
63
65
  worklog: role.worklog ?? null,
64
66
  authProxy: role.auth_proxy ?? null,
package/dist/runner.d.ts CHANGED
@@ -4,6 +4,7 @@ import { Tmux } from './tmux.js';
4
4
  import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
5
5
  import { type Exec } from './exec.js';
6
6
  import type { ExitRecord } from './session/types.js';
7
+ import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
7
8
  export interface RunnerDeps {
8
9
  tmux: Tmux;
9
10
  exec: Exec;
@@ -16,6 +17,8 @@ export interface RunnerDeps {
16
17
  fetch: FetchLike;
17
18
  /** Construct the supervisor mail monitor (injectable so tests stub it out). */
18
19
  createMonitor(opts: MonitorOpts): MonitorHandle;
20
+ /** Construct trusted owner ingress (injectable for lifecycle tests). */
21
+ createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
19
22
  /** Lets a test (or a shutdown path) end the supervised restart loop. */
20
23
  shouldStop?(): boolean;
21
24
  }
package/dist/runner.js CHANGED
@@ -11,12 +11,14 @@ import { realExec, shq } from './exec.js';
11
11
  import { resolveIsolation } from './isolation/policy.js';
12
12
  import { selectIsolationBackend } from './isolation/registry.js';
13
13
  import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
14
+ import { resolveLaunchRuntime } from './isolation/runtime.js';
14
15
  import { AcpSession } from './session/acp.js';
15
16
  import { RoleControlServer } from './session/control.js';
16
17
  import { TmuxSession } from './session/tmux.js';
17
18
  import { classifyShellStatus } from './session/types.js';
18
19
  import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
19
20
  import { rotateWorklog } from './worklog.js';
21
+ import { OwnerChannel } from './owner-channel/channel.js';
20
22
  const defaultDeps = () => ({
21
23
  tmux: new Tmux(),
22
24
  exec: realExec,
@@ -33,6 +35,7 @@ const defaultDeps = () => ({
33
35
  log: line => process.stderr.write(line + '\n'),
34
36
  fetch: (url, init) => globalThis.fetch(url, init),
35
37
  createMonitor: opts => createMonitor(opts),
38
+ createOwnerChannel: opts => new OwnerChannel(opts),
36
39
  });
37
40
  const MONITOR_OWNER_FILE = '.monitor-owner';
38
41
  /**
@@ -319,7 +322,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
319
322
  const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
320
323
  const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
321
324
  const sessionBackend = role.session ?? 'tmux';
322
- const launch = sessionBackend === 'acp'
325
+ let launch = sessionBackend === 'acp'
323
326
  ? (() => {
324
327
  if (!adapter.buildAcpLaunch)
325
328
  throw new Error(`harness '${role.harness}' does not support the ACP session backend`);
@@ -330,10 +333,15 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
330
333
  // env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
331
334
  let wrappedArgv = launch.argv;
332
335
  if (role.isolation) {
333
- // The SAME context config validation and doctor judged (5.2): a policy
334
- // checked against a different mount set than the one that launches is not a
335
- // check at all.
336
- const ctx = { ...isolationContextFor(role), stateDir: dir, runCwd };
336
+ // Start with the SAME durable context config validation and doctor judged
337
+ // (5.2), then add the selected launch's exact runtime closure. Those paths
338
+ // still pass through resolveIsolation's canonical blocklist enforcement.
339
+ const runtime = resolveLaunchRuntime(launch.argv);
340
+ launch = { ...launch, argv: runtime.argv };
341
+ const ctx = {
342
+ ...isolationContextFor(role), stateDir: dir, runCwd,
343
+ runtimeReadPaths: runtime.readPaths,
344
+ };
337
345
  const policy = resolveIsolation(role.isolation, ctx);
338
346
  const sel = await selectIsolationBackend(policy, deps.exec); // throws on strict + unavailable
339
347
  const degradedMarker = join(dir, '.isolation-degraded');
@@ -409,6 +417,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
409
417
  let unsubscribeRecovery;
410
418
  let monitorLoop;
411
419
  let acpStartupComplete = false;
420
+ let ownerChannel;
412
421
  if (sessionBackend === 'acp') {
413
422
  const perms = role.permissions ?? resolvePermissions(undefined, undefined);
414
423
  // Say once, at startup, that this role will decide permission requests by
@@ -494,6 +503,29 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
494
503
  `${started.detail ? `: ${started.detail}` : ''}`);
495
504
  }
496
505
  acpStartupComplete = true;
506
+ if (role.owner_channel) {
507
+ ownerChannel = deps.createOwnerChannel({
508
+ role: name,
509
+ config: role.owner_channel,
510
+ session: acpSession,
511
+ stateDir: dir,
512
+ env: role.env,
513
+ log: deps.log,
514
+ });
515
+ try {
516
+ await ownerChannel.start();
517
+ }
518
+ catch (error) {
519
+ monitor?.stop();
520
+ if (monitorLoop)
521
+ await monitorLoop;
522
+ await control.close();
523
+ await acpSession.close();
524
+ unsubscribeRecovery?.();
525
+ throw new Error(`[${name}] owner channel failed to start: `
526
+ + `${error?.message ?? String(error)}`);
527
+ }
528
+ }
497
529
  }
498
530
  else {
499
531
  await deps.tmux.kill(name);
@@ -516,6 +548,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
516
548
  const start = deps.now();
517
549
  while (sessionHandle.isAlive())
518
550
  await deps.sleep(2000);
551
+ if (ownerChannel)
552
+ await ownerChannel.close();
519
553
  if (monitor) {
520
554
  monitor.stop();
521
555
  await monitorLoop;