@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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { OursClient } from '@ours.network/sdk/client';
|
|
2
|
+
import { RuntimeJournal, type RuntimeState } from './state.js';
|
|
3
|
+
export interface AssignedIdentity {
|
|
4
|
+
instance: string;
|
|
5
|
+
generation: number;
|
|
6
|
+
daemon: string;
|
|
7
|
+
name: string;
|
|
8
|
+
lifetime: 'permanent' | 'temporary';
|
|
9
|
+
action: string;
|
|
10
|
+
expectedCid?: string;
|
|
11
|
+
allowCreate: boolean;
|
|
12
|
+
bio: string;
|
|
13
|
+
}
|
|
14
|
+
export interface RoomAdmission {
|
|
15
|
+
id: string;
|
|
16
|
+
cid: string;
|
|
17
|
+
seat: string;
|
|
18
|
+
action: string;
|
|
19
|
+
/** The secret stays in trusted storage and is never returned to the child. */
|
|
20
|
+
redeem(client: OursClient): Promise<{
|
|
21
|
+
cid: string;
|
|
22
|
+
}>;
|
|
23
|
+
observe(agentCid: string): Promise<'pending' | 'established' | 'mismatch'>;
|
|
24
|
+
discardSecret(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export interface RuntimeDependencies {
|
|
27
|
+
journal: RuntimeJournal;
|
|
28
|
+
/** Client is already attached to the pinned daemon with its durable external owner. */
|
|
29
|
+
client: OursClient;
|
|
30
|
+
/** Checked before every operation; tied to the cross-process controller lock. */
|
|
31
|
+
assertFence(): void;
|
|
32
|
+
sleep(ms: number): Promise<void>;
|
|
33
|
+
now(): number;
|
|
34
|
+
}
|
|
35
|
+
/** A bridge never owns this object or its client; EOF never releases its lease. */
|
|
36
|
+
export declare class AgentOursRuntime {
|
|
37
|
+
readonly assignment: AssignedIdentity;
|
|
38
|
+
private readonly deps;
|
|
39
|
+
private inFlight;
|
|
40
|
+
private drain?;
|
|
41
|
+
private accepting;
|
|
42
|
+
private state;
|
|
43
|
+
constructor(assignment: AssignedIdentity, deps: RuntimeDependencies);
|
|
44
|
+
get snapshot(): Readonly<RuntimeState>;
|
|
45
|
+
private transition;
|
|
46
|
+
private verify;
|
|
47
|
+
prepare(room?: RoomAdmission, timeoutMs?: number): Promise<void>;
|
|
48
|
+
/** Admission covers SDK calls, response rendering and agent filesystem callbacks. */
|
|
49
|
+
admit(): Promise<() => void>;
|
|
50
|
+
startHarness<T>(start: () => Promise<T>): Promise<T>;
|
|
51
|
+
suspend(): Promise<void>;
|
|
52
|
+
terminal(): Promise<void>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/** A bridge never owns this object or its client; EOF never releases its lease. */
|
|
2
|
+
export class AgentOursRuntime {
|
|
3
|
+
assignment;
|
|
4
|
+
deps;
|
|
5
|
+
inFlight = 0;
|
|
6
|
+
drain;
|
|
7
|
+
accepting = false;
|
|
8
|
+
state;
|
|
9
|
+
constructor(assignment, deps) {
|
|
10
|
+
this.assignment = assignment;
|
|
11
|
+
this.deps = deps;
|
|
12
|
+
const existing = deps.journal.read();
|
|
13
|
+
if (existing) {
|
|
14
|
+
if (existing.instance !== assignment.instance ||
|
|
15
|
+
existing.daemon !== assignment.daemon ||
|
|
16
|
+
existing.name !== assignment.name ||
|
|
17
|
+
existing.lifetime !== assignment.lifetime ||
|
|
18
|
+
existing.generation !== assignment.generation ||
|
|
19
|
+
existing.action !== assignment.action ||
|
|
20
|
+
(assignment.expectedCid !== undefined &&
|
|
21
|
+
existing.cid !== undefined &&
|
|
22
|
+
existing.cid !== assignment.expectedCid))
|
|
23
|
+
throw Error('RUNTIME_ASSIGNMENT_MISMATCH');
|
|
24
|
+
this.state = existing;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
// Deliberately serialize only the public ownership facts, never credentials.
|
|
28
|
+
const { instance, generation, daemon, name, lifetime, action } = assignment;
|
|
29
|
+
this.state = {
|
|
30
|
+
version: 1,
|
|
31
|
+
instance,
|
|
32
|
+
generation,
|
|
33
|
+
daemon,
|
|
34
|
+
name,
|
|
35
|
+
lifetime,
|
|
36
|
+
action,
|
|
37
|
+
phase: 'PREPARING',
|
|
38
|
+
revision: 0,
|
|
39
|
+
updatedAt: '',
|
|
40
|
+
};
|
|
41
|
+
deps.assertFence();
|
|
42
|
+
deps.journal.commit(this.state);
|
|
43
|
+
this.state = deps.journal.read();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
get snapshot() {
|
|
47
|
+
return structuredClone(this.state);
|
|
48
|
+
}
|
|
49
|
+
transition(phase, patch = {}) {
|
|
50
|
+
this.deps.assertFence();
|
|
51
|
+
this.deps.journal.commit({ ...this.state, ...patch, phase }, this.state.revision);
|
|
52
|
+
this.state = this.deps.journal.read();
|
|
53
|
+
}
|
|
54
|
+
async verify() {
|
|
55
|
+
this.deps.assertFence();
|
|
56
|
+
if (this.assignment.expectedCid && this.state.cid !== this.assignment.expectedCid)
|
|
57
|
+
throw Error('PINNED_CID_MISMATCH');
|
|
58
|
+
const actual = await this.deps.client.currentIdentity();
|
|
59
|
+
if (actual.cid !== this.state.cid ||
|
|
60
|
+
actual.name !== this.state.name ||
|
|
61
|
+
actual.temporary !== (this.state.lifetime === 'temporary') ||
|
|
62
|
+
actual.isRoot)
|
|
63
|
+
throw Error('IDENTITY_PROOF_MISMATCH');
|
|
64
|
+
this.deps.assertFence();
|
|
65
|
+
}
|
|
66
|
+
async prepare(room, timeoutMs = 30_000) {
|
|
67
|
+
if (['TERMINAL_INTENT', 'CLEANUP_PENDING', 'RELEASED', 'FAILED', 'QUIESCING'].includes(this.state.phase))
|
|
68
|
+
throw Error('RUNTIME_NOT_RESUMABLE');
|
|
69
|
+
this.deps.assertFence();
|
|
70
|
+
if (!this.state.cid) {
|
|
71
|
+
// An existing PREPARING journal is an uncertain create, never evidence of absence.
|
|
72
|
+
if (this.state.phase === 'RECOVERING')
|
|
73
|
+
throw Error('UNCERTAIN_PROVISIONING');
|
|
74
|
+
const rows = await this.deps.client.listIdentities();
|
|
75
|
+
const row = rows.find((r) => r.name === this.state.name);
|
|
76
|
+
if (row) {
|
|
77
|
+
if (!('cid' in row) ||
|
|
78
|
+
this.state.lifetime === 'temporary' ||
|
|
79
|
+
!this.assignment.expectedCid ||
|
|
80
|
+
row.cid !== this.assignment.expectedCid ||
|
|
81
|
+
row.kind === 'root' ||
|
|
82
|
+
row.temp)
|
|
83
|
+
throw Error('IDENTITY_COLLISION');
|
|
84
|
+
this.transition('RECOVERING');
|
|
85
|
+
await this.deps.client.chooseIdentity({ name: this.state.name, force: false });
|
|
86
|
+
this.transition('OWNED', { cid: row.cid });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
if (!this.assignment.allowCreate || this.assignment.expectedCid)
|
|
90
|
+
throw Error('IDENTITY_ABSENT');
|
|
91
|
+
// Persist uncertainty before the non-idempotent call. Crash recovery requires owner proof.
|
|
92
|
+
this.transition('RECOVERING');
|
|
93
|
+
const args = {
|
|
94
|
+
name: this.state.name,
|
|
95
|
+
bio: this.assignment.bio,
|
|
96
|
+
exposeLocal: false,
|
|
97
|
+
localAutoAccept: true,
|
|
98
|
+
};
|
|
99
|
+
const created = this.state.lifetime === 'temporary'
|
|
100
|
+
? await this.deps.client.createTemporaryIdentity(args)
|
|
101
|
+
: await this.deps.client.createIdentity(args);
|
|
102
|
+
if (created.hierarchy !== 'role')
|
|
103
|
+
throw Error('ROOT_PROVISIONING_FORBIDDEN');
|
|
104
|
+
this.transition('OWNED', { cid: created.info.cid });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await this.verify();
|
|
108
|
+
if (!room && (this.state.room || this.state.admissionIntent))
|
|
109
|
+
throw Error('ROOM_DESCRIPTOR_REQUIRED');
|
|
110
|
+
if (room) {
|
|
111
|
+
if (this.state.room &&
|
|
112
|
+
(this.state.room.id !== room.id ||
|
|
113
|
+
this.state.room.cid !== room.cid ||
|
|
114
|
+
this.state.room.seat !== room.seat ||
|
|
115
|
+
this.state.room.agentCid !== this.state.cid ||
|
|
116
|
+
this.state.room.action !== room.action))
|
|
117
|
+
throw Error('ROOM_ASSIGNMENT_MISMATCH');
|
|
118
|
+
const intent = this.state.admissionIntent;
|
|
119
|
+
if (intent &&
|
|
120
|
+
(intent.id !== room.id ||
|
|
121
|
+
intent.cid !== room.cid ||
|
|
122
|
+
intent.seat !== room.seat ||
|
|
123
|
+
intent.action !== room.action ||
|
|
124
|
+
intent.agentCid !== this.state.cid))
|
|
125
|
+
throw Error('ROOM_INTENT_MISMATCH');
|
|
126
|
+
if (!this.state.room && !intent) {
|
|
127
|
+
// A permanent identity may already occupy its seat after a clean supervisor stop.
|
|
128
|
+
const existing = await room.observe(this.state.cid);
|
|
129
|
+
if (existing === 'mismatch') {
|
|
130
|
+
this.transition('FAILED');
|
|
131
|
+
throw Error('WRONG_ROOM_SEAT');
|
|
132
|
+
}
|
|
133
|
+
this.transition('ROOM_PENDING', {
|
|
134
|
+
admissionIntent: {
|
|
135
|
+
id: room.id,
|
|
136
|
+
cid: room.cid,
|
|
137
|
+
seat: room.seat,
|
|
138
|
+
action: room.action,
|
|
139
|
+
agentCid: this.state.cid,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
if (existing !== 'established') {
|
|
143
|
+
const result = await room.redeem(this.deps.client);
|
|
144
|
+
if (result.cid !== room.cid) {
|
|
145
|
+
this.transition('FAILED');
|
|
146
|
+
throw Error('WRONG_ROOM_CID');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const deadline = this.deps.now() + timeoutMs;
|
|
151
|
+
for (;;) {
|
|
152
|
+
this.deps.assertFence();
|
|
153
|
+
const observation = await room.observe(this.state.cid);
|
|
154
|
+
if (observation === 'mismatch') {
|
|
155
|
+
this.transition('FAILED');
|
|
156
|
+
throw Error('WRONG_ROOM_SEAT');
|
|
157
|
+
}
|
|
158
|
+
if (observation === 'established')
|
|
159
|
+
break;
|
|
160
|
+
if (this.deps.now() >= deadline)
|
|
161
|
+
throw Error('ROOM_PENDING');
|
|
162
|
+
await this.deps.sleep(Math.min(250, deadline - this.deps.now()));
|
|
163
|
+
}
|
|
164
|
+
this.transition('READY', {
|
|
165
|
+
room: {
|
|
166
|
+
id: room.id,
|
|
167
|
+
cid: room.cid,
|
|
168
|
+
seat: room.seat,
|
|
169
|
+
agentCid: this.state.cid,
|
|
170
|
+
action: room.action,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
await room.discardSecret();
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
this.transition('READY');
|
|
177
|
+
this.accepting = true;
|
|
178
|
+
}
|
|
179
|
+
/** Admission covers SDK calls, response rendering and agent filesystem callbacks. */
|
|
180
|
+
async admit() {
|
|
181
|
+
this.deps.assertFence();
|
|
182
|
+
if (!this.accepting || !['READY', 'SERVING'].includes(this.state.phase))
|
|
183
|
+
throw Error('RUNTIME_NOT_READY');
|
|
184
|
+
this.inFlight++;
|
|
185
|
+
let released = false;
|
|
186
|
+
const release = () => {
|
|
187
|
+
if (released)
|
|
188
|
+
return;
|
|
189
|
+
released = true;
|
|
190
|
+
if (--this.inFlight === 0)
|
|
191
|
+
this.drain?.();
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
await this.verify();
|
|
195
|
+
if (!this.accepting)
|
|
196
|
+
throw Error('RUNTIME_QUIESCING');
|
|
197
|
+
return release;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
release();
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async startHarness(start) {
|
|
205
|
+
if (!this.accepting || this.state.phase !== 'READY')
|
|
206
|
+
throw Error('RUNTIME_NOT_READY');
|
|
207
|
+
await this.verify();
|
|
208
|
+
this.transition('SERVING');
|
|
209
|
+
return start();
|
|
210
|
+
}
|
|
211
|
+
async suspend() {
|
|
212
|
+
this.accepting = false;
|
|
213
|
+
if (this.inFlight)
|
|
214
|
+
await new Promise((resolve) => {
|
|
215
|
+
this.drain = resolve;
|
|
216
|
+
});
|
|
217
|
+
this.transition('RECOVERING');
|
|
218
|
+
}
|
|
219
|
+
async terminal() {
|
|
220
|
+
this.accepting = false;
|
|
221
|
+
if (this.state.phase === 'RELEASED')
|
|
222
|
+
return;
|
|
223
|
+
if (!['TERMINAL_INTENT', 'CLEANUP_PENDING'].includes(this.state.phase))
|
|
224
|
+
this.transition('QUIESCING');
|
|
225
|
+
if (this.inFlight)
|
|
226
|
+
await new Promise((resolve) => {
|
|
227
|
+
this.drain = resolve;
|
|
228
|
+
});
|
|
229
|
+
this.transition('TERMINAL_INTENT');
|
|
230
|
+
try {
|
|
231
|
+
this.deps.assertFence();
|
|
232
|
+
const ack = await this.deps.client.releaseLease();
|
|
233
|
+
if (!ack ||
|
|
234
|
+
!Array.isArray(ack.released) ||
|
|
235
|
+
!Array.isArray(ack.closed) ||
|
|
236
|
+
[...ack.released, ...ack.closed].some((name) => typeof name !== 'string') ||
|
|
237
|
+
[ack.attempted, ack.notified, ack.failed].some((n) => !Number.isSafeInteger(n) || n < 0) ||
|
|
238
|
+
ack.notified + ack.failed !== ack.attempted)
|
|
239
|
+
throw Error('INVALID_RELEASE_ACK');
|
|
240
|
+
this.transition('RELEASED', { releaseAck: ack });
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
this.transition('CLEANUP_PENDING');
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ResolvedRole } from '../config.js';
|
|
2
|
+
import { AgentOursRuntime } from './runtime.js';
|
|
3
|
+
export declare const privateRuntimeRoot: () => string;
|
|
4
|
+
export interface ManagedAgentService {
|
|
5
|
+
runtime: AgentOursRuntime;
|
|
6
|
+
descriptor: string;
|
|
7
|
+
privatePaths: string[];
|
|
8
|
+
close(terminal: boolean): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/** Only trusted launch orchestration may write this descriptor, never the child. */
|
|
11
|
+
export declare function storeRoomSecret(role: ResolvedRole): void;
|
|
12
|
+
export declare function storeTemporaryLaunch(role: ResolvedRole, action: string): void;
|
|
13
|
+
export declare function prepareManagedAgent(role: ResolvedRole, stateDir: string, temporary: boolean): Promise<ManagedAgentService>;
|
|
14
|
+
/** Explicit trusted inventory migration: read and pin existing permanent CID, never bind. */
|
|
15
|
+
export declare function preparePermanentAssignment(role: ResolvedRole): Promise<'verified' | 'unverified'>;
|
|
16
|
+
export declare function readRoomReadiness(roomCid: string, name: string): {
|
|
17
|
+
room: string;
|
|
18
|
+
invite: string;
|
|
19
|
+
cid: string;
|
|
20
|
+
generation: number;
|
|
21
|
+
} | undefined;
|
|
22
|
+
/** Explicit logical-instance termination, independent of harness/bridge transport. */
|
|
23
|
+
export declare function releaseManagedAgent(role: ResolvedRole): Promise<void>;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
5
|
+
import { ApplicationIdentityStore } from '@ours.network/mcp/application-identities';
|
|
6
|
+
import { readClientProfile } from '../client-profile.js';
|
|
7
|
+
import { stateRoot } from '../paths.js';
|
|
8
|
+
import { createCoworkAdapter } from '../rooms-tasks/cowork-adapter.js';
|
|
9
|
+
import { RuntimeController } from './controller.js';
|
|
10
|
+
import { atomicPrivateWrite, binderKey } from './state.js';
|
|
11
|
+
import { AgentOursRuntime } from './runtime.js';
|
|
12
|
+
import { startMcpEndpoint } from './mcp-endpoint.js';
|
|
13
|
+
export const privateRuntimeRoot = () => join(stateRoot(), 'private-ours');
|
|
14
|
+
/** Only trusted launch orchestration may write this descriptor, never the child. */
|
|
15
|
+
export function storeRoomSecret(role) {
|
|
16
|
+
const startup = role.roomMemberStartup;
|
|
17
|
+
if (!startup?.invite)
|
|
18
|
+
return;
|
|
19
|
+
const root = join(privateRuntimeRoot(), 'room-inputs');
|
|
20
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
21
|
+
const path = join(root, binderKey(startup.room_identity_cid, role.name) + '.json');
|
|
22
|
+
if (existsSync(path)) {
|
|
23
|
+
const old = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
if (old.room_id !== startup.room_id ||
|
|
25
|
+
old.invite_id !== startup.invite_id ||
|
|
26
|
+
old.identity_name !== startup.identity_name)
|
|
27
|
+
throw Error('ROOM_SECRET_COLLISION');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
atomicPrivateWrite(path, startup);
|
|
31
|
+
}
|
|
32
|
+
export function storeTemporaryLaunch(role, action) {
|
|
33
|
+
const root = join(privateRuntimeRoot(), 'launches');
|
|
34
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
35
|
+
atomicPrivateWrite(join(root, binderKey('temporary', role.name) + '.json'), {
|
|
36
|
+
role: role.name,
|
|
37
|
+
identity: role.identity,
|
|
38
|
+
action,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
42
|
+
if (role.owner_channel?.identity === role.identity)
|
|
43
|
+
throw Error('OWNER_AGENT_IDENTITY_COLLISION');
|
|
44
|
+
const profile = readClientProfile({ ...process.env, ...role.env });
|
|
45
|
+
if (!profile)
|
|
46
|
+
throw Error('MANAGED_OURS_REQUIRES_EXPLICIT_DAEMON_PROFILE');
|
|
47
|
+
const root = privateRuntimeRoot(), key = binderKey(profile.expectedInstanceId, role.identity), dir = join(root, key);
|
|
48
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
49
|
+
const instancePath = join(dir, 'instance.json');
|
|
50
|
+
const launch = temporary
|
|
51
|
+
? JSON.parse(readFileSync(join(root, 'launches', binderKey('temporary', role.name) + '.json'), 'utf8'))
|
|
52
|
+
: undefined;
|
|
53
|
+
if (launch &&
|
|
54
|
+
(launch.role !== role.name ||
|
|
55
|
+
launch.identity !== role.identity ||
|
|
56
|
+
typeof launch.action !== 'string'))
|
|
57
|
+
throw Error('INVALID_TEMP_LAUNCH');
|
|
58
|
+
let instance;
|
|
59
|
+
if (existsSync(instancePath)) {
|
|
60
|
+
const old = JSON.parse(readFileSync(instancePath, 'utf8'));
|
|
61
|
+
if (old.role !== role.name || old.temporary !== temporary)
|
|
62
|
+
throw Error('IDENTITY_ASSIGNED_TO_OTHER_ROLE');
|
|
63
|
+
instance = old.instance;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
instance = randomUUID();
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(instancePath, JSON.stringify({ instance, role: role.name, temporary }), {
|
|
69
|
+
flag: 'wx',
|
|
70
|
+
mode: 0o600,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== 'EEXIST')
|
|
75
|
+
throw error;
|
|
76
|
+
const old = JSON.parse(readFileSync(instancePath, 'utf8'));
|
|
77
|
+
if (old.role !== role.name || old.temporary !== temporary)
|
|
78
|
+
throw Error('IDENTITY_ASSIGNED_TO_OTHER_ROLE');
|
|
79
|
+
instance = old.instance;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance);
|
|
83
|
+
let closeClient;
|
|
84
|
+
let partialEndpoint;
|
|
85
|
+
try {
|
|
86
|
+
let prior = controller.journal.read();
|
|
87
|
+
if (prior?.phase === 'RELEASED') {
|
|
88
|
+
if (temporary && launch.action === prior.action)
|
|
89
|
+
throw Error('TERMINAL_TEMP_REQUIRES_NEW_LAUNCH');
|
|
90
|
+
instance = controller.successor();
|
|
91
|
+
atomicPrivateWrite(instancePath, { instance, role: role.name, temporary });
|
|
92
|
+
if (temporary && existsSync(join(dir, 'identity-pin.json')))
|
|
93
|
+
unlinkSync(join(dir, 'identity-pin.json'));
|
|
94
|
+
prior = undefined;
|
|
95
|
+
}
|
|
96
|
+
const generation = prior ? controller.resumeGeneration() : 1;
|
|
97
|
+
const tokenPath = join(dir, 'owner.json');
|
|
98
|
+
let token;
|
|
99
|
+
if (existsSync(tokenPath)) {
|
|
100
|
+
const owner = JSON.parse(readFileSync(tokenPath, 'utf8'));
|
|
101
|
+
if (owner.instance !== instance || typeof owner.token !== 'string')
|
|
102
|
+
throw Error('OWNER_RECORD_MISMATCH');
|
|
103
|
+
token = owner.token;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
if (prior)
|
|
107
|
+
throw Error('OWNER_RECORD_MISSING');
|
|
108
|
+
token = randomBytes(32).toString('hex');
|
|
109
|
+
atomicPrivateWrite(tokenPath, { instance, token });
|
|
110
|
+
}
|
|
111
|
+
const client = await attachOursClient({
|
|
112
|
+
endpoint: profile.endpoint,
|
|
113
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
114
|
+
credentialPath: profile.credentialPath,
|
|
115
|
+
sessionMode: 'external',
|
|
116
|
+
leaseToken: token,
|
|
117
|
+
requiredCapabilities: ['external-sessions-v1', 'root-first-identities-v1'],
|
|
118
|
+
});
|
|
119
|
+
closeClient = () => client.close();
|
|
120
|
+
const pinPath = join(dir, 'identity-pin.json');
|
|
121
|
+
const pin = existsSync(pinPath) ? JSON.parse(readFileSync(pinPath, 'utf8')) : undefined;
|
|
122
|
+
if (pin &&
|
|
123
|
+
(pin.daemon !== profile.expectedInstanceId ||
|
|
124
|
+
pin.name !== role.identity ||
|
|
125
|
+
typeof pin.cid !== 'string'))
|
|
126
|
+
throw Error('INVALID_IDENTITY_PIN');
|
|
127
|
+
const runtime = new AgentOursRuntime({
|
|
128
|
+
instance,
|
|
129
|
+
generation,
|
|
130
|
+
daemon: profile.expectedInstanceId,
|
|
131
|
+
name: role.identity,
|
|
132
|
+
lifetime: temporary ? 'temporary' : 'permanent',
|
|
133
|
+
action: launch?.action ?? prior?.action ?? randomUUID(),
|
|
134
|
+
expectedCid: pin?.cid,
|
|
135
|
+
allowCreate: true,
|
|
136
|
+
bio: role.bio ?? '',
|
|
137
|
+
}, {
|
|
138
|
+
client,
|
|
139
|
+
journal: controller.journal,
|
|
140
|
+
assertFence: controller.assertFence,
|
|
141
|
+
now: Date.now,
|
|
142
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
143
|
+
});
|
|
144
|
+
let room;
|
|
145
|
+
if (role.roomMemberStartup) {
|
|
146
|
+
const startup = role.roomMemberStartup;
|
|
147
|
+
const path = join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.json');
|
|
148
|
+
const cowork = createCoworkAdapter();
|
|
149
|
+
room = {
|
|
150
|
+
id: startup.room_id,
|
|
151
|
+
cid: startup.room_identity_cid,
|
|
152
|
+
seat: startup.invite_id,
|
|
153
|
+
action: startup.invite_id,
|
|
154
|
+
redeem: async (attached) => {
|
|
155
|
+
const secret = JSON.parse(readFileSync(path, 'utf8'));
|
|
156
|
+
if (secret.room_id !== startup.room_id ||
|
|
157
|
+
secret.invite_id !== startup.invite_id ||
|
|
158
|
+
secret.identity_name !== role.identity)
|
|
159
|
+
throw Error('ROOM_SECRET_MISMATCH');
|
|
160
|
+
return attached.addContact({ invite: secret.invite });
|
|
161
|
+
},
|
|
162
|
+
observe: async (agentCid) => {
|
|
163
|
+
const [contacts, observed] = await Promise.all([
|
|
164
|
+
client.listContacts(),
|
|
165
|
+
cowork.getRoom(startup.room_id),
|
|
166
|
+
]);
|
|
167
|
+
if (!observed || observed.identity_cid !== startup.room_identity_cid)
|
|
168
|
+
return 'mismatch';
|
|
169
|
+
const seat = observed.seats.find((s) => s.invite_id === startup.invite_id);
|
|
170
|
+
if (seat && (seat.identity_cid !== agentCid || seat.role !== startup.role))
|
|
171
|
+
return 'mismatch';
|
|
172
|
+
return seat?.seat_state === 'active' &&
|
|
173
|
+
contacts.contacts.some((c) => c.container_id === startup.room_identity_cid)
|
|
174
|
+
? 'established'
|
|
175
|
+
: 'pending';
|
|
176
|
+
},
|
|
177
|
+
discardSecret: async () => {
|
|
178
|
+
if (existsSync(path))
|
|
179
|
+
unlinkSync(path);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
await runtime.prepare(room);
|
|
184
|
+
if (role.roomMemberStartup) {
|
|
185
|
+
const startup = role.roomMemberStartup;
|
|
186
|
+
atomicPrivateWrite(join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.ready.json'), { room: startup.room_id, invite: startup.invite_id, cid: runtime.snapshot.cid, generation });
|
|
187
|
+
}
|
|
188
|
+
if (!pin)
|
|
189
|
+
atomicPrivateWrite(pinPath, {
|
|
190
|
+
daemon: profile.expectedInstanceId,
|
|
191
|
+
name: role.identity,
|
|
192
|
+
cid: runtime.snapshot.cid,
|
|
193
|
+
});
|
|
194
|
+
const identities = new ApplicationIdentityStore({ instanceId: profile.expectedInstanceId }, { path: join(dir, 'application.json') });
|
|
195
|
+
await identities.add(role.identity);
|
|
196
|
+
const bridgeDir = join(stateDir, '.ours-bridge');
|
|
197
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
198
|
+
const capability = randomBytes(32).toString('hex'), socket = join(bridgeDir, `g${generation}.sock`);
|
|
199
|
+
const endpoint = await startMcpEndpoint({
|
|
200
|
+
socket,
|
|
201
|
+
capability,
|
|
202
|
+
generation,
|
|
203
|
+
runtime,
|
|
204
|
+
client,
|
|
205
|
+
identities,
|
|
206
|
+
remoteDaemonFiles: true,
|
|
207
|
+
});
|
|
208
|
+
partialEndpoint = endpoint;
|
|
209
|
+
const descriptor = join(bridgeDir, 'descriptor.json');
|
|
210
|
+
atomicPrivateWrite(descriptor, { socket, capability, generation });
|
|
211
|
+
return {
|
|
212
|
+
runtime,
|
|
213
|
+
descriptor,
|
|
214
|
+
privatePaths: [root, profile.configPath, profile.credentialPath],
|
|
215
|
+
close: async (terminal) => {
|
|
216
|
+
try {
|
|
217
|
+
await endpoint.close();
|
|
218
|
+
if (terminal)
|
|
219
|
+
await runtime.terminal();
|
|
220
|
+
else
|
|
221
|
+
await runtime.suspend();
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
try {
|
|
225
|
+
await client.close();
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
controller.unlock();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
try {
|
|
236
|
+
await partialEndpoint?.close();
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
try {
|
|
240
|
+
await closeClient?.();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
controller.unlock();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Explicit trusted inventory migration: read and pin existing permanent CID, never bind. */
|
|
250
|
+
export async function preparePermanentAssignment(role) {
|
|
251
|
+
const profile = readClientProfile({ ...process.env, ...role.env });
|
|
252
|
+
if (!profile)
|
|
253
|
+
throw Error('MANAGED_OURS_REQUIRES_EXPLICIT_DAEMON_PROFILE');
|
|
254
|
+
const client = await attachOursClient({
|
|
255
|
+
endpoint: profile.endpoint,
|
|
256
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
257
|
+
credentialPath: profile.credentialPath,
|
|
258
|
+
sessionMode: 'local',
|
|
259
|
+
requiredCapabilities: ['local-pid-v1', 'root-first-identities-v1'],
|
|
260
|
+
});
|
|
261
|
+
try {
|
|
262
|
+
const rows = await client.listIdentities(), row = rows.find((r) => r.name === role.identity);
|
|
263
|
+
if (!row)
|
|
264
|
+
return 'unverified';
|
|
265
|
+
if (!('cid' in row) || row.kind !== 'role' || row.temp)
|
|
266
|
+
throw Error('PERMANENT_ASSIGNMENT_NOT_A_ROLE');
|
|
267
|
+
const dir = join(privateRuntimeRoot(), binderKey(profile.expectedInstanceId, role.identity));
|
|
268
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
269
|
+
const path = join(dir, 'identity-pin.json');
|
|
270
|
+
if (existsSync(path)) {
|
|
271
|
+
const old = JSON.parse(readFileSync(path, 'utf8'));
|
|
272
|
+
if (old.cid !== row.cid ||
|
|
273
|
+
old.daemon !== profile.expectedInstanceId ||
|
|
274
|
+
old.name !== role.identity)
|
|
275
|
+
throw Error('PERMANENT_ASSIGNMENT_PIN_MISMATCH');
|
|
276
|
+
}
|
|
277
|
+
else
|
|
278
|
+
atomicPrivateWrite(path, {
|
|
279
|
+
daemon: profile.expectedInstanceId,
|
|
280
|
+
name: role.identity,
|
|
281
|
+
cid: row.cid,
|
|
282
|
+
});
|
|
283
|
+
return 'verified';
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await client.close();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
export function readRoomReadiness(roomCid, name) {
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(readFileSync(join(privateRuntimeRoot(), 'room-inputs', binderKey(roomCid, name) + '.ready.json'), 'utf8'));
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error.code === 'ENOENT')
|
|
295
|
+
return;
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Explicit logical-instance termination, independent of harness/bridge transport. */
|
|
300
|
+
export async function releaseManagedAgent(role) {
|
|
301
|
+
const profile = readClientProfile({ ...process.env, ...role.env });
|
|
302
|
+
if (!profile)
|
|
303
|
+
throw Error('MANAGED_OURS_REQUIRES_EXPLICIT_DAEMON_PROFILE');
|
|
304
|
+
const root = privateRuntimeRoot(), dir = join(root, binderKey(profile.expectedInstanceId, role.identity));
|
|
305
|
+
if (!existsSync(join(dir, 'instance.json')))
|
|
306
|
+
return;
|
|
307
|
+
const instance = JSON.parse(readFileSync(join(dir, 'instance.json'), 'utf8'));
|
|
308
|
+
if (instance.role !== role.name)
|
|
309
|
+
throw Error('IDENTITY_ASSIGNED_TO_OTHER_ROLE');
|
|
310
|
+
const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance.instance);
|
|
311
|
+
try {
|
|
312
|
+
const state = controller.journal.read();
|
|
313
|
+
if (!state || state.phase === 'RELEASED')
|
|
314
|
+
return;
|
|
315
|
+
const owner = JSON.parse(readFileSync(join(dir, 'owner.json'), 'utf8'));
|
|
316
|
+
if (owner.instance !== state.instance)
|
|
317
|
+
throw Error('OWNER_RECORD_MISMATCH');
|
|
318
|
+
const client = await attachOursClient({
|
|
319
|
+
endpoint: profile.endpoint,
|
|
320
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
321
|
+
credentialPath: profile.credentialPath,
|
|
322
|
+
sessionMode: 'external',
|
|
323
|
+
leaseToken: owner.token,
|
|
324
|
+
requiredCapabilities: ['external-sessions-v1', 'root-first-identities-v1'],
|
|
325
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
const runtime = new AgentOursRuntime({
|
|
328
|
+
instance: state.instance,
|
|
329
|
+
generation: state.generation,
|
|
330
|
+
daemon: state.daemon,
|
|
331
|
+
name: state.name,
|
|
332
|
+
lifetime: state.lifetime,
|
|
333
|
+
action: state.action,
|
|
334
|
+
allowCreate: false,
|
|
335
|
+
bio: '',
|
|
336
|
+
}, {
|
|
337
|
+
client,
|
|
338
|
+
journal: controller.journal,
|
|
339
|
+
assertFence: controller.assertFence,
|
|
340
|
+
now: Date.now,
|
|
341
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
342
|
+
});
|
|
343
|
+
await runtime.terminal();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await client.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
controller.unlock();
|
|
351
|
+
}
|
|
352
|
+
}
|