@ours.network/fleet 0.17.3 → 0.18.0-nightly.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +5 -5
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +77 -70
- package/dist/owner-channel/ours-client.d.ts +126 -0
- package/dist/owner-channel/ours-client.js +148 -0
- package/dist/runner.d.ts +9 -1
- package/dist/runner.js +21 -3
- package/package.json +2 -1
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
5
|
-
"dirty":
|
|
6
|
-
"builtAt": "2026-08-
|
|
2
|
+
"version": "0.18.0-nightly.1",
|
|
3
|
+
"buildId": "880ef60a977f",
|
|
4
|
+
"commit": "ece91028c05a5669507c0cdcff3a6f365902fcab",
|
|
5
|
+
"dirty": true,
|
|
6
|
+
"builtAt": "2026-08-16T13:06:15.416Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"monitor.interrupt.after_tool"
|
|
9
9
|
]
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { type FileHandle } from 'node:fs/promises';
|
|
1
2
|
import type { OwnerAttachmentConfig } from '../config.js';
|
|
3
|
+
import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
|
|
2
4
|
export interface AttachmentReplyRef {
|
|
3
5
|
wire_id: string;
|
|
4
6
|
sentence?: number;
|
|
@@ -42,8 +44,14 @@ export interface AdmittedAttachment {
|
|
|
42
44
|
kind: 'file' | 'voice_message';
|
|
43
45
|
transcription?: Omit<VoiceTranscription, 'audioPath'>;
|
|
44
46
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Admit the daemon's file listing. The rows are typed now, but every field is
|
|
49
|
+
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
50
|
+
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
51
|
+
* row rather than produce a half-built attachment.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseIncomingAttachments(raw: OursIncomingFile[] | undefined): IncomingAttachment[];
|
|
54
|
+
export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | undefined, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
|
|
47
55
|
export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
|
|
48
56
|
/**
|
|
49
57
|
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
@@ -54,6 +62,21 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
|
|
|
54
62
|
export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
|
|
55
63
|
mimePolicy?: 'strict' | 'report-only';
|
|
56
64
|
}): Promise<AdmittedAttachment[]>;
|
|
65
|
+
/** Injectable short-write seam, so partial writes are provably handled. */
|
|
66
|
+
export interface AttachmentWriteDeps {
|
|
67
|
+
write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
71
|
+
*
|
|
72
|
+
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
73
|
+
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
74
|
+
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
75
|
+
* rather than accepted from a caller, and the file is published by link-after-
|
|
76
|
+
* fsync, so a crash or a short write can never leave a partial file where the
|
|
77
|
+
* admission step would read it as complete.
|
|
78
|
+
*/
|
|
79
|
+
export declare function writeRecoveredAttachment(dir: string, wireId: string, bytes: Uint8Array, deps?: AttachmentWriteDeps): Promise<string>;
|
|
57
80
|
export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
|
|
58
81
|
export declare function removeRequestDirectory(path: string): Promise<void>;
|
|
59
82
|
export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
|
|
@@ -6,8 +6,14 @@ import { replaceFileAtomically } from '../atomic-file.js';
|
|
|
6
6
|
const WIRE = /^[A-Fa-f0-9]{64}$/;
|
|
7
7
|
const CID = /^[A-Fa-f0-9]{64}$/;
|
|
8
8
|
const MAX_PENDING_REQUESTS = 32;
|
|
9
|
+
/**
|
|
10
|
+
* Admit the daemon's file listing. The rows are typed now, but every field is
|
|
11
|
+
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
12
|
+
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
13
|
+
* row rather than produce a half-built attachment.
|
|
14
|
+
*/
|
|
9
15
|
export function parseIncomingAttachments(raw) {
|
|
10
|
-
const values = raw
|
|
16
|
+
const values = raw;
|
|
11
17
|
if (!Array.isArray(values))
|
|
12
18
|
return [];
|
|
13
19
|
const out = [];
|
|
@@ -40,12 +46,12 @@ export function parseIncomingAttachments(raw) {
|
|
|
40
46
|
export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
41
47
|
const values = raw?.files;
|
|
42
48
|
if (!Array.isArray(values) || values.length !== expected.length)
|
|
43
|
-
throw new Error('ours
|
|
49
|
+
throw new Error('the ours daemon returned an incomplete selected attachment set');
|
|
44
50
|
const byWire = new Map(expected.map(file => [file.wireId, file]));
|
|
45
51
|
const out = [];
|
|
46
52
|
for (const value of values) {
|
|
47
53
|
if (!value || typeof value !== 'object')
|
|
48
|
-
throw new Error('ours
|
|
54
|
+
throw new Error('the ours daemon returned invalid attachment metadata');
|
|
49
55
|
const file = value;
|
|
50
56
|
const wireId = String(file.wire_id ?? '');
|
|
51
57
|
const listed = byWire.get(wireId);
|
|
@@ -58,7 +64,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
58
64
|
|| !Number.isSafeInteger(size) || size !== listed.size || mime !== listed.mime
|
|
59
65
|
|| kind !== listed.kind || !/^[a-f0-9]{64}$/.test(sha256)
|
|
60
66
|
|| typeof file.path !== 'string' || !file.path)
|
|
61
|
-
throw new Error('
|
|
67
|
+
throw new Error('selected attachment provenance or integrity metadata mismatched');
|
|
62
68
|
out.push({
|
|
63
69
|
...listed, filename: safeField(file.filename, 255), mime, size, path: file.path, sha256, kind,
|
|
64
70
|
...(recovered ? {} : parseTranscription(file.transcription, wireId)),
|
|
@@ -66,7 +72,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
66
72
|
byWire.delete(wireId);
|
|
67
73
|
}
|
|
68
74
|
if (byWire.size)
|
|
69
|
-
throw new Error('ours
|
|
75
|
+
throw new Error('the ours daemon omitted a selected attachment');
|
|
70
76
|
return out;
|
|
71
77
|
}
|
|
72
78
|
function parseTranscription(value, wireId) {
|
|
@@ -219,6 +225,56 @@ export async function admitAttachments(files, dir, config, options = {}) {
|
|
|
219
225
|
}
|
|
220
226
|
return admitted;
|
|
221
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
230
|
+
*
|
|
231
|
+
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
232
|
+
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
233
|
+
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
234
|
+
* rather than accepted from a caller, and the file is published by link-after-
|
|
235
|
+
* fsync, so a crash or a short write can never leave a partial file where the
|
|
236
|
+
* admission step would read it as complete.
|
|
237
|
+
*/
|
|
238
|
+
export async function writeRecoveredAttachment(dir, wireId, bytes, deps = {}) {
|
|
239
|
+
if (!WIRE.test(wireId))
|
|
240
|
+
throw new Error('recovered attachment wire id is not a 64-hex value');
|
|
241
|
+
const dirStat = await lstat(dir);
|
|
242
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
|
|
243
|
+
throw new Error('recovered attachment directory is not a safe directory');
|
|
244
|
+
const write = deps.write
|
|
245
|
+
?? ((handle, buffer, offset) => handle.write(buffer, offset, buffer.length - offset)
|
|
246
|
+
.then(result => result.bytesWritten));
|
|
247
|
+
const finalPath = join(dir, `.recovered-${wireId}-${randomUUID()}`);
|
|
248
|
+
const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
|
|
249
|
+
const handle = await open(tmp, 'wx', 0o600);
|
|
250
|
+
try {
|
|
251
|
+
for (let written = 0; written < bytes.length;) {
|
|
252
|
+
const advanced = await write(handle, bytes, written);
|
|
253
|
+
if (advanced <= 0)
|
|
254
|
+
throw new Error(`recovered attachment write made no progress at byte ${written}`);
|
|
255
|
+
written += advanced;
|
|
256
|
+
}
|
|
257
|
+
await handle.sync();
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
await handle.close().catch(() => undefined);
|
|
261
|
+
await rm(tmp, { force: true });
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
await handle.close();
|
|
265
|
+
// link publishes the finished bytes under a name that never existed in a
|
|
266
|
+
// partial state; the temp is only ever removed after it succeeded.
|
|
267
|
+
try {
|
|
268
|
+
await link(tmp, finalPath);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
await rm(tmp, { force: true });
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
await rm(tmp, { force: true });
|
|
275
|
+
await chmod(finalPath, 0o600);
|
|
276
|
+
return finalPath;
|
|
277
|
+
}
|
|
222
278
|
export async function recoveredAttachment(file, path) {
|
|
223
279
|
const stat = await lstat(path);
|
|
224
280
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.size)
|
|
@@ -4,7 +4,7 @@ import { type FetchLike } from '../monitor.js';
|
|
|
4
4
|
import { type SessionHandle } from '../session/types.js';
|
|
5
5
|
import { type OwnerFleetOps } from './commands.js';
|
|
6
6
|
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
7
|
-
import { type
|
|
7
|
+
import { type OursOps } from './ours-client.js';
|
|
8
8
|
import { type OwnerUpdatePhase } from './notices.js';
|
|
9
9
|
import { type OwnerEntry } from './state.js';
|
|
10
10
|
import { type OwnerTaskPhase } from './tasks.js';
|
|
@@ -17,9 +17,13 @@ export interface OwnerChannelOptions {
|
|
|
17
17
|
session: SessionHandle;
|
|
18
18
|
stateDir: string;
|
|
19
19
|
env?: Record<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* `ours-mcp` binary for the legacy `watch` child process only. Daemon
|
|
22
|
+
* operations no longer go through it; they use the ours SDK client.
|
|
23
|
+
*/
|
|
20
24
|
command?: string;
|
|
21
25
|
log(line: string): void;
|
|
22
|
-
client?:
|
|
26
|
+
client?: OursOps;
|
|
23
27
|
/** Legacy child-process test seam; production uses the direct notification API. */
|
|
24
28
|
watch?: (identity: string) => ChildProcessWithoutNullStreams;
|
|
25
29
|
/** Test seam for the production direct notification long-poll. */
|
|
@@ -118,7 +122,13 @@ export type { OwnerUpdatePhase } from './notices.js';
|
|
|
118
122
|
export interface OwnerContact {
|
|
119
123
|
cid: string;
|
|
120
124
|
name: string;
|
|
125
|
+
/** Structural, from which daemon collection the row came: established or pending. */
|
|
121
126
|
status: string;
|
|
127
|
+
/**
|
|
128
|
+
* Retained for the `ours-fleet owner contact list` column. The daemon's typed
|
|
129
|
+
* contact view has no such field, so it is always absent; it is not inferred
|
|
130
|
+
* from anything a contact controls.
|
|
131
|
+
*/
|
|
122
132
|
kind?: string;
|
|
123
133
|
human?: {
|
|
124
134
|
cid?: string;
|
|
@@ -174,6 +184,12 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
174
184
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
175
185
|
notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
|
|
176
186
|
private manageNow;
|
|
187
|
+
/**
|
|
188
|
+
* The daemon reports established contacts and pending introductions as two
|
|
189
|
+
* separate collections, so the status is structural rather than a word parsed
|
|
190
|
+
* out of a rendered line. Nothing here can be spoofed by a contact's own
|
|
191
|
+
* display name.
|
|
192
|
+
*/
|
|
177
193
|
private contacts;
|
|
178
194
|
private contact;
|
|
179
195
|
private assertCid;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { createHash
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
@@ -10,11 +10,11 @@ import { resolveEndpoint } from '../monitor.js';
|
|
|
10
10
|
import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
|
|
11
11
|
import { VERSION } from '../version.js';
|
|
12
12
|
import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
|
|
13
|
-
import {
|
|
13
|
+
import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
|
|
14
14
|
import { ownerNotices, } from './notices.js';
|
|
15
15
|
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
16
16
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
17
|
-
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
|
|
17
|
+
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
|
|
18
18
|
import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
|
|
19
19
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
20
20
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
@@ -84,7 +84,7 @@ export class OwnerChannel {
|
|
|
84
84
|
fleetOps;
|
|
85
85
|
constructor(options) {
|
|
86
86
|
this.options = options;
|
|
87
|
-
this.client = options.client ?? new
|
|
87
|
+
this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
88
88
|
this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
|
|
89
89
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
90
90
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
@@ -123,12 +123,16 @@ export class OwnerChannel {
|
|
|
123
123
|
const bindStartedAt = now();
|
|
124
124
|
for (;;) {
|
|
125
125
|
try {
|
|
126
|
-
await this.client.
|
|
126
|
+
await this.client.bindIdentity(this.options.config.identity);
|
|
127
127
|
break;
|
|
128
128
|
}
|
|
129
129
|
catch (error) {
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
// The predecessor's lease may still be in flight. Only the daemon's
|
|
131
|
+
// own typed verdict may extend the handoff window: matching the
|
|
132
|
+
// wording of an error message would let any other failure whose text
|
|
133
|
+
// happens to say "bound to another live session" — including one
|
|
134
|
+
// relayed from a peer — spin here for the whole timeout.
|
|
135
|
+
const liveConflict = oursErrorCode(error) === OURS_BOUND_ELSEWHERE;
|
|
132
136
|
if (!this.binder.inherited || !liveConflict
|
|
133
137
|
|| now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
|
|
134
138
|
throw error;
|
|
@@ -217,11 +221,14 @@ export class OwnerChannel {
|
|
|
217
221
|
return { action: request.action, contacts: await this.contacts() };
|
|
218
222
|
case 'contact_invite': {
|
|
219
223
|
this.assertLabel(request.name);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
// The invite is the blob field, not a sentence containing it. The MCP
|
|
225
|
+
// surface answered with "One-time invite for X created (invite_id …).
|
|
226
|
+
// Share this blob out-of-band …:\n<blob>" and the whole sentence was
|
|
227
|
+
// handed out as the invite, so any rewording changed the payload.
|
|
228
|
+
const { blob } = await this.client.generateInvite(request.name);
|
|
229
|
+
if (typeof blob !== 'string' || !blob)
|
|
230
|
+
throw new Error('the ours daemon returned no invite blob');
|
|
231
|
+
return { action: request.action, invite: blob };
|
|
225
232
|
}
|
|
226
233
|
case 'contact_add': {
|
|
227
234
|
if (typeof request.invite !== 'string' || !request.invite)
|
|
@@ -229,18 +236,19 @@ export class OwnerChannel {
|
|
|
229
236
|
if (Buffer.byteLength(request.invite) > 48 * 1024)
|
|
230
237
|
throw new Error('invite exceeds 49152 bytes');
|
|
231
238
|
this.assertLabel(request.name);
|
|
232
|
-
let
|
|
239
|
+
let added;
|
|
233
240
|
try {
|
|
234
|
-
|
|
241
|
+
added = await this.client.addContact({
|
|
235
242
|
invite: request.invite, ...(request.name ? { name: request.name } : {}),
|
|
236
243
|
});
|
|
237
244
|
}
|
|
238
245
|
catch {
|
|
239
246
|
// Daemon errors are not allowed to reflect invite material through
|
|
240
247
|
// the control response, CLI stderr, or supervisor logs.
|
|
241
|
-
throw new Error('ours
|
|
248
|
+
throw new Error('the ours daemon could not accept the contact invite');
|
|
242
249
|
}
|
|
243
|
-
|
|
250
|
+
const contact = this.contact(added.cid, added.display, 'pending');
|
|
251
|
+
return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
|
|
244
252
|
}
|
|
245
253
|
case 'owner_list':
|
|
246
254
|
return {
|
|
@@ -314,33 +322,36 @@ export class OwnerChannel {
|
|
|
314
322
|
throw new Error('unknown owner-channel management action');
|
|
315
323
|
}
|
|
316
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* The daemon reports established contacts and pending introductions as two
|
|
327
|
+
* separate collections, so the status is structural rather than a word parsed
|
|
328
|
+
* out of a rendered line. Nothing here can be spoofed by a contact's own
|
|
329
|
+
* display name.
|
|
330
|
+
*/
|
|
317
331
|
async contacts() {
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
332
|
+
const view = await this.client.listContacts();
|
|
333
|
+
const rows = [
|
|
334
|
+
...(Array.isArray(view?.contacts) ? view.contacts : [])
|
|
335
|
+
.map(row => this.contact(row?.container_id, row?.name, 'established', view)),
|
|
336
|
+
...(Array.isArray(view?.pending) ? view.pending : [])
|
|
337
|
+
.map(row => this.contact(row?.container_id, row?.name, 'pending', view)),
|
|
338
|
+
];
|
|
339
|
+
return rows.filter((row) => Boolean(row))
|
|
323
340
|
.sort((a, b) => a.cid.localeCompare(b.cid));
|
|
324
341
|
}
|
|
325
|
-
contact(
|
|
326
|
-
|
|
327
|
-
return undefined;
|
|
328
|
-
const value = raw;
|
|
329
|
-
const cid = String(value.cid ?? value.id ?? value.container_id ?? value.containerId ?? '');
|
|
342
|
+
contact(cidValue, nameValue, status, view) {
|
|
343
|
+
const cid = String(cidValue ?? '');
|
|
330
344
|
if (!/^[A-Fa-f0-9]{64}$/.test(cid))
|
|
331
345
|
return undefined;
|
|
332
|
-
const
|
|
333
|
-
const
|
|
346
|
+
const root = view?.roots?.[cid];
|
|
347
|
+
const rootCid = String(root?.root_cid ?? '');
|
|
334
348
|
return {
|
|
335
349
|
cid,
|
|
336
|
-
name: this.safeMetadata(
|
|
337
|
-
status
|
|
338
|
-
...(
|
|
339
|
-
|
|
340
|
-
...(
|
|
341
|
-
&& /^[A-Fa-f0-9]{64}$/.test(String(human.cid ?? human.id))
|
|
342
|
-
? { cid: String(human.cid ?? human.id) } : {}),
|
|
343
|
-
...(human.name ? { name: this.safeMetadata(human.name) } : {}),
|
|
350
|
+
name: this.safeMetadata(nameValue ?? cid),
|
|
351
|
+
status,
|
|
352
|
+
...(root ? { human: {
|
|
353
|
+
...(/^[A-Fa-f0-9]{64}$/.test(rootCid) ? { cid: rootCid } : {}),
|
|
354
|
+
...(root.root_name ? { name: this.safeMetadata(root.root_name) } : {}),
|
|
344
355
|
} } : {}),
|
|
345
356
|
};
|
|
346
357
|
}
|
|
@@ -525,16 +536,16 @@ export class OwnerChannel {
|
|
|
525
536
|
// A finite cap protects the supervisor if a broken daemon repeats unread
|
|
526
537
|
// messages forever. A watch notification will resume draining later.
|
|
527
538
|
for (let pass = 0; pass < 100 && !this.stopping; pass++) {
|
|
528
|
-
const [
|
|
529
|
-
this.client.
|
|
530
|
-
this.client.
|
|
539
|
+
const [payload, fileResult] = await Promise.all([
|
|
540
|
+
this.client.getMessages(),
|
|
541
|
+
this.client.listIncomingFiles()
|
|
531
542
|
.catch(error => {
|
|
532
543
|
this.logError('attachment metadata inspection unavailable', error);
|
|
533
544
|
return undefined;
|
|
534
545
|
}),
|
|
535
546
|
]);
|
|
536
|
-
const messages = Array.isArray(
|
|
537
|
-
?
|
|
547
|
+
const messages = Array.isArray(payload?.messages)
|
|
548
|
+
? payload.messages.filter(message => message && typeof message === 'object')
|
|
538
549
|
: [];
|
|
539
550
|
let files = [];
|
|
540
551
|
try {
|
|
@@ -558,7 +569,7 @@ export class OwnerChannel {
|
|
|
558
569
|
&& Number.isInteger(message.msg_id);
|
|
559
570
|
}).map(message => message.msg_id);
|
|
560
571
|
if (deferred.length)
|
|
561
|
-
await this.client.
|
|
572
|
+
await this.client.deferMessages(deferred);
|
|
562
573
|
let advanced = false;
|
|
563
574
|
const consumedMessages = new Set();
|
|
564
575
|
const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
|
|
@@ -689,15 +700,12 @@ export class OwnerChannel {
|
|
|
689
700
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
690
701
|
const processed = group.files.filter(file => file.status !== 'unread');
|
|
691
702
|
const retrieved = unread.length
|
|
692
|
-
? parseRetrievedAttachments(await this.client.
|
|
693
|
-
wire_ids: unread.map(file => file.wireId),
|
|
694
|
-
}), unread)
|
|
703
|
+
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
695
704
|
: [];
|
|
696
705
|
for (const file of processed) {
|
|
697
706
|
if (!group.recovery)
|
|
698
707
|
throw new Error('unexpected processed attachment without recovery route');
|
|
699
|
-
const recoveryPath =
|
|
700
|
-
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
708
|
+
const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
701
709
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
702
710
|
}
|
|
703
711
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -794,12 +802,12 @@ export class OwnerChannel {
|
|
|
794
802
|
// the authenticated agent once so the wait is never silent.
|
|
795
803
|
this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
|
|
796
804
|
+ `route yet; message stays queued: ${this.errorText(error)}`);
|
|
797
|
-
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
|
|
805
|
+
await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
|
|
798
806
|
return false;
|
|
799
807
|
}
|
|
800
808
|
else {
|
|
801
809
|
this.logError('managed-agent message relay refused', error);
|
|
802
|
-
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
810
|
+
await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
803
811
|
}
|
|
804
812
|
}
|
|
805
813
|
this.state.remember(wireId);
|
|
@@ -1044,7 +1052,9 @@ export class OwnerChannel {
|
|
|
1044
1052
|
async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
|
|
1045
1053
|
const captionWire = group.caption ? this.wireId(group.caption) : undefined;
|
|
1046
1054
|
const nackWire = captionWire ?? group.files[0].wireId;
|
|
1047
|
-
|
|
1055
|
+
// A caption whose own wire id is synthetic (msg_id only) must not be echoed
|
|
1056
|
+
// back as a reply reference; a file wire always is a real one.
|
|
1057
|
+
const nackReplyTo = (group.caption ? group.caption.wire_id : nackWire) ? nackWire : undefined;
|
|
1048
1058
|
let requestDir;
|
|
1049
1059
|
let recovery;
|
|
1050
1060
|
try {
|
|
@@ -1080,15 +1090,12 @@ export class OwnerChannel {
|
|
|
1080
1090
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
1081
1091
|
const processed = group.files.filter(file => file.status !== 'unread');
|
|
1082
1092
|
const retrieved = unread.length
|
|
1083
|
-
? parseRetrievedAttachments(await this.client.
|
|
1084
|
-
wire_ids: unread.map(file => file.wireId),
|
|
1085
|
-
}), unread)
|
|
1093
|
+
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
1086
1094
|
: [];
|
|
1087
1095
|
for (const file of processed) {
|
|
1088
1096
|
if (!group.recovery)
|
|
1089
1097
|
throw new Error('unexpected processed attachment without recovery route');
|
|
1090
|
-
const recoveryPath =
|
|
1091
|
-
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
1098
|
+
const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
1092
1099
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
1093
1100
|
}
|
|
1094
1101
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -1100,9 +1107,9 @@ export class OwnerChannel {
|
|
|
1100
1107
|
if (caption)
|
|
1101
1108
|
await this.send(contact, caption, replyTo);
|
|
1102
1109
|
for (const file of admitted) {
|
|
1103
|
-
await this.client.
|
|
1110
|
+
await this.client.sendFile({
|
|
1104
1111
|
contact, path: file.path, filename: file.filename,
|
|
1105
|
-
...(replyTo ? {
|
|
1112
|
+
...(replyTo ? { replyToWireId: replyTo } : {}),
|
|
1106
1113
|
});
|
|
1107
1114
|
}
|
|
1108
1115
|
}
|
|
@@ -1139,11 +1146,11 @@ export class OwnerChannel {
|
|
|
1139
1146
|
if (error instanceof RelayUnroutableError) {
|
|
1140
1147
|
this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
|
|
1141
1148
|
+ `transaction stays queued: ${this.errorText(error)}`);
|
|
1142
|
-
await this.nackManagedAgent(agent,
|
|
1149
|
+
await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayQueued());
|
|
1143
1150
|
return false;
|
|
1144
1151
|
}
|
|
1145
1152
|
this.logError('managed-agent caption/file relay refused', error);
|
|
1146
|
-
await this.nackManagedAgent(agent,
|
|
1153
|
+
await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayRefused(this.errorText(error)));
|
|
1147
1154
|
// Rejection/admission failure and uncertain transport are terminal and
|
|
1148
1155
|
// visible. Consuming every correlated wire prevents a later partial replay.
|
|
1149
1156
|
for (const wire of handledWireIds)
|
|
@@ -1182,14 +1189,14 @@ export class OwnerChannel {
|
|
|
1182
1189
|
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
1183
1190
|
* delivery is best-effort — it must never make the failure worse.
|
|
1184
1191
|
*/
|
|
1185
|
-
async nackManagedAgent(contact,
|
|
1192
|
+
async nackManagedAgent(contact, replyTo, wireId, notice) {
|
|
1186
1193
|
if (this.relayNacks.has(wireId))
|
|
1187
1194
|
return;
|
|
1188
1195
|
this.relayNacks.add(wireId);
|
|
1189
1196
|
if (this.relayNacks.size > RELAY_NACK_MEMORY)
|
|
1190
1197
|
this.relayNacks.delete(this.relayNacks.values().next().value);
|
|
1191
1198
|
try {
|
|
1192
|
-
await this.send(contact, notice,
|
|
1199
|
+
await this.send(contact, notice, replyTo);
|
|
1193
1200
|
}
|
|
1194
1201
|
catch (error) {
|
|
1195
1202
|
this.logError('managed-agent relay NACK delivery failed', error);
|
|
@@ -1507,8 +1514,8 @@ export class OwnerChannel {
|
|
|
1507
1514
|
return createHash('sha256').update(wireId).digest('hex');
|
|
1508
1515
|
}
|
|
1509
1516
|
send(contact, text, replyTo) {
|
|
1510
|
-
return this.client.
|
|
1511
|
-
contact, text, ...(replyTo ? {
|
|
1517
|
+
return this.client.sendMessage({
|
|
1518
|
+
contact, text, ...(replyTo ? { replyToWireId: replyTo } : {}),
|
|
1512
1519
|
});
|
|
1513
1520
|
}
|
|
1514
1521
|
async sendAttachments(contact, outbox, replyTo) {
|
|
@@ -1516,11 +1523,11 @@ export class OwnerChannel {
|
|
|
1516
1523
|
.filter(entry => entry.isFile())
|
|
1517
1524
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1518
1525
|
for (const entry of entries) {
|
|
1519
|
-
await this.client.
|
|
1526
|
+
await this.client.sendFile({
|
|
1520
1527
|
contact,
|
|
1521
1528
|
path: join(outbox, entry.name),
|
|
1522
1529
|
filename: entry.name,
|
|
1523
|
-
|
|
1530
|
+
replyToWireId: replyTo,
|
|
1524
1531
|
});
|
|
1525
1532
|
}
|
|
1526
1533
|
await rm(outbox, { recursive: true, force: true });
|
|
@@ -1543,11 +1550,11 @@ export class OwnerChannel {
|
|
|
1543
1550
|
return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
|
|
1544
1551
|
}
|
|
1545
1552
|
sender(message) {
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
const id = String(source?.id ??
|
|
1550
|
-
return { id, name: String(source?.name ??
|
|
1553
|
+
// Authenticated routing data, straight from the daemon's typed envelope.
|
|
1554
|
+
// The id still goes through the CID checks in acceptedSender/isEffectiveOwner.
|
|
1555
|
+
const source = message.from;
|
|
1556
|
+
const id = String(source?.id ?? '');
|
|
1557
|
+
return { id, name: String(source?.name ?? id) };
|
|
1551
1558
|
}
|
|
1552
1559
|
latestEventSeq(events) {
|
|
1553
1560
|
return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { OursClient, type OursClientOptions } from '@ours.network/sdk/client';
|
|
2
|
+
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
3
|
+
export declare class OursDaemonError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The daemon accepted the call and answered "not sent". The MCP surface reported
|
|
7
|
+
* exactly these verdicts as tool errors, so they must keep throwing here: the
|
|
8
|
+
* owner channel books a resolved send as delivered, and a silently-swallowed
|
|
9
|
+
* refusal would be recorded as a delivered message that never left the host.
|
|
10
|
+
*/
|
|
11
|
+
export declare class OursSendRefusedError extends OursDaemonError {
|
|
12
|
+
}
|
|
13
|
+
type Res<M extends keyof OursClient> = OursClient[M] extends (...args: never[]) => infer R ? Awaited<R> : never;
|
|
14
|
+
export type OursContactsView = Res<'listContacts'>;
|
|
15
|
+
export type OursInviteResult = Res<'generateInvite'>;
|
|
16
|
+
export type OursAddContactResult = Res<'addContact'>;
|
|
17
|
+
export type OursMessagesPayload = Res<'getMessages'>;
|
|
18
|
+
export type OursInboundMessage = OursMessagesPayload['messages'][number];
|
|
19
|
+
export type OursIncomingFile = Res<'listIncomingFiles'>[number];
|
|
20
|
+
export type OursRetrievedFiles = Res<'getFiles'>;
|
|
21
|
+
export type OursRetrievedFile = OursRetrievedFiles['files'][number];
|
|
22
|
+
/**
|
|
23
|
+
* The daemon operations the owner channel needs, one typed method each.
|
|
24
|
+
*
|
|
25
|
+
* This interface deliberately has no generic `callTool(name, args): unknown`
|
|
26
|
+
* escape hatch. The MCP surface had one, and because ours-mcp answers every
|
|
27
|
+
* tool with `{content:[{type:'text',...}]}` and no `structuredContent`, the
|
|
28
|
+
* transport fell back to returning the daemon's English sentence — which the
|
|
29
|
+
* channel then pattern-matched (an invite blob sliced out of a prose sentence,
|
|
30
|
+
* a bind conflict detected with /currently bound to another live session/i).
|
|
31
|
+
* With no untyped result there is nothing left to pattern-match.
|
|
32
|
+
*/
|
|
33
|
+
export interface OursOps {
|
|
34
|
+
/** Prepare the transport. Must be called before any operation. */
|
|
35
|
+
start(): Promise<void>;
|
|
36
|
+
/** Bind this session's identity. Throws `BOUND_ELSEWHERE` when it is held live. */
|
|
37
|
+
bindIdentity(name: string): Promise<void>;
|
|
38
|
+
listContacts(): Promise<OursContactsView>;
|
|
39
|
+
generateInvite(name?: string): Promise<OursInviteResult>;
|
|
40
|
+
addContact(a: {
|
|
41
|
+
invite: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
}): Promise<OursAddContactResult>;
|
|
44
|
+
getMessages(): Promise<OursMessagesPayload>;
|
|
45
|
+
deferMessages(msgIds: number[]): Promise<void>;
|
|
46
|
+
listIncomingFiles(): Promise<OursIncomingFile[]>;
|
|
47
|
+
getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
|
|
48
|
+
/**
|
|
49
|
+
* The bytes of an already-retrieved file. Transport only — the caller owns
|
|
50
|
+
* where they land, so path safety stays with the attachment code that already
|
|
51
|
+
* enforces it (`writeRecoveredAttachment`).
|
|
52
|
+
*/
|
|
53
|
+
fetchFile(wireId: string): Promise<Uint8Array>;
|
|
54
|
+
sendMessage(a: {
|
|
55
|
+
contact: string;
|
|
56
|
+
text: string;
|
|
57
|
+
replyToWireId?: string;
|
|
58
|
+
}): Promise<void>;
|
|
59
|
+
sendFile(a: {
|
|
60
|
+
contact: string;
|
|
61
|
+
path: string;
|
|
62
|
+
filename: string;
|
|
63
|
+
replyToWireId?: string;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
/** Release the daemon lease and stop. Never throws. */
|
|
66
|
+
close(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The typed error code of a daemon operation, or undefined when the failure was
|
|
70
|
+
* not one (transport, abort, programming error). `instanceof` is checked first;
|
|
71
|
+
* the structural fallback keeps a duplicated SDK copy in a consumer's tree from
|
|
72
|
+
* silently demoting a real daemon verdict to "unknown transport failure".
|
|
73
|
+
*/
|
|
74
|
+
export declare function oursErrorCode(error: unknown): string | undefined;
|
|
75
|
+
/** The identity is bound by another live session; a predecessor may still be releasing it. */
|
|
76
|
+
export declare const OURS_BOUND_ELSEWHERE = "BOUND_ELSEWHERE";
|
|
77
|
+
export interface OursSdkClientDeps {
|
|
78
|
+
/** Test seam; production builds an `OursClient` from the resolved endpoint. */
|
|
79
|
+
createClient?(options: OursClientOptions): OursClient;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The owner-channel's daemon client: one `OursClient` over the local ours HTTP
|
|
83
|
+
* API, owning exactly one identity binding.
|
|
84
|
+
*
|
|
85
|
+
* Lease lifetime. The lease token IS the session, so each channel instance mints
|
|
86
|
+
* its own and hands it back in `close()`. That replaces the `ours-mcp proxy`
|
|
87
|
+
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
88
|
+
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
89
|
+
* release does that deterministically, and `clientPid` still covers the case
|
|
90
|
+
* where the whole supervisor dies without unwinding.
|
|
91
|
+
*/
|
|
92
|
+
export declare class OursSdkClient implements OursOps {
|
|
93
|
+
private readonly env;
|
|
94
|
+
private readonly log;
|
|
95
|
+
private readonly deps;
|
|
96
|
+
private client?;
|
|
97
|
+
private readonly leaseToken;
|
|
98
|
+
constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
|
|
99
|
+
start(): Promise<void>;
|
|
100
|
+
bindIdentity(name: string): Promise<void>;
|
|
101
|
+
listContacts(): Promise<OursContactsView>;
|
|
102
|
+
generateInvite(name?: string): Promise<OursInviteResult>;
|
|
103
|
+
addContact(a: {
|
|
104
|
+
invite: string;
|
|
105
|
+
name?: string;
|
|
106
|
+
}): Promise<OursAddContactResult>;
|
|
107
|
+
getMessages(): Promise<OursMessagesPayload>;
|
|
108
|
+
deferMessages(msgIds: number[]): Promise<void>;
|
|
109
|
+
listIncomingFiles(): Promise<OursIncomingFile[]>;
|
|
110
|
+
getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
|
|
111
|
+
fetchFile(wireId: string): Promise<Uint8Array>;
|
|
112
|
+
sendMessage(a: {
|
|
113
|
+
contact: string;
|
|
114
|
+
text: string;
|
|
115
|
+
replyToWireId?: string;
|
|
116
|
+
}): Promise<void>;
|
|
117
|
+
sendFile(a: {
|
|
118
|
+
contact: string;
|
|
119
|
+
path: string;
|
|
120
|
+
filename: string;
|
|
121
|
+
replyToWireId?: string;
|
|
122
|
+
}): Promise<void>;
|
|
123
|
+
close(): Promise<void>;
|
|
124
|
+
private ops;
|
|
125
|
+
}
|
|
126
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { OursClient, OursError } from '@ours.network/sdk/client';
|
|
3
|
+
import { resolveApiToken, resolveEndpoint } from '../monitor.js';
|
|
4
|
+
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
5
|
+
export class OursDaemonError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The daemon accepted the call and answered "not sent". The MCP surface reported
|
|
9
|
+
* exactly these verdicts as tool errors, so they must keep throwing here: the
|
|
10
|
+
* owner channel books a resolved send as delivered, and a silently-swallowed
|
|
11
|
+
* refusal would be recorded as a delivered message that never left the host.
|
|
12
|
+
*/
|
|
13
|
+
export class OursSendRefusedError extends OursDaemonError {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The typed error code of a daemon operation, or undefined when the failure was
|
|
17
|
+
* not one (transport, abort, programming error). `instanceof` is checked first;
|
|
18
|
+
* the structural fallback keeps a duplicated SDK copy in a consumer's tree from
|
|
19
|
+
* silently demoting a real daemon verdict to "unknown transport failure".
|
|
20
|
+
*/
|
|
21
|
+
export function oursErrorCode(error) {
|
|
22
|
+
if (error instanceof OursError)
|
|
23
|
+
return error.code;
|
|
24
|
+
if (error instanceof Error && error.name === 'OursError') {
|
|
25
|
+
const code = error.code;
|
|
26
|
+
if (typeof code === 'string')
|
|
27
|
+
return code;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The identity is bound by another live session; a predecessor may still be releasing it. */
|
|
32
|
+
export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
|
|
33
|
+
/**
|
|
34
|
+
* The owner-channel's daemon client: one `OursClient` over the local ours HTTP
|
|
35
|
+
* API, owning exactly one identity binding.
|
|
36
|
+
*
|
|
37
|
+
* Lease lifetime. The lease token IS the session, so each channel instance mints
|
|
38
|
+
* its own and hands it back in `close()`. That replaces the `ours-mcp proxy`
|
|
39
|
+
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
40
|
+
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
41
|
+
* release does that deterministically, and `clientPid` still covers the case
|
|
42
|
+
* where the whole supervisor dies without unwinding.
|
|
43
|
+
*/
|
|
44
|
+
export class OursSdkClient {
|
|
45
|
+
env;
|
|
46
|
+
log;
|
|
47
|
+
deps;
|
|
48
|
+
client;
|
|
49
|
+
leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
|
|
50
|
+
constructor(env = {}, log = () => undefined, deps = {}) {
|
|
51
|
+
this.env = env;
|
|
52
|
+
this.log = log;
|
|
53
|
+
this.deps = deps;
|
|
54
|
+
}
|
|
55
|
+
async start() {
|
|
56
|
+
if (this.client)
|
|
57
|
+
return;
|
|
58
|
+
const environment = { ...process.env, ...this.env };
|
|
59
|
+
// Reuse fleet's own daemon resolution so this client and the notification
|
|
60
|
+
// watch loop can never disagree about which daemon they are talking to.
|
|
61
|
+
const endpoint = resolveEndpoint(environment);
|
|
62
|
+
const apiToken = resolveApiToken(environment);
|
|
63
|
+
const options = {
|
|
64
|
+
url: endpoint.origin,
|
|
65
|
+
leaseToken: this.leaseToken,
|
|
66
|
+
clientPid: process.pid,
|
|
67
|
+
...(apiToken ? { apiToken } : {}),
|
|
68
|
+
};
|
|
69
|
+
this.client = this.deps.createClient?.(options) ?? new OursClient(options);
|
|
70
|
+
}
|
|
71
|
+
async bindIdentity(name) {
|
|
72
|
+
// force is pinned off: the owner channel never evicts another live session
|
|
73
|
+
// from an identity, it waits for the bounded handoff window and then fails.
|
|
74
|
+
await this.ops().chooseIdentity({ name, force: false });
|
|
75
|
+
}
|
|
76
|
+
async listContacts() {
|
|
77
|
+
return this.ops().listContacts();
|
|
78
|
+
}
|
|
79
|
+
async generateInvite(name) {
|
|
80
|
+
return this.ops().generateInvite(name ? { name } : {});
|
|
81
|
+
}
|
|
82
|
+
async addContact(a) {
|
|
83
|
+
return this.ops().addContact({ invite: a.invite, ...(a.name ? { name: a.name } : {}) });
|
|
84
|
+
}
|
|
85
|
+
async getMessages() {
|
|
86
|
+
return this.ops().getMessages();
|
|
87
|
+
}
|
|
88
|
+
async deferMessages(msgIds) {
|
|
89
|
+
await this.ops().deferMessages({ msg_ids: msgIds });
|
|
90
|
+
}
|
|
91
|
+
async listIncomingFiles() {
|
|
92
|
+
return this.ops().listIncomingFiles();
|
|
93
|
+
}
|
|
94
|
+
async getFiles(wireIds) {
|
|
95
|
+
return this.ops().getFiles({ wire_ids: wireIds });
|
|
96
|
+
}
|
|
97
|
+
async fetchFile(wireId) {
|
|
98
|
+
// save_file has no SDK operation on purpose: that daemon route only reports
|
|
99
|
+
// that a too-old connector reached it. The bytes of a retrieved file are
|
|
100
|
+
// already on disk, so read them back and let the caller write them as this
|
|
101
|
+
// process's own OS user.
|
|
102
|
+
return this.ops().fetchFile(wireId);
|
|
103
|
+
}
|
|
104
|
+
async sendMessage(a) {
|
|
105
|
+
const verdict = await this.ops().sendMessage({
|
|
106
|
+
contact: a.contact, text: a.text,
|
|
107
|
+
...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
|
|
108
|
+
});
|
|
109
|
+
// Parity with ours-mcp 0.16.0: only `refused` was a tool error. `migrating`,
|
|
110
|
+
// `deferred` and `e2e` are accepted-and-queued outcomes that it reported as
|
|
111
|
+
// success, so they must not become failures here.
|
|
112
|
+
if (verdict.kind === 'refused')
|
|
113
|
+
throw new OursSendRefusedError('the daemon refused the message: the contact\'s end-to-end session must be '
|
|
114
|
+
+ 're-established after an upgrade; it was not sent and not downgraded');
|
|
115
|
+
}
|
|
116
|
+
async sendFile(a) {
|
|
117
|
+
const verdict = await this.ops().sendFile({
|
|
118
|
+
contact: a.contact, path: a.path, filename: a.filename,
|
|
119
|
+
...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
|
|
120
|
+
});
|
|
121
|
+
// Parity with ours-mcp 0.16.0, which treated `migrating` as an error for
|
|
122
|
+
// files and as success for messages: files are not auto-queued behind a
|
|
123
|
+
// migration, so "queued" would be a false delivery claim.
|
|
124
|
+
if (verdict.kind === 'refused' || verdict.kind === 'migrating')
|
|
125
|
+
throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
|
|
126
|
+
+ 'session must be re-established after an upgrade; files are not queued');
|
|
127
|
+
}
|
|
128
|
+
async close() {
|
|
129
|
+
const client = this.client;
|
|
130
|
+
this.client = undefined;
|
|
131
|
+
if (!client)
|
|
132
|
+
return;
|
|
133
|
+
// Handing the lease back is what lets a successor bind this identity without
|
|
134
|
+
// waiting for the supervisor to exit. A failure here is not fatal — the
|
|
135
|
+
// daemon still reclaims the lease when this process dies.
|
|
136
|
+
try {
|
|
137
|
+
await client.releaseLease();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
this.log(`lease release failed: ${error?.message ?? String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
ops() {
|
|
144
|
+
if (!this.client)
|
|
145
|
+
throw new OursDaemonError('ours daemon client is not started');
|
|
146
|
+
return this.client;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { Launch } from './harness/types.js';
|
|
|
3
3
|
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
|
-
import type
|
|
6
|
+
import { type AcpSessionOptions } from './session/acp.js';
|
|
7
|
+
import { RoleControlServer } from './session/control.js';
|
|
8
|
+
import type { ExitRecord, SessionHandle, TurnResult } from './session/types.js';
|
|
7
9
|
import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
|
|
8
10
|
import { type OwnerBinderLease } from './owner-channel/binder.js';
|
|
9
11
|
export interface RunnerDeps {
|
|
@@ -20,6 +22,10 @@ export interface RunnerDeps {
|
|
|
20
22
|
createMonitor(opts: MonitorOpts): MonitorHandle;
|
|
21
23
|
/** Construct trusted owner ingress (injectable for lifecycle tests). */
|
|
22
24
|
createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
|
|
25
|
+
/** Start the ACP transport (injectable for deterministic runner lifecycle tests). */
|
|
26
|
+
startAcpSession(opts: AcpSessionOptions): Promise<SessionHandle>;
|
|
27
|
+
/** Construct the authenticated role control route (injectable where sockets are unavailable). */
|
|
28
|
+
createControlServer(stateDir: string, session: SessionHandle, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
|
|
23
29
|
/** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
|
|
24
30
|
acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
|
|
25
31
|
/** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
|
|
@@ -107,6 +113,8 @@ export interface AttemptResult {
|
|
|
107
113
|
export declare const TEMP_IDENTITY_CLOSE_DEBOUNCE_MS = 5000;
|
|
108
114
|
/** Lifecycle polling is deliberately slower than the 500ms stop-signal loop. */
|
|
109
115
|
export declare const TEMP_IDENTITY_POLL_MS = 2000;
|
|
116
|
+
/** Only authenticated wake interrupts may turn a temp startup cancellation into readiness. */
|
|
117
|
+
export declare function isRecoverableTempStartupCancellation(temp: boolean, result: TurnResult): boolean;
|
|
110
118
|
/** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
|
|
111
119
|
export declare function runOnce(name: string, opts?: {
|
|
112
120
|
temp?: boolean;
|
package/dist/runner.js
CHANGED
|
@@ -42,6 +42,8 @@ const defaultDeps = () => ({
|
|
|
42
42
|
fetch: (url, init) => globalThis.fetch(url, init),
|
|
43
43
|
createMonitor: opts => createMonitor(opts),
|
|
44
44
|
createOwnerChannel: opts => new OwnerChannel(opts),
|
|
45
|
+
startAcpSession: opts => AcpSession.start(opts),
|
|
46
|
+
createControlServer: (stateDir, session, log) => new RoleControlServer(stateDir, session, log),
|
|
45
47
|
acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
|
|
46
48
|
reportOwnerStartupFailure: async (stateDir) => {
|
|
47
49
|
const response = await controlRequest(stateDir, {
|
|
@@ -352,6 +354,12 @@ function resolveConfigPath(dir, explicit) {
|
|
|
352
354
|
export const TEMP_IDENTITY_CLOSE_DEBOUNCE_MS = 5_000;
|
|
353
355
|
/** Lifecycle polling is deliberately slower than the 500ms stop-signal loop. */
|
|
354
356
|
export const TEMP_IDENTITY_POLL_MS = 2_000;
|
|
357
|
+
/** Only authenticated wake interrupts may turn a temp startup cancellation into readiness. */
|
|
358
|
+
export function isRecoverableTempStartupCancellation(temp, result) {
|
|
359
|
+
return temp && result.accepted && result.outcome === 'cancelled'
|
|
360
|
+
&& (result.cancellationSource === 'local-console'
|
|
361
|
+
|| result.cancellationSource === 'fleet-monitor');
|
|
362
|
+
}
|
|
355
363
|
/** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
|
|
356
364
|
export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
357
365
|
const deps = { ...defaultDeps(), ...partialDeps };
|
|
@@ -512,7 +520,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
512
520
|
if (perms.unattended === 'deny')
|
|
513
521
|
deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
|
|
514
522
|
`permission requests are automatically denied once each (reject_once) and the turn continues`);
|
|
515
|
-
acpSession = await
|
|
523
|
+
acpSession = await deps.startAcpSession({
|
|
516
524
|
name,
|
|
517
525
|
argv: wrappedArgv,
|
|
518
526
|
cwd: runCwd,
|
|
@@ -555,7 +563,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
555
563
|
+ `${error?.message ?? String(error)}`);
|
|
556
564
|
}
|
|
557
565
|
}
|
|
558
|
-
control =
|
|
566
|
+
control = deps.createControlServer(dir, arbiter, deps.log);
|
|
559
567
|
try {
|
|
560
568
|
await control.start();
|
|
561
569
|
}
|
|
@@ -640,7 +648,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
640
648
|
// success, so there is neither a deaf gap nor a boot-cancellation loop.
|
|
641
649
|
monitorLoop = monitor?.run(pid);
|
|
642
650
|
const started = await starting;
|
|
643
|
-
|
|
651
|
+
// A temporary role's first turn can be the active turn when an ours wake
|
|
652
|
+
// needs immediate attention. A typed console/monitor cancellation ends
|
|
653
|
+
// only that turn: the already-live ACP session and any queued wake remain
|
|
654
|
+
// valid. Keep every unproven cancellation, refusal, shutdown, and genuine
|
|
655
|
+
// failure terminal so a role that never accepted its briefing is not
|
|
656
|
+
// silently reported as healthy.
|
|
657
|
+
const interruptedForWake = isRecoverableTempStartupCancellation(temp, started);
|
|
658
|
+
if (!started.succeeded && !interruptedForWake) {
|
|
644
659
|
monitor?.stop();
|
|
645
660
|
await control.close();
|
|
646
661
|
ownerBinder?.release();
|
|
@@ -664,6 +679,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
664
679
|
throw new Error(`[${name}] ACP startup prompt ${started.outcome}` +
|
|
665
680
|
`${started.detail ? `: ${started.detail}` : ''}`);
|
|
666
681
|
}
|
|
682
|
+
if (interruptedForWake)
|
|
683
|
+
deps.log(`[${name}] ACP startup prompt cancelled by ${started.cancellationSource}; `
|
|
684
|
+
+ 'keeping temporary supervisor alive');
|
|
667
685
|
acpStartupComplete = true;
|
|
668
686
|
if (role.owner_channel) {
|
|
669
687
|
ownerChannel = deps.createOwnerChannel({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0-nightly.1",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
39
39
|
"@fastify/static": "^10.1.2",
|
|
40
40
|
"@fastify/websocket": "^11.2.0",
|
|
41
|
+
"@ours.network/sdk": "1.3.1",
|
|
41
42
|
"@xterm/addon-fit": "0.10.0",
|
|
42
43
|
"@xterm/addon-serialize": "0.13.0",
|
|
43
44
|
"@xterm/headless": "5.5.0",
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
// ours-mcp normally records the long-lived client PID so an identity lease
|
|
23
|
-
// survives connector churn. An owner-channel connector has the opposite
|
|
24
|
-
// lifecycle: each supervised attempt owns a fresh connector and must make
|
|
25
|
-
// its lease reclaimable when that connector exits, even though the fleet
|
|
26
|
-
// supervisor itself remains alive. POSIX exec preserves the shell PID as
|
|
27
|
-
// the proxy PID, giving the daemon an exact process-lifetime fence without
|
|
28
|
-
// interpolating the command path into shell text.
|
|
29
|
-
const child = spawn('/bin/sh', [
|
|
30
|
-
'-c', 'OURS_CLIENT_PID=$$; export OURS_CLIENT_PID; exec "$1" "$2"',
|
|
31
|
-
'ours-fleet-owner-proxy', this.command, 'proxy',
|
|
32
|
-
], {
|
|
33
|
-
env: {
|
|
34
|
-
...process.env,
|
|
35
|
-
...this.env,
|
|
36
|
-
// Bindings are keyed by this value. Sharing it would silently rebind a
|
|
37
|
-
// role's normal mailbox or another owner channel.
|
|
38
|
-
CLAUDE_CODE_SESSION_ID: `ours-fleet-owner-${process.pid}-${randomUUID()}`,
|
|
39
|
-
},
|
|
40
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
|
-
});
|
|
42
|
-
await new Promise((resolve, reject) => {
|
|
43
|
-
child.once('spawn', resolve);
|
|
44
|
-
child.once('error', reject);
|
|
45
|
-
});
|
|
46
|
-
this.child = child;
|
|
47
|
-
child.once('exit', (code, signal) => {
|
|
48
|
-
this.log(`ours-mcp proxy launcher exited (${code ?? signal ?? 'unknown'})`);
|
|
49
|
-
});
|
|
50
|
-
child.stdin.on('error', error => this.log(`ours-mcp stdin: ${error.message}`));
|
|
51
|
-
createInterface({ input: child.stderr }).on('line', line => this.log(`ours-mcp: ${line}`));
|
|
52
|
-
try {
|
|
53
|
-
await this.request('initialize', {
|
|
54
|
-
protocolVersion: '2025-03-26', capabilities: {},
|
|
55
|
-
clientInfo: { name: 'ours-fleet-owner-channel', version: '1' },
|
|
56
|
-
});
|
|
57
|
-
await this.notify('notifications/initialized', {});
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
await this.close();
|
|
61
|
-
throw error;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
async callTool(name, args = {}) {
|
|
65
|
-
const result = await this.request('tools/call', { name, arguments: args });
|
|
66
|
-
const text = (result.content ?? [])
|
|
67
|
-
.filter(item => item.type === 'text').map(item => item.text ?? '').join('\n').trim();
|
|
68
|
-
if (result.isError)
|
|
69
|
-
throw new OursMcpError(text || `ours tool ${name} failed`);
|
|
70
|
-
if (result.structuredContent !== undefined)
|
|
71
|
-
return result.structuredContent;
|
|
72
|
-
if (!text)
|
|
73
|
-
return {};
|
|
74
|
-
try {
|
|
75
|
-
return JSON.parse(text);
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return text;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
async close() {
|
|
82
|
-
const child = this.child;
|
|
83
|
-
this.child = undefined;
|
|
84
|
-
if (!child || child.exitCode !== null)
|
|
85
|
-
return;
|
|
86
|
-
// EOF asks the proxy to close normally. Once this exact process exits, the
|
|
87
|
-
// daemon can reclaim its lease even while the supervisor stays alive.
|
|
88
|
-
child.stdin.end();
|
|
89
|
-
const exited = await new Promise(resolve => {
|
|
90
|
-
const timer = setTimeout(() => resolve(false), 1_000);
|
|
91
|
-
child.once('exit', () => { clearTimeout(timer); resolve(true); });
|
|
92
|
-
});
|
|
93
|
-
if (exited || child.exitCode !== null)
|
|
94
|
-
return;
|
|
95
|
-
child.kill('SIGTERM');
|
|
96
|
-
await new Promise(resolve => {
|
|
97
|
-
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 5_000);
|
|
98
|
-
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
request(method, params) {
|
|
102
|
-
const run = this.tail.then(() => this.requestNow(method, params));
|
|
103
|
-
this.tail = run.then(() => undefined, () => undefined);
|
|
104
|
-
return run;
|
|
105
|
-
}
|
|
106
|
-
async requestNow(method, params) {
|
|
107
|
-
const child = this.child;
|
|
108
|
-
if (!child || child.exitCode !== null)
|
|
109
|
-
throw new OursMcpError('ours-mcp proxy is not running');
|
|
110
|
-
const id = ++this.nextId;
|
|
111
|
-
await this.write(child, { jsonrpc: '2.0', id, method, params });
|
|
112
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
113
|
-
try {
|
|
114
|
-
for await (const line of lines) {
|
|
115
|
-
let response;
|
|
116
|
-
try {
|
|
117
|
-
response = JSON.parse(line);
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (response.id !== id)
|
|
123
|
-
continue;
|
|
124
|
-
if (response.error !== undefined)
|
|
125
|
-
throw new OursMcpError(JSON.stringify(response.error));
|
|
126
|
-
return response.result ?? {};
|
|
127
|
-
}
|
|
128
|
-
throw new OursMcpError('ours-mcp proxy closed its output');
|
|
129
|
-
}
|
|
130
|
-
finally {
|
|
131
|
-
lines.close();
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
async notify(method, params) {
|
|
135
|
-
const child = this.child;
|
|
136
|
-
if (!child || child.exitCode !== null)
|
|
137
|
-
throw new OursMcpError('ours-mcp proxy is not running');
|
|
138
|
-
await this.write(child, { jsonrpc: '2.0', method, params });
|
|
139
|
-
}
|
|
140
|
-
write(child, value) {
|
|
141
|
-
return new Promise((resolve, reject) => {
|
|
142
|
-
child.stdin.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve());
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|