@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.
- package/README.md +41 -0
- package/dist/briefing.js +15 -0
- package/dist/cli.js +6 -0
- package/dist/config.d.ts +15 -1
- package/dist/config.js +58 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +43 -1
- package/dist/harness/codex.js +16 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/isolation/policy.js +5 -0
- package/dist/isolation/runtime.d.ts +16 -0
- package/dist/isolation/runtime.js +136 -0
- package/dist/isolation/types.d.ts +2 -0
- package/dist/owner-channel/channel.d.ts +55 -0
- package/dist/owner-channel/channel.js +295 -0
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +123 -0
- package/dist/owner-channel/state.d.ts +10 -0
- package/dist/owner-channel/state.js +37 -0
- package/dist/resolved-plan.js +2 -0
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +39 -5
- package/dist/session/acp.d.ts +1 -0
- package/dist/session/acp.js +17 -2
- package/dist/session/types.d.ts +3 -1
- package/dist/session/types.js +2 -2
- package/dist/spawn.js +7 -3
- package/dist/watchdog/config.d.ts +4 -0
- package/dist/watchdog/config.js +9 -3
- package/dist/watchdog/run.d.ts +2 -0
- package/dist/watchdog/run.js +50 -23
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/resolved-plan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
const
|
|
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;
|