@ours.network/fleet 1.2.0-nightly.3 → 1.2.0-nightly.4
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 +16 -19
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +169 -0
- package/dist/agent-ours/controller.d.ts +14 -0
- package/dist/agent-ours/controller.js +60 -0
- package/dist/agent-ours/file-rpc.d.ts +23 -0
- package/dist/agent-ours/file-rpc.js +123 -0
- package/dist/agent-ours/harness.d.ts +14 -0
- package/dist/agent-ours/harness.js +64 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
- package/dist/agent-ours/mcp-endpoint.js +111 -0
- package/dist/agent-ours/runtime.d.ts +53 -0
- package/dist/agent-ours/runtime.js +247 -0
- package/dist/agent-ours/service.d.ts +23 -0
- package/dist/agent-ours/service.js +352 -0
- package/dist/agent-ours/state.d.ts +45 -0
- package/dist/agent-ours/state.js +95 -0
- package/dist/agent-ours/wire.d.ts +14 -0
- package/dist/agent-ours/wire.js +55 -0
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +4 -4
- package/dist/cli.js +15 -2
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +3 -14
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +15 -20
- package/dist/fleet-command-audit.js +1 -1
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +4 -2
- package/dist/harness/codex-session.js +9 -2
- package/dist/harness/hermes-session.js +7 -1
- package/dist/rooms-tasks/provision.js +9 -2
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +522 -492
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/spawn.js +12 -16
- package/dist/temp-supervisor-recovery.d.ts +3 -0
- package/dist/temp-supervisor-recovery.js +46 -0
- package/dist/watchdog/briefing.js +3 -16
- package/dist/watchdog/run.js +19 -5
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -52,11 +52,10 @@ briefing.md per role ──► agent session adapter ──► native app-se
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Each role gets a state dir (`~/.ours-fleet/agents/<Name>/`) holding its briefing,
|
|
55
|
-
logs, routines, and session markers.
|
|
56
|
-
|
|
55
|
+
logs, routines, and session markers. Before boot, the supervisor binds the assigned identity and prepares its room.
|
|
56
|
+
The agent then reads its briefing and starts work — while the
|
|
57
57
|
supervisor delivers its mail wakes as `[fleet-monitor]` console lines (see
|
|
58
|
-
[Mail monitor](#mail-monitor)).
|
|
59
|
-
should own mail wake instead. On crash the supervisor relaunches it and the
|
|
58
|
+
[Mail monitor](#mail-monitor)). Managed ours sessions use supervisor-owned mail wakes. On crash the supervisor relaunches it and the
|
|
60
59
|
harness resumes the same session.
|
|
61
60
|
|
|
62
61
|
The state dir contract:
|
|
@@ -231,15 +230,11 @@ from the current runner generation and excludes adapter `session/load` replay;
|
|
|
231
230
|
those replay events remain in the durable ledger with `agent_replay` provenance
|
|
232
231
|
for diagnosis and recovery rather than appearing as current work.
|
|
233
232
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
A collision, missing tool, or creation error stops for operator review; fleet
|
|
240
|
-
never force-adopts or deletes identity state. Permanent roles are
|
|
241
|
-
provisioned by fleet before launch and never delegate normal identity creation
|
|
242
|
-
to the harness.
|
|
233
|
+
The Fleet supervisor creates and binds the assigned identity before the harness
|
|
234
|
+
starts. Temporary identities survive harness reconnects and are released only when
|
|
235
|
+
the logical run ends. Permanent identities remain available after the supervisor
|
|
236
|
+
releases ownership. Agents receive ready tools; no model-owned identity bootstrap
|
|
237
|
+
or onboarding skill is required.
|
|
243
238
|
|
|
244
239
|
The web console uses structured ACP activity rather than exposing a raw agent terminal.
|
|
245
240
|
|
|
@@ -649,10 +644,10 @@ originating task. Existing active rooms are not retroactively migrated, and room
|
|
|
649
644
|
with Owner attachment disabled receive no Owner grant.
|
|
650
645
|
|
|
651
646
|
Fleet launches each template member with a dedicated one-time Cowork invite.
|
|
652
|
-
The
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
647
|
+
The supervisor accepts the startup invite and verifies the provisioned identity CID
|
|
648
|
+
in the authenticated room seat before starting the model. The generated briefing
|
|
649
|
+
contains the assigned identity, room, role and task, without invite material.
|
|
650
|
+
A room is ready only after the Cowork room (and its task, when
|
|
656
651
|
task-bound) is durably active, every configured member seat is authenticated and active with its matching
|
|
657
652
|
live Fleet launch, and the configured Owner seat is active when owner attachment is
|
|
658
653
|
enabled. There is no briefing hash, startup ACK, or separate role-briefing readiness
|
|
@@ -675,8 +670,8 @@ Set `room.anonymous: true` on a room template, or pass `--anonymous` to
|
|
|
675
670
|
`task create`, `task start`, `task work`, or `room create`, to create an
|
|
676
671
|
anonymous Cowork room. `--no-anonymous` explicitly overrides an anonymous
|
|
677
672
|
template. Fleet records the resolved value before room creation so retries keep
|
|
678
|
-
the same choice.
|
|
679
|
-
|
|
673
|
+
the same choice. The supervisor creates anonymous room members with local contact-book exposure
|
|
674
|
+
disabled. Their generated briefings
|
|
680
675
|
do not disclose or compare an Owner participant CID. A participant-originated
|
|
681
676
|
instruction has Owner authority only when the authenticated Cowork room envelope
|
|
682
677
|
attributes that participant seat the exact `Owner` role. Literal text, display
|
|
@@ -1697,3 +1692,5 @@ origin directly. UUID/capability checks still precede credential-bearing calls.
|
|
|
1697
1692
|
This adds client HTTPS support, not an HTTPS daemon listener, certificate
|
|
1698
1693
|
provisioning or automatic reverse-proxy configuration. Existing local HTTP and
|
|
1699
1694
|
SSH-tunnel profiles continue to work.
|
|
1695
|
+
|
|
1696
|
+
See [supervisor-owned ours and migration](docs/supervisor-ours.md) for the runtime contract and review build instructions.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { connect } from 'node:net';
|
|
3
|
+
import { access, mkdir, open, readFile } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { MAX_CHUNK_BYTES, MAX_FRAME_BYTES, Wire } from './wire.js';
|
|
8
|
+
/** Runs inside exactly the harness OS sandbox. Only this process opens agent paths. */
|
|
9
|
+
export async function runBridge(descriptorPath) {
|
|
10
|
+
const descriptor = JSON.parse(await readFile(descriptorPath, 'utf8'));
|
|
11
|
+
if (typeof descriptor.socket !== 'string' ||
|
|
12
|
+
typeof descriptor.capability !== 'string' ||
|
|
13
|
+
!Number.isInteger(descriptor.generation))
|
|
14
|
+
throw Error('INVALID_BRIDGE_DESCRIPTOR');
|
|
15
|
+
const wire = new Wire(connect(descriptor.socket));
|
|
16
|
+
const handles = new Map();
|
|
17
|
+
const cleanup = async () => {
|
|
18
|
+
for (const row of handles.values())
|
|
19
|
+
await row.file.close().catch(() => { });
|
|
20
|
+
handles.clear();
|
|
21
|
+
};
|
|
22
|
+
let admitted = false;
|
|
23
|
+
let stdin = Buffer.alloc(0);
|
|
24
|
+
let chain = Promise.resolve();
|
|
25
|
+
const fail = () => {
|
|
26
|
+
wire.close();
|
|
27
|
+
process.stdin.pause();
|
|
28
|
+
void cleanup();
|
|
29
|
+
};
|
|
30
|
+
wire.onClose = fail;
|
|
31
|
+
wire.onFrame = (raw) => {
|
|
32
|
+
if (raw?.kind === 'ready') {
|
|
33
|
+
if (admitted)
|
|
34
|
+
throw Error('DUPLICATE_READY');
|
|
35
|
+
admitted = true;
|
|
36
|
+
process.stdin.resume();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!admitted)
|
|
40
|
+
throw Error('BRIDGE_NOT_READY');
|
|
41
|
+
if (raw?.kind === 'mcp') {
|
|
42
|
+
const line = JSON.stringify(raw.value) + '\n';
|
|
43
|
+
if (!process.stdout.write(line)) {
|
|
44
|
+
wire.socket.pause();
|
|
45
|
+
process.stdout.once('drain', () => wire.socket.resume());
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (raw?.kind !== 'fs' || typeof raw.id !== 'string' || typeof raw.call !== 'string')
|
|
50
|
+
throw Error('INVALID_BRIDGE_FRAME');
|
|
51
|
+
chain = chain
|
|
52
|
+
.then(async () => {
|
|
53
|
+
try {
|
|
54
|
+
const { op, args, call } = raw;
|
|
55
|
+
let value;
|
|
56
|
+
if (op === 'probe') {
|
|
57
|
+
try {
|
|
58
|
+
await access(args.path, constants.R_OK);
|
|
59
|
+
value = true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
value = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (op === 'read-open' || op === 'write-open') {
|
|
66
|
+
if (handles.size >= 32 ||
|
|
67
|
+
typeof args.handle !== 'string' ||
|
|
68
|
+
handles.has(args.handle) ||
|
|
69
|
+
typeof args.path !== 'string')
|
|
70
|
+
throw Error('FILE_LIMIT');
|
|
71
|
+
const path = resolve(args.path);
|
|
72
|
+
if (op === 'write-open')
|
|
73
|
+
await mkdir(dirname(path), { recursive: true });
|
|
74
|
+
const file = await open(path, op === 'write-open' ? 'w' : 'r');
|
|
75
|
+
handles.set(args.handle, { file, call, write: op === 'write-open' });
|
|
76
|
+
value =
|
|
77
|
+
op === 'write-open'
|
|
78
|
+
? path
|
|
79
|
+
: { filename: basename(path), size: (await file.stat()).size };
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const row = handles.get(args.handle);
|
|
83
|
+
if (!row) {
|
|
84
|
+
if (op === 'close') {
|
|
85
|
+
value = null;
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
throw Error('UNKNOWN_FILE_HANDLE');
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
if (row.call !== call)
|
|
92
|
+
throw Error('FOREIGN_FILE_CALL');
|
|
93
|
+
if (op === 'close') {
|
|
94
|
+
handles.delete(args.handle);
|
|
95
|
+
await row.file.close();
|
|
96
|
+
value = null;
|
|
97
|
+
}
|
|
98
|
+
else if (op === 'read' && !row.write) {
|
|
99
|
+
const buffer = Buffer.alloc(MAX_CHUNK_BYTES);
|
|
100
|
+
const { bytesRead } = await row.file.read(buffer);
|
|
101
|
+
value = bytesRead ? buffer.subarray(0, bytesRead).toString('base64') : null;
|
|
102
|
+
}
|
|
103
|
+
else if (op === 'write' && row.write) {
|
|
104
|
+
if (typeof args.data !== 'string' ||
|
|
105
|
+
args.data.length > Math.ceil(MAX_CHUNK_BYTES / 3) * 4)
|
|
106
|
+
throw Error('FILE_CHUNK_LIMIT');
|
|
107
|
+
const buffer = Buffer.from(args.data, 'base64');
|
|
108
|
+
let offset = 0;
|
|
109
|
+
while (offset < buffer.length) {
|
|
110
|
+
const r = await row.file.write(buffer, offset, buffer.length - offset);
|
|
111
|
+
if (!r.bytesWritten)
|
|
112
|
+
throw Error('SHORT_WRITE');
|
|
113
|
+
offset += r.bytesWritten;
|
|
114
|
+
}
|
|
115
|
+
value = null;
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
throw Error('INVALID_FILE_OPERATION');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await wire.send({ kind: 'fs-result', id: raw.id, value });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
await wire.send({ kind: 'fs-result', id: raw.id, error: 'FILE_OPERATION_FAILED' });
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.catch(fail);
|
|
128
|
+
};
|
|
129
|
+
process.stdin.pause();
|
|
130
|
+
process.stdin.on('data', (chunk) => {
|
|
131
|
+
stdin = Buffer.concat([stdin, chunk]);
|
|
132
|
+
for (;;) {
|
|
133
|
+
const end = stdin.indexOf(10);
|
|
134
|
+
if (end < 0) {
|
|
135
|
+
if (stdin.length > MAX_FRAME_BYTES)
|
|
136
|
+
fail();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (end > MAX_FRAME_BYTES) {
|
|
140
|
+
fail();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const line = stdin.subarray(0, end);
|
|
144
|
+
stdin = stdin.subarray(end + 1);
|
|
145
|
+
try {
|
|
146
|
+
const value = JSON.parse(line.toString('utf8'));
|
|
147
|
+
process.stdin.pause();
|
|
148
|
+
void wire.send({ kind: 'mcp', value }).then(() => process.stdin.resume(), fail);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
fail();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
process.stdin.on('end', fail);
|
|
157
|
+
process.stdout.on('error', fail);
|
|
158
|
+
await wire.send({
|
|
159
|
+
kind: 'hello',
|
|
160
|
+
capability: descriptor.capability,
|
|
161
|
+
generation: descriptor.generation,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
165
|
+
runBridge(process.env.FLEET_OURS_BRIDGE_DESCRIPTOR ?? '').catch(() => {
|
|
166
|
+
process.stderr.write('Fleet ours bridge unavailable\n');
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { RuntimeJournal } from './state.js';
|
|
2
|
+
/** Reuse Fleet's process-lifetime binder to serialize startup and cleanup. */
|
|
3
|
+
export declare class RuntimeController {
|
|
4
|
+
private readonly lease;
|
|
5
|
+
readonly privateDir: string;
|
|
6
|
+
readonly journal: RuntimeJournal;
|
|
7
|
+
private active;
|
|
8
|
+
private constructor();
|
|
9
|
+
static acquire(root: string, daemon: string, name: string, instance: string): Promise<RuntimeController>;
|
|
10
|
+
assertFence: () => void;
|
|
11
|
+
successor(): string;
|
|
12
|
+
resumeGeneration(): number;
|
|
13
|
+
unlock(): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, renameSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { acquireOwnerBinderLease } from '../owner-channel/binder.js';
|
|
5
|
+
import { binderKey, RuntimeJournal } from './state.js';
|
|
6
|
+
/** Reuse Fleet's process-lifetime binder to serialize startup and cleanup. */
|
|
7
|
+
export class RuntimeController {
|
|
8
|
+
lease;
|
|
9
|
+
privateDir;
|
|
10
|
+
journal;
|
|
11
|
+
active = true;
|
|
12
|
+
constructor(lease, privateDir) {
|
|
13
|
+
this.lease = lease;
|
|
14
|
+
this.privateDir = privateDir;
|
|
15
|
+
this.journal = new RuntimeJournal(privateDir);
|
|
16
|
+
}
|
|
17
|
+
static async acquire(root, daemon, name, instance) {
|
|
18
|
+
const dir = join(root, binderKey(daemon, name));
|
|
19
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
20
|
+
const lease = await acquireOwnerBinderLease(dir, 'agent-ours', name);
|
|
21
|
+
const controller = new RuntimeController(lease, dir);
|
|
22
|
+
if (controller.journal.read()?.instance && controller.journal.read().instance !== instance) {
|
|
23
|
+
lease.release();
|
|
24
|
+
throw Error('RUNTIME_INSTANCE_MISMATCH');
|
|
25
|
+
}
|
|
26
|
+
return controller;
|
|
27
|
+
}
|
|
28
|
+
assertFence = () => {
|
|
29
|
+
if (!this.active)
|
|
30
|
+
throw Error('STALE_RUNTIME_CONTROLLER');
|
|
31
|
+
};
|
|
32
|
+
successor() {
|
|
33
|
+
this.assertFence();
|
|
34
|
+
const state = this.journal.read();
|
|
35
|
+
if (state?.phase !== 'RELEASED')
|
|
36
|
+
throw Error('PREDECESSOR_NOT_RELEASED');
|
|
37
|
+
const archive = join(this.privateDir, `retired-${state.instance}`);
|
|
38
|
+
mkdirSync(archive, { mode: 0o700 });
|
|
39
|
+
for (const file of ['state.json', 'owner.json', 'instance.json'])
|
|
40
|
+
renameSync(join(this.privateDir, file), join(archive, file));
|
|
41
|
+
return randomUUID();
|
|
42
|
+
}
|
|
43
|
+
resumeGeneration() {
|
|
44
|
+
this.assertFence();
|
|
45
|
+
const s = this.journal.read();
|
|
46
|
+
if (!s)
|
|
47
|
+
return 1;
|
|
48
|
+
if (['TERMINAL_INTENT', 'CLEANUP_PENDING', 'RELEASED', 'FAILED'].includes(s.phase))
|
|
49
|
+
throw Error('RUNTIME_RETIRED_OR_FAILED');
|
|
50
|
+
if (!s.cid)
|
|
51
|
+
throw Error('UNCERTAIN_PROVISIONING');
|
|
52
|
+
this.journal.commit({ ...s, generation: s.generation + 1, phase: 'RECOVERING' }, s.revision);
|
|
53
|
+
return s.generation + 1;
|
|
54
|
+
}
|
|
55
|
+
unlock() {
|
|
56
|
+
this.assertFence();
|
|
57
|
+
this.lease.release();
|
|
58
|
+
this.active = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FileExecutionContext } from '@ours.network/mcp/server';
|
|
2
|
+
import type { ToolRequestExtra } from '@ours.network/mcp/dist/types/mcp/tool.js';
|
|
3
|
+
import { type Wire } from './wire.js';
|
|
4
|
+
/** Call-scoped callbacks carry no daemon credentials or upload receipts. */
|
|
5
|
+
export declare class FileCallbacks implements FileExecutionContext {
|
|
6
|
+
private readonly wire;
|
|
7
|
+
private pending;
|
|
8
|
+
constructor(wire: Wire);
|
|
9
|
+
receive(frame: any): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
private rpc;
|
|
12
|
+
canRead(path: string, extra: ToolRequestExtra): Promise<boolean>;
|
|
13
|
+
read(path: string, extra: ToolRequestExtra): Promise<{
|
|
14
|
+
filename: any;
|
|
15
|
+
size: any;
|
|
16
|
+
body: ReadableStream<Uint8Array<ArrayBufferLike>>;
|
|
17
|
+
close: () => Promise<void>;
|
|
18
|
+
}>;
|
|
19
|
+
write(path: string, body: ReadableStream<Uint8Array>, extra: ToolRequestExtra): Promise<{
|
|
20
|
+
path: string;
|
|
21
|
+
size: number;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { MAX_CHUNK_BYTES } from './wire.js';
|
|
3
|
+
/** Call-scoped callbacks carry no daemon credentials or upload receipts. */
|
|
4
|
+
export class FileCallbacks {
|
|
5
|
+
wire;
|
|
6
|
+
pending = new Map();
|
|
7
|
+
constructor(wire) {
|
|
8
|
+
this.wire = wire;
|
|
9
|
+
}
|
|
10
|
+
receive(frame) {
|
|
11
|
+
if (frame?.kind !== 'fs-result' || typeof frame.id !== 'string')
|
|
12
|
+
throw Error('INVALID_FILE_RESPONSE');
|
|
13
|
+
const p = this.pending.get(frame.id);
|
|
14
|
+
if (!p)
|
|
15
|
+
throw Error('UNSOLICITED_FILE_RESPONSE');
|
|
16
|
+
this.pending.delete(frame.id);
|
|
17
|
+
clearTimeout(p.timer);
|
|
18
|
+
if (frame.error)
|
|
19
|
+
p.reject(Error('AGENT_FILE_OPERATION_FAILED'));
|
|
20
|
+
else
|
|
21
|
+
p.resolve(frame.value);
|
|
22
|
+
}
|
|
23
|
+
close() {
|
|
24
|
+
for (const p of this.pending.values()) {
|
|
25
|
+
clearTimeout(p.timer);
|
|
26
|
+
p.reject(Error('FILE_BRIDGE_DISCONNECTED'));
|
|
27
|
+
}
|
|
28
|
+
this.pending.clear();
|
|
29
|
+
}
|
|
30
|
+
async rpc(call, op, args, signal) {
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
if (this.pending.size >= 64)
|
|
33
|
+
throw Error('FILE_CALLBACK_LIMIT');
|
|
34
|
+
const id = randomUUID();
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const finish = (f) => (value) => {
|
|
37
|
+
signal.removeEventListener('abort', abort);
|
|
38
|
+
f(value);
|
|
39
|
+
};
|
|
40
|
+
const fail = (reason) => {
|
|
41
|
+
const p = this.pending.get(id);
|
|
42
|
+
if (!p)
|
|
43
|
+
return;
|
|
44
|
+
this.pending.delete(id);
|
|
45
|
+
clearTimeout(p.timer);
|
|
46
|
+
finish(reject)(reason);
|
|
47
|
+
};
|
|
48
|
+
const abort = () => fail(Error('FILE_CALLBACK_ABORTED'));
|
|
49
|
+
const timer = setTimeout(() => fail(Error('FILE_CALLBACK_TIMEOUT')), 30_000);
|
|
50
|
+
timer.unref();
|
|
51
|
+
this.pending.set(id, { resolve: finish(resolve), reject: finish(reject), timer });
|
|
52
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
53
|
+
void this.wire
|
|
54
|
+
.send({ kind: 'fs', id, call, op, args })
|
|
55
|
+
.catch(() => fail(Error('FILE_BRIDGE_DISCONNECTED')));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async canRead(path, extra) {
|
|
59
|
+
return (await this.rpc(String(extra.requestId), 'probe', { path }, extra.signal)) === true;
|
|
60
|
+
}
|
|
61
|
+
async read(path, extra) {
|
|
62
|
+
const call = String(extra.requestId), handle = randomUUID();
|
|
63
|
+
const meta = await this.rpc(call, 'read-open', { path, handle }, extra.signal);
|
|
64
|
+
if (!meta ||
|
|
65
|
+
typeof meta.filename !== 'string' ||
|
|
66
|
+
!Number.isSafeInteger(meta.size) ||
|
|
67
|
+
meta.size < 0)
|
|
68
|
+
throw Error('INVALID_FILE_METADATA');
|
|
69
|
+
const close = async () => {
|
|
70
|
+
await this.rpc(call, 'close', { handle }, new AbortController().signal).catch(() => { });
|
|
71
|
+
};
|
|
72
|
+
const body = new ReadableStream({
|
|
73
|
+
pull: async (controller) => {
|
|
74
|
+
try {
|
|
75
|
+
const chunk = await this.rpc(call, 'read', { handle }, extra.signal);
|
|
76
|
+
if (chunk === null) {
|
|
77
|
+
controller.close();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (typeof chunk !== 'string' || chunk.length > Math.ceil(MAX_CHUNK_BYTES / 3) * 4)
|
|
81
|
+
throw Error('INVALID_FILE_CHUNK');
|
|
82
|
+
const bytes = Buffer.from(chunk, 'base64');
|
|
83
|
+
if (bytes.length > MAX_CHUNK_BYTES)
|
|
84
|
+
throw Error('FILE_CHUNK_TOO_LARGE');
|
|
85
|
+
controller.enqueue(bytes);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
controller.error(error);
|
|
89
|
+
await close();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
cancel: close,
|
|
93
|
+
});
|
|
94
|
+
return { filename: meta.filename, size: meta.size, body, close };
|
|
95
|
+
}
|
|
96
|
+
async write(path, body, extra) {
|
|
97
|
+
const call = String(extra.requestId), handle = randomUUID();
|
|
98
|
+
const reader = body.getReader();
|
|
99
|
+
try {
|
|
100
|
+
const resolved = await this.rpc(call, 'write-open', { path, handle }, extra.signal);
|
|
101
|
+
if (typeof resolved !== 'string')
|
|
102
|
+
throw Error('INVALID_FILE_PATH');
|
|
103
|
+
let size = 0;
|
|
104
|
+
for (;;) {
|
|
105
|
+
extra.signal.throwIfAborted();
|
|
106
|
+
const chunk = await reader.read();
|
|
107
|
+
if (chunk.done)
|
|
108
|
+
break;
|
|
109
|
+
for (let offset = 0; offset < chunk.value.length; offset += MAX_CHUNK_BYTES) {
|
|
110
|
+
const bytes = chunk.value.subarray(offset, offset + MAX_CHUNK_BYTES);
|
|
111
|
+
size += bytes.length;
|
|
112
|
+
await this.rpc(call, 'write', { handle, data: Buffer.from(bytes).toString('base64') }, extra.signal);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await this.rpc(call, 'close', { handle }, extra.signal);
|
|
116
|
+
return { path: resolved, size };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await reader.cancel().catch(() => { });
|
|
120
|
+
await this.rpc(call, 'close', { handle }, new AbortController().signal).catch(() => { });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AcpMcpServer } from '../harness/types.js';
|
|
2
|
+
import type { ResolvedRole } from '../config.js';
|
|
3
|
+
export interface ManagedHarnessOurs {
|
|
4
|
+
server: AcpMcpServer;
|
|
5
|
+
native: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
/** Do not pass the supervisor's ours connection credentials to harness children. */
|
|
8
|
+
export declare function managedChildEnvironment(input: NodeJS.ProcessEnv): Record<string, string>;
|
|
9
|
+
/** Replace ours only; retain the user's ordinary harness configuration and plugins. */
|
|
10
|
+
export declare function prepareManagedHarness(role: ResolvedRole, stateDir: string, cwd: string, descriptor: string, env: Record<string, string>): {
|
|
11
|
+
ours: ManagedHarnessOurs;
|
|
12
|
+
env: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
export declare function managedClaudeMeta(base: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
/** Do not pass the supervisor's ours connection credentials to harness children. */
|
|
4
|
+
export function managedChildEnvironment(input) {
|
|
5
|
+
const out = {};
|
|
6
|
+
for (const [key, value] of Object.entries(input)) {
|
|
7
|
+
if (value === undefined || (/^OURS_/i.test(key) && !key.startsWith('OURS_FLEET_')))
|
|
8
|
+
continue;
|
|
9
|
+
out[key] = value;
|
|
10
|
+
}
|
|
11
|
+
out.FLEET_OURS_MANAGED = '1';
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
/** Replace ours only; retain the user's ordinary harness configuration and plugins. */
|
|
15
|
+
export function prepareManagedHarness(role, stateDir, cwd, descriptor, env) {
|
|
16
|
+
const bridge = fileURLToPath(new URL('./bridge.js', import.meta.url));
|
|
17
|
+
const server = {
|
|
18
|
+
name: 'ours',
|
|
19
|
+
command: process.execPath,
|
|
20
|
+
args: [bridge],
|
|
21
|
+
env: [{ name: 'FLEET_OURS_BRIDGE_DESCRIPTOR', value: descriptor }],
|
|
22
|
+
};
|
|
23
|
+
const native = {
|
|
24
|
+
mcp_servers: {
|
|
25
|
+
ours: {
|
|
26
|
+
command: process.execPath,
|
|
27
|
+
args: [bridge],
|
|
28
|
+
env: { FLEET_OURS_BRIDGE_DESCRIPTOR: descriptor },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
plugins: { 'ours@ours-codex-marketplace': { enabled: false } },
|
|
32
|
+
};
|
|
33
|
+
const child = managedChildEnvironment({ ...process.env, ...env });
|
|
34
|
+
if (role.harness === 'codex') {
|
|
35
|
+
const config = child.CODEX_CONFIG ? JSON.parse(child.CODEX_CONFIG) : {};
|
|
36
|
+
child.CODEX_CONFIG = JSON.stringify({
|
|
37
|
+
...config,
|
|
38
|
+
plugins: { ...config.plugins, ...native.plugins },
|
|
39
|
+
});
|
|
40
|
+
// codex-acp otherwise prefers a same-name connector from the user's config.
|
|
41
|
+
child.DISABLE_MCP_CONFIG_FILTERING = 'true';
|
|
42
|
+
}
|
|
43
|
+
return { ours: { server, native }, env: child };
|
|
44
|
+
}
|
|
45
|
+
export function managedClaudeMeta(base) {
|
|
46
|
+
const meta = base ?? {}, claude = (meta.claudeCode ?? {});
|
|
47
|
+
const options = (claude.options ?? {});
|
|
48
|
+
const settings = typeof options.settings === 'string'
|
|
49
|
+
? JSON.parse(readFileSync(options.settings, 'utf8'))
|
|
50
|
+
: (options.settings ?? {});
|
|
51
|
+
return {
|
|
52
|
+
...meta,
|
|
53
|
+
claudeCode: {
|
|
54
|
+
...claude,
|
|
55
|
+
options: {
|
|
56
|
+
...options,
|
|
57
|
+
settings: {
|
|
58
|
+
...settings,
|
|
59
|
+
enabledPlugins: { ...settings.enabledPlugins, 'ours@ours.network': false },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ApplicationIdentityStore } from '@ours.network/mcp/application-identities';
|
|
2
|
+
import type { OursClient } from '@ours.network/sdk/client';
|
|
3
|
+
import type { AgentOursRuntime } from './runtime.js';
|
|
4
|
+
export interface EndpointOptions {
|
|
5
|
+
socket: string;
|
|
6
|
+
capability: string;
|
|
7
|
+
generation: number;
|
|
8
|
+
runtime: AgentOursRuntime;
|
|
9
|
+
client: OursClient;
|
|
10
|
+
identities: ApplicationIdentityStore;
|
|
11
|
+
remoteDaemonFiles: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function startMcpEndpoint(options: EndpointOptions): Promise<{
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { chmod, unlink } from 'node:fs/promises';
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { createManagedOursMcpServer } from '@ours.network/mcp/server';
|
|
5
|
+
import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { FileCallbacks } from './file-rpc.js';
|
|
7
|
+
import { Wire } from './wire.js';
|
|
8
|
+
export async function startMcpEndpoint(options) {
|
|
9
|
+
const sockets = new Set();
|
|
10
|
+
const server = createServer((socket) => {
|
|
11
|
+
if (sockets.size >= 4) {
|
|
12
|
+
socket.destroy();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const wire = new Wire(socket);
|
|
16
|
+
sockets.add(wire);
|
|
17
|
+
const files = new FileCallbacks(wire);
|
|
18
|
+
let authenticated = false;
|
|
19
|
+
let inFlight = 0;
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
const deadline = setTimeout(() => wire.close(), 5000);
|
|
22
|
+
deadline.unref();
|
|
23
|
+
const transport = {
|
|
24
|
+
start: async () => { },
|
|
25
|
+
send: async (message) => {
|
|
26
|
+
if ('id' in message &&
|
|
27
|
+
message.id !== undefined &&
|
|
28
|
+
('result' in message || 'error' in message))
|
|
29
|
+
ids.delete(message.id);
|
|
30
|
+
await wire.send({ kind: 'mcp', value: message });
|
|
31
|
+
},
|
|
32
|
+
close: async () => wire.close(),
|
|
33
|
+
};
|
|
34
|
+
const mcp = createManagedOursMcpServer(options.client, 'managed-v1', options.identities, {
|
|
35
|
+
remoteDaemonFiles: options.remoteDaemonFiles,
|
|
36
|
+
fileContext: files,
|
|
37
|
+
admit: async () => {
|
|
38
|
+
if (!authenticated || inFlight >= 32)
|
|
39
|
+
throw Error('MCP_ADMISSION_LIMIT');
|
|
40
|
+
inFlight++;
|
|
41
|
+
try {
|
|
42
|
+
const release = await options.runtime.admit();
|
|
43
|
+
return () => {
|
|
44
|
+
inFlight--;
|
|
45
|
+
release();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
inFlight--;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
wire.onClose = () => {
|
|
55
|
+
clearTimeout(deadline);
|
|
56
|
+
sockets.delete(wire);
|
|
57
|
+
files.close();
|
|
58
|
+
transport.onclose?.();
|
|
59
|
+
};
|
|
60
|
+
wire.onFrame = (frame) => {
|
|
61
|
+
if (!authenticated) {
|
|
62
|
+
const actual = typeof frame?.capability === 'string' ? Buffer.from(frame.capability) : Buffer.alloc(0);
|
|
63
|
+
const expected = Buffer.from(options.capability);
|
|
64
|
+
if (frame?.kind !== 'hello' ||
|
|
65
|
+
frame.generation !== options.generation ||
|
|
66
|
+
actual.length !== expected.length ||
|
|
67
|
+
!timingSafeEqual(actual, expected))
|
|
68
|
+
throw Error('UNAUTHORIZED_BRIDGE');
|
|
69
|
+
authenticated = true;
|
|
70
|
+
clearTimeout(deadline);
|
|
71
|
+
void mcp
|
|
72
|
+
.connect(transport)
|
|
73
|
+
.then(() => wire.send({ kind: 'ready' }))
|
|
74
|
+
.catch(() => wire.close());
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (frame?.kind === 'fs-result') {
|
|
78
|
+
files.receive(frame);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (frame?.kind !== 'mcp')
|
|
82
|
+
throw Error('UNKNOWN_BRIDGE_OPERATION');
|
|
83
|
+
const message = JSONRPCMessageSchema.parse(frame.value);
|
|
84
|
+
if ('method' in message && 'id' in message) {
|
|
85
|
+
if (ids.has(message.id) || ids.size >= 64)
|
|
86
|
+
throw Error('MCP_REQUEST_LIMIT');
|
|
87
|
+
ids.add(message.id);
|
|
88
|
+
}
|
|
89
|
+
transport.onmessage?.(message);
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
server.once('error', reject);
|
|
94
|
+
server.listen(options.socket, () => {
|
|
95
|
+
server.removeListener('error', reject);
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
await chmod(options.socket, 0o600);
|
|
100
|
+
return {
|
|
101
|
+
close: async () => {
|
|
102
|
+
for (const socket of sockets)
|
|
103
|
+
socket.close();
|
|
104
|
+
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
105
|
+
await unlink(options.socket).catch((error) => {
|
|
106
|
+
if (error.code !== 'ENOENT')
|
|
107
|
+
throw error;
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|