@ours.network/fleet 0.15.1 → 0.15.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 +49 -13
- package/dist/application/fleet-query-service.js +3 -0
- package/dist/application/model-catalog.d.ts +20 -0
- package/dist/application/model-catalog.js +57 -0
- package/dist/application/role-creation-service.d.ts +7 -0
- package/dist/application/role-creation-service.js +21 -4
- package/dist/application/role-removal-service.d.ts +32 -0
- package/dist/application/role-removal-service.js +87 -0
- package/dist/application/role-repository.js +13 -1
- package/dist/application/session-control.d.ts +74 -0
- package/dist/application/session-control.js +66 -1
- package/dist/application/types.d.ts +18 -0
- package/dist/briefing.js +21 -1
- package/dist/cli.js +39 -7
- package/dist/config.d.ts +4 -1
- package/dist/config.js +3 -2
- package/dist/creation.d.ts +6 -3
- package/dist/creation.js +5 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +47 -11
- package/dist/fleet-proxy.d.ts +25 -0
- package/dist/fleet-proxy.js +38 -0
- package/dist/harness/claude-code.js +20 -3
- package/dist/harness/codex.js +14 -2
- package/dist/harness/types.d.ts +6 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/owner-channel/channel.d.ts +13 -0
- package/dist/owner-channel/channel.js +191 -9
- package/dist/owner-channel/state.d.ts +7 -1
- package/dist/owner-channel/state.js +41 -4
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +7 -0
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +86 -2
- package/dist/session/acp.d.ts +61 -1
- package/dist/session/acp.js +398 -20
- package/dist/session/arbiter.d.ts +10 -1
- package/dist/session/arbiter.js +24 -0
- package/dist/session/control.d.ts +33 -2
- package/dist/session/control.js +158 -5
- package/dist/session/conversation-normalizer.d.ts +34 -0
- package/dist/session/conversation-normalizer.js +356 -0
- package/dist/session/conversation-store.d.ts +88 -0
- package/dist/session/conversation-store.js +347 -0
- package/dist/session/conversation-types.d.ts +274 -0
- package/dist/session/conversation-types.js +1 -0
- package/dist/session/types.d.ts +40 -0
- package/dist/spawn.d.ts +6 -1
- package/dist/spawn.js +23 -16
- package/dist/web/auth.d.ts +1 -1
- package/dist/web/fleet-config-service.d.ts +47 -0
- package/dist/web/fleet-config-service.js +204 -0
- package/dist/web/runtime.js +14 -1
- package/dist/web/server.d.ts +6 -0
- package/dist/web/server.js +181 -9
- package/dist/web/topology.d.ts +31 -0
- package/dist/web/topology.js +61 -0
- package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
- package/dist/web-app/assets/index-COg4Azq1.css +1 -0
- package/dist/web-app/assets/index-Cde9auW0.js +10 -0
- package/dist/web-app/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
- package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { effectivePermissionMode } from './permissions.js';
|
|
2
|
+
/** Present only inside a managed role process. The CLI treats it as a routing hint, not authority. */
|
|
3
|
+
export const FLEET_PROXY_STATE_DIR_ENV = 'OURS_FLEET_PROXY_STATE_DIR';
|
|
4
|
+
export const FLEET_PROXY_CALLER_ENV = 'OURS_FLEET_PROXY_CALLER';
|
|
5
|
+
/**
|
|
6
|
+
* Fill only omitted spawn settings from the live caller. Explicit agent choices
|
|
7
|
+
* always win. This is convenience attribution, not an authorization boundary.
|
|
8
|
+
*/
|
|
9
|
+
export function inheritCallerSpawnDefaults(caller, requested, configPath) {
|
|
10
|
+
const options = { ...requested };
|
|
11
|
+
const inherited = [];
|
|
12
|
+
const take = (key, value) => {
|
|
13
|
+
if (options[key] !== undefined || value === undefined)
|
|
14
|
+
return;
|
|
15
|
+
options[key] = value;
|
|
16
|
+
inherited.push(String(key));
|
|
17
|
+
};
|
|
18
|
+
const sameHarness = requested.harness === undefined || requested.harness === caller.harness;
|
|
19
|
+
take('harness', caller.harness);
|
|
20
|
+
take('session', caller.session);
|
|
21
|
+
take('cwd', caller.cwd);
|
|
22
|
+
take('coordinator', caller.name);
|
|
23
|
+
if (options.approval === undefined)
|
|
24
|
+
take('approval', effectivePermissionMode(caller).fleetMode);
|
|
25
|
+
take('filesystem', caller.permissions.filesystem);
|
|
26
|
+
take('unattended', caller.permissions.unattended);
|
|
27
|
+
take('monitorConfig', structuredClone(caller.monitor));
|
|
28
|
+
// A model name and native harness options are not portable across harnesses.
|
|
29
|
+
// When the caller explicitly switches harness, let that harness/fleet defaults
|
|
30
|
+
// select its model instead of copying (for example) a Codex model into Claude.
|
|
31
|
+
if (sameHarness)
|
|
32
|
+
take('model', caller.model);
|
|
33
|
+
options.configPath = configPath;
|
|
34
|
+
options.surface = 'agent';
|
|
35
|
+
options.callerRole = caller.name;
|
|
36
|
+
options.inheritedFromCaller = [...inherited];
|
|
37
|
+
return { options, inherited };
|
|
38
|
+
}
|
|
@@ -6,7 +6,8 @@ import { registerAdapter } from './registry.js';
|
|
|
6
6
|
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
7
7
|
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
8
8
|
import { bundledAcpAgent } from './acp-agent.js';
|
|
9
|
-
const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode'];
|
|
9
|
+
const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort'];
|
|
10
|
+
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
10
11
|
/** Claude Code's accepted --permission-mode values. */
|
|
11
12
|
const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
|
|
12
13
|
/**
|
|
@@ -23,8 +24,9 @@ const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPe
|
|
|
23
24
|
export function nativePermissionMode(approval) {
|
|
24
25
|
switch (approval) {
|
|
25
26
|
case 'allow': return 'bypassPermissions';
|
|
27
|
+
case 'auto': return 'acceptEdits';
|
|
26
28
|
case 'deny': return 'plan';
|
|
27
|
-
default: return undefined; //
|
|
29
|
+
default: return undefined; // ask uses Claude's native default
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
@@ -149,9 +151,13 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
149
151
|
return [];
|
|
150
152
|
if (typeof opts !== 'object' || Array.isArray(opts))
|
|
151
153
|
return [{ path: 'harness_options', message: 'must be a map' }];
|
|
152
|
-
|
|
154
|
+
const errors = Object.keys(opts)
|
|
153
155
|
.filter(k => !OPTION_KEYS.includes(k))
|
|
154
156
|
.map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
|
|
157
|
+
const effort = opts.effort;
|
|
158
|
+
if (effort != null && !EFFORT_LEVELS.includes(effort))
|
|
159
|
+
errors.push({ path: 'harness_options.effort', message: `must be one of: ${EFFORT_LEVELS.join(', ')}` });
|
|
160
|
+
return errors;
|
|
155
161
|
},
|
|
156
162
|
async prepareSession(role, dirs) {
|
|
157
163
|
// Pre-trust stays a HOST-side step: inside the sandbox ~/.claude.json is
|
|
@@ -189,7 +195,9 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
189
195
|
buildLaunch(role, mode, s, prep) {
|
|
190
196
|
const stateDir = roleStateDir(role);
|
|
191
197
|
const pm = permissionMode(role);
|
|
198
|
+
const o = role.harness_options;
|
|
192
199
|
const base = ['claude', ...(role.model ? ['--model', role.model] : []),
|
|
200
|
+
...(o?.effort ? ['--effort', o.effort] : []),
|
|
193
201
|
...(pm ? ['--permission-mode', pm] : []),
|
|
194
202
|
...prep.argv, '--remote-control', role.name];
|
|
195
203
|
const argv = mode === 'fresh'
|
|
@@ -256,6 +264,15 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
256
264
|
capabilities: claudeCapabilities(native, role.permissions.filesystem),
|
|
257
265
|
};
|
|
258
266
|
},
|
|
267
|
+
effectivePermissionMode(role) {
|
|
268
|
+
const nativeMode = permissionMode(role) ?? 'default';
|
|
269
|
+
const fleetMode = nativeMode === 'bypassPermissions' ? 'allow'
|
|
270
|
+
: nativeMode === 'acceptEdits' || nativeMode === 'dontAsk' ? 'auto'
|
|
271
|
+
: nativeMode === 'default' || nativeMode === 'plan' ? 'ask' : undefined;
|
|
272
|
+
if (!fleetMode)
|
|
273
|
+
throw new Error(`unsupported Claude permission mode '${nativeMode}'`);
|
|
274
|
+
return { fleetMode, nativeMode };
|
|
275
|
+
},
|
|
259
276
|
vocabulary: {
|
|
260
277
|
bindTool: 'choose_identity',
|
|
261
278
|
createTool: 'create_identity',
|
package/dist/harness/codex.js
CHANGED
|
@@ -63,8 +63,10 @@ function approvalPolicy(role) {
|
|
|
63
63
|
const approval = role.permissions?.approval;
|
|
64
64
|
if (approval === 'allow')
|
|
65
65
|
return 'never';
|
|
66
|
-
if (approval === '
|
|
66
|
+
if (approval === 'auto' || approval === 'deny')
|
|
67
67
|
return 'on-request';
|
|
68
|
+
if (approval === 'ask')
|
|
69
|
+
return 'untrusted';
|
|
68
70
|
return undefined;
|
|
69
71
|
}
|
|
70
72
|
if (!APPROVAL_POLICIES.includes(a))
|
|
@@ -259,7 +261,8 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
259
261
|
};
|
|
260
262
|
},
|
|
261
263
|
translatePermissions(permissions) {
|
|
262
|
-
const approval = permissions.approval === 'allow' ? 'never'
|
|
264
|
+
const approval = permissions.approval === 'allow' ? 'never'
|
|
265
|
+
: permissions.approval === 'ask' ? 'untrusted' : 'on-request';
|
|
263
266
|
const sandbox = permissions.filesystem === 'read-only'
|
|
264
267
|
? 'read-only'
|
|
265
268
|
: permissions.filesystem === 'unrestricted'
|
|
@@ -285,6 +288,15 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
285
288
|
capabilities: codexCapabilities(approval, sandbox),
|
|
286
289
|
};
|
|
287
290
|
},
|
|
291
|
+
effectivePermissionMode(role) {
|
|
292
|
+
const nativeMode = approvalPolicy(role) ?? 'untrusted';
|
|
293
|
+
const fleetMode = nativeMode === 'never' ? 'allow'
|
|
294
|
+
: nativeMode === 'on-request' ? 'auto'
|
|
295
|
+
: nativeMode === 'untrusted' ? 'ask' : undefined;
|
|
296
|
+
if (!fleetMode)
|
|
297
|
+
throw new Error(`unsupported Codex approval policy '${nativeMode}'`);
|
|
298
|
+
return { fleetMode, nativeMode };
|
|
299
|
+
},
|
|
288
300
|
vocabulary: {
|
|
289
301
|
bindTool: 'choose_identity',
|
|
290
302
|
createTool: 'create_identity',
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CommonPermissions, ResolvedRole } from '../config.js';
|
|
1
|
+
import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
|
|
2
2
|
export interface PrereqCheck {
|
|
3
3
|
name: string;
|
|
4
4
|
ok: boolean;
|
|
@@ -106,6 +106,11 @@ export interface HarnessAdapter {
|
|
|
106
106
|
* agent's default. Omit for a harness whose ACP agent has no modes.
|
|
107
107
|
*/
|
|
108
108
|
acpPermissionModeId?(role: ResolvedRole): string | undefined;
|
|
109
|
+
/** Effective portable policy and harness-native approval mode after native overrides win. */
|
|
110
|
+
effectivePermissionMode?(role: ResolvedRole): {
|
|
111
|
+
fleetMode: FleetPermissionMode;
|
|
112
|
+
nativeMode: string;
|
|
113
|
+
};
|
|
109
114
|
/**
|
|
110
115
|
* REQUIRED. Every adapter must either translate neutral permissions or
|
|
111
116
|
* explicitly declare that it cannot. Enforced at registration.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
|
|
2
|
-
export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, SessionOptions, OwnerChannelConfig, } from './config.js';
|
|
2
|
+
export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, FleetPermissionMode, ApprovalMode, SessionOptions, OwnerChannelConfig, } from './config.js';
|
|
3
3
|
export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, AcpLaunch, PermissionTranslation, RoleDirs, ValidationError, } from './harness/types.js';
|
|
4
4
|
export type { SessionHandle, SessionSnapshot, SessionEvent, TurnResult, } from './session/types.js';
|
|
5
5
|
export { AcpSession } from './session/acp.js';
|
|
@@ -9,6 +9,7 @@ export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.j
|
|
|
9
9
|
export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
|
|
10
10
|
export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
|
|
11
11
|
export { generateBriefing } from './briefing.js';
|
|
12
|
+
export { effectivePermissionMode } from './permissions.js';
|
|
12
13
|
export { pickBackend } from './supervisor/index.js';
|
|
13
14
|
export type { SupervisorBackend } from './supervisor/types.js';
|
|
14
15
|
export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.j
|
|
|
6
6
|
export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
|
|
7
7
|
export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
|
|
8
8
|
export { generateBriefing } from './briefing.js';
|
|
9
|
+
export { effectivePermissionMode } from './permissions.js';
|
|
9
10
|
export { pickBackend } from './supervisor/index.js';
|
|
10
11
|
export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
|
|
11
12
|
export { spawnPermanent, spawnTemp, buildRoleConfig, profileValues, validateSpawnOpts, } from './spawn.js';
|
|
@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
|
2
2
|
import { type OwnerChannelConfig } from '../config.js';
|
|
3
3
|
import type { SessionHandle } from '../session/types.js';
|
|
4
4
|
import { type OwnerFleetOps } from './commands.js';
|
|
5
|
+
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
5
6
|
import { type OursToolClient } from './mcp.js';
|
|
6
7
|
import { type OwnerUpdatePhase } from './notices.js';
|
|
7
8
|
import { type OwnerEntry } from './state.js';
|
|
@@ -34,6 +35,8 @@ export interface OwnerChannelHandle {
|
|
|
34
35
|
drain(): Promise<void>;
|
|
35
36
|
close(): Promise<void>;
|
|
36
37
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
38
|
+
/** Fleet-owned deterministic lifecycle notice; absent on legacy test doubles. */
|
|
39
|
+
notifyFleetSpawn?(event: ManagedFleetSpawnResult): Promise<void>;
|
|
37
40
|
}
|
|
38
41
|
export type OwnerChannelManagementRequest = {
|
|
39
42
|
action: 'contact_list';
|
|
@@ -155,6 +158,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
155
158
|
drain(): Promise<void>;
|
|
156
159
|
close(): Promise<void>;
|
|
157
160
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
161
|
+
notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
|
|
158
162
|
private manageNow;
|
|
159
163
|
private contacts;
|
|
160
164
|
private contact;
|
|
@@ -195,6 +199,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
195
199
|
private isAgentSender;
|
|
196
200
|
private isEffectiveOwner;
|
|
197
201
|
private relayManagedAgentMessage;
|
|
202
|
+
/**
|
|
203
|
+
* Caption and files are admitted as one relay transaction. The authenticated
|
|
204
|
+
* route and optional source wire are fixed before bytes are retrieved, and no
|
|
205
|
+
* outbound part is emitted until every file passes admission. A transport
|
|
206
|
+
* failure after emission starts is durably uncertain and never blind-retried;
|
|
207
|
+
* the managed agent receives one bounded NACK for the whole transaction.
|
|
208
|
+
*/
|
|
209
|
+
private handleManagedAgentAttachmentGroup;
|
|
210
|
+
private managedAttachmentReplyWire;
|
|
198
211
|
/**
|
|
199
212
|
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
200
213
|
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
@@ -157,6 +157,20 @@ export class OwnerChannel {
|
|
|
157
157
|
this.managementTail = run.then(() => undefined, () => undefined);
|
|
158
158
|
return run;
|
|
159
159
|
}
|
|
160
|
+
notifyFleetSpawn(event) {
|
|
161
|
+
const run = this.managementTail.then(async () => {
|
|
162
|
+
if (!this.ready || this.stopping)
|
|
163
|
+
throw new Error('owner-channel MCP client is unavailable');
|
|
164
|
+
const model = event.model ? `, model ${event.model}` : '';
|
|
165
|
+
const monitor = `${event.monitor.mode} monitor${event.monitor.interrupt ? ' with interruption' : ''}`;
|
|
166
|
+
const inherited = event.inherited.length
|
|
167
|
+
? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
|
|
168
|
+
await this.sendProactiveMessage(`🧑💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
|
|
169
|
+
+ `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
|
|
170
|
+
});
|
|
171
|
+
this.managementTail = run.then(() => undefined, () => undefined);
|
|
172
|
+
return run;
|
|
173
|
+
}
|
|
160
174
|
async manageNow(request) {
|
|
161
175
|
if (!this.ready || this.stopping)
|
|
162
176
|
throw new Error('owner-channel MCP client is unavailable');
|
|
@@ -337,13 +351,15 @@ export class OwnerChannel {
|
|
|
337
351
|
await send;
|
|
338
352
|
return { action: request.action, requestId: request.requestId, sequence };
|
|
339
353
|
}
|
|
340
|
-
async sendProactiveMessage(messageValue) {
|
|
354
|
+
async sendProactiveMessage(messageValue, digestValue = messageValue, minIntervalMs) {
|
|
341
355
|
if (!this.authorizationIntegrity().ok)
|
|
342
356
|
throw new Error('owner authorization state is corrupt; proactive messages are disabled');
|
|
343
357
|
const message = this.safeProactiveMessage(messageValue);
|
|
344
358
|
const route = this.conversations.route(this.effectiveOwners());
|
|
345
|
-
const digest = createHash('sha256').update(
|
|
346
|
-
const sending =
|
|
359
|
+
const digest = createHash('sha256').update(digestValue).digest('hex');
|
|
360
|
+
const sending = minIntervalMs === undefined
|
|
361
|
+
? this.conversations.beginSend(route.contact, digest)
|
|
362
|
+
: this.conversations.beginSend(route.contact, digest, Date.now(), minIntervalMs);
|
|
347
363
|
try {
|
|
348
364
|
if (!this.isEffectiveOwner(route.contact))
|
|
349
365
|
throw new Error('selected proactive owner is no longer authorized');
|
|
@@ -542,7 +558,18 @@ export class OwnerChannel {
|
|
|
542
558
|
if (exact.some(file => file.senderId !== recovery.contact))
|
|
543
559
|
continue;
|
|
544
560
|
exact.forEach(file => used.add(file.wireId));
|
|
545
|
-
|
|
561
|
+
const caption = recovery.originWireId === recovery.fileWireIds[0]
|
|
562
|
+
? undefined : messageByWire.get(recovery.originWireId);
|
|
563
|
+
if (caption && this.sender(caption).id !== recovery.contact)
|
|
564
|
+
continue;
|
|
565
|
+
// A managed-agent caption is deferred before retrieval. If recovery sees
|
|
566
|
+
// the processed file before that body is replayed, keep the file reserved
|
|
567
|
+
// by the journal until both halves are present again.
|
|
568
|
+
if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
|
|
569
|
+
continue;
|
|
570
|
+
if (caption)
|
|
571
|
+
consumed.add(caption);
|
|
572
|
+
groups.push({ files: exact, recovery, ...(caption ? { caption } : {}) });
|
|
546
573
|
}
|
|
547
574
|
for (const file of files) {
|
|
548
575
|
if (used.has(file.wireId))
|
|
@@ -578,6 +605,9 @@ export class OwnerChannel {
|
|
|
578
605
|
if (handledWireIds.some(wire => this.inFlight.has(wire)))
|
|
579
606
|
return false;
|
|
580
607
|
const sender = { id: group.files[0].senderId, name: group.files[0].senderName };
|
|
608
|
+
if (group.files.every(file => this.isAgentSender(file.senderId))
|
|
609
|
+
&& (!group.caption || this.isAgentSender(this.sender(group.caption).id)))
|
|
610
|
+
return this.handleManagedAgentAttachmentGroup(group, handledWireIds, sender.id);
|
|
581
611
|
if (group.files.some(file => file.senderId !== sender.id)
|
|
582
612
|
|| !this.isEffectiveOwner(sender.id)) {
|
|
583
613
|
this.options.log(`[${this.options.role}] owner channel ignored unauthorized attachment sender ${sender.id}`);
|
|
@@ -590,6 +620,14 @@ export class OwnerChannel {
|
|
|
590
620
|
catch { }
|
|
591
621
|
return true;
|
|
592
622
|
}
|
|
623
|
+
// Owner files are requests too: retain their authenticated source wire so
|
|
624
|
+
// a later managed-agent attachment reply cannot drift to a newer owner.
|
|
625
|
+
try {
|
|
626
|
+
this.conversations.recordInbound(sender.id, originWireId);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
this.logError('owner attachment route update failed', error);
|
|
630
|
+
}
|
|
593
631
|
const rejection = !this.attachmentRecovery.integrity()
|
|
594
632
|
? 'attachment recovery state is unavailable'
|
|
595
633
|
: validateAttachmentSelection(group.files, this.attachmentConfig);
|
|
@@ -633,7 +671,8 @@ export class OwnerChannel {
|
|
|
633
671
|
const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
|
|
634
672
|
interrupt: this.options.config.interrupt,
|
|
635
673
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
636
|
-
origin: { kind: 'owner', requestId
|
|
674
|
+
origin: { kind: 'owner', requestId,
|
|
675
|
+
...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
|
|
637
676
|
});
|
|
638
677
|
const accepted = this.options.config.interrupt
|
|
639
678
|
? ownerNotices.receivedInterrupting()
|
|
@@ -745,9 +784,9 @@ export class OwnerChannel {
|
|
|
745
784
|
// never prevent an authenticated owner from using the ordinary channel.
|
|
746
785
|
this.logError('owner conversation route update failed', error);
|
|
747
786
|
}
|
|
748
|
-
const text = String(message.text ?? '')
|
|
749
|
-
if (isOwnerCommandText(text)) {
|
|
750
|
-
await this.handleCommand(sender, text, wireId);
|
|
787
|
+
const text = String(message.text ?? '');
|
|
788
|
+
if (isOwnerCommandText(text.trim())) {
|
|
789
|
+
await this.handleCommand(sender, text.trim(), wireId);
|
|
751
790
|
return true;
|
|
752
791
|
}
|
|
753
792
|
const requestId = this.requestId(wireId);
|
|
@@ -759,7 +798,7 @@ export class OwnerChannel {
|
|
|
759
798
|
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
|
|
760
799
|
interrupt: this.options.config.interrupt,
|
|
761
800
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
762
|
-
origin: { kind: 'owner', requestId },
|
|
801
|
+
origin: { kind: 'owner', requestId, displayText: text },
|
|
763
802
|
});
|
|
764
803
|
}
|
|
765
804
|
catch (error) {
|
|
@@ -936,6 +975,149 @@ export class OwnerChannel {
|
|
|
936
975
|
+ `wire=${createHash('sha256').update(wireId).digest('hex').slice(0, 12)} `
|
|
937
976
|
+ `basis=${route.basis} chars=${Array.from(text).length} bytes=${Buffer.byteLength(text)}`);
|
|
938
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* Caption and files are admitted as one relay transaction. The authenticated
|
|
980
|
+
* route and optional source wire are fixed before bytes are retrieved, and no
|
|
981
|
+
* outbound part is emitted until every file passes admission. A transport
|
|
982
|
+
* failure after emission starts is durably uncertain and never blind-retried;
|
|
983
|
+
* the managed agent receives one bounded NACK for the whole transaction.
|
|
984
|
+
*/
|
|
985
|
+
async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
|
|
986
|
+
const captionWire = group.caption ? this.wireId(group.caption) : undefined;
|
|
987
|
+
const nackWire = captionWire ?? group.files[0].wireId;
|
|
988
|
+
const nackMessage = group.caption ?? { wire_id: nackWire };
|
|
989
|
+
let requestDir;
|
|
990
|
+
let recovery;
|
|
991
|
+
try {
|
|
992
|
+
if (!this.authorizationIntegrity().ok)
|
|
993
|
+
throw new Error('owner authorization state is corrupt; managed-agent relay is disabled');
|
|
994
|
+
const caption = group.caption ? this.safeRelayMessage(group.caption.text) : undefined;
|
|
995
|
+
const replyTo = this.managedAttachmentReplyWire(group, captionWire);
|
|
996
|
+
let route;
|
|
997
|
+
try {
|
|
998
|
+
route = replyTo
|
|
999
|
+
? this.conversations.routeForWire(replyTo, this.effectiveOwners())
|
|
1000
|
+
: this.conversations.route(this.effectiveOwners());
|
|
1001
|
+
}
|
|
1002
|
+
catch (error) {
|
|
1003
|
+
throw new RelayUnroutableError(this.errorText(error));
|
|
1004
|
+
}
|
|
1005
|
+
if (!this.isEffectiveOwner(route.contact))
|
|
1006
|
+
throw new Error('selected attachment relay owner is no longer authorized');
|
|
1007
|
+
const contact = await this.routableContact(route);
|
|
1008
|
+
const rejection = !this.attachmentRecovery.integrity()
|
|
1009
|
+
? 'attachment recovery state is unavailable'
|
|
1010
|
+
: validateAttachmentSelection(group.files, this.attachmentConfig);
|
|
1011
|
+
if (rejection)
|
|
1012
|
+
throw new Error(rejection);
|
|
1013
|
+
const transactionId = this.requestId(`managed-agent-attachment:${handledWireIds.slice().sort().join(':')}`);
|
|
1014
|
+
recovery = group.recovery ?? {
|
|
1015
|
+
id: transactionId, contact: agent, originWireId: captionWire ?? group.files[0].wireId,
|
|
1016
|
+
fileWireIds: group.files.map(file => file.wireId), createdAt: Date.now(),
|
|
1017
|
+
};
|
|
1018
|
+
if (!group.recovery)
|
|
1019
|
+
this.attachmentRecovery.add(recovery);
|
|
1020
|
+
requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
|
|
1021
|
+
const unread = group.files.filter(file => file.status === 'unread');
|
|
1022
|
+
const processed = group.files.filter(file => file.status !== 'unread');
|
|
1023
|
+
const retrieved = unread.length
|
|
1024
|
+
? parseRetrievedAttachments(await this.client.callTool('get_files', {
|
|
1025
|
+
wire_ids: unread.map(file => file.wireId),
|
|
1026
|
+
}), unread)
|
|
1027
|
+
: [];
|
|
1028
|
+
for (const file of processed) {
|
|
1029
|
+
if (!group.recovery)
|
|
1030
|
+
throw new Error('unexpected processed attachment without recovery route');
|
|
1031
|
+
const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
|
|
1032
|
+
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
1033
|
+
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
1034
|
+
}
|
|
1035
|
+
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
1036
|
+
retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
|
|
1037
|
+
const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
|
|
1038
|
+
const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
|
|
1039
|
+
const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
|
|
1040
|
+
try {
|
|
1041
|
+
if (caption)
|
|
1042
|
+
await this.send(contact, caption, replyTo);
|
|
1043
|
+
for (const file of admitted) {
|
|
1044
|
+
await this.client.callTool('send_file', {
|
|
1045
|
+
contact, path: file.path, filename: file.filename,
|
|
1046
|
+
...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
try {
|
|
1052
|
+
this.conversations.finishSend(sending.id, 'uncertain');
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
this.logError('managed-agent attachment uncertainty persist failed', error);
|
|
1056
|
+
}
|
|
1057
|
+
throw new Error('caption/file relay delivery outcome is uncertain; it was not retried');
|
|
1058
|
+
}
|
|
1059
|
+
this.conversations.finishSend(sending.id, 'delivered');
|
|
1060
|
+
for (const wire of handledWireIds)
|
|
1061
|
+
this.state.remember(wire);
|
|
1062
|
+
this.attachmentRecovery.remove(recovery.id);
|
|
1063
|
+
this.options.log(`[${this.options.role}] managed-agent attachment transaction relayed `
|
|
1064
|
+
+ `wires=${handledWireIds.length} basis=${route.basis} files=${admitted.length} `
|
|
1065
|
+
+ `bytes=${admitted.reduce((total, file) => total + file.size, 0)}`);
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
catch (error) {
|
|
1069
|
+
if (error instanceof DuplicateSendError) {
|
|
1070
|
+
this.options.log(`[${this.options.role}] managed-agent attachment replay consumed`);
|
|
1071
|
+
for (const wire of handledWireIds)
|
|
1072
|
+
this.state.remember(wire);
|
|
1073
|
+
if (recovery)
|
|
1074
|
+
try {
|
|
1075
|
+
this.attachmentRecovery.remove(recovery.id);
|
|
1076
|
+
}
|
|
1077
|
+
catch { }
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
if (error instanceof RelayUnroutableError) {
|
|
1081
|
+
this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
|
|
1082
|
+
+ `transaction stays queued: ${this.errorText(error)}`);
|
|
1083
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
|
|
1084
|
+
return false;
|
|
1085
|
+
}
|
|
1086
|
+
this.logError('managed-agent caption/file relay refused', error);
|
|
1087
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
|
|
1088
|
+
// Rejection/admission failure and uncertain transport are terminal and
|
|
1089
|
+
// visible. Consuming every correlated wire prevents a later partial replay.
|
|
1090
|
+
for (const wire of handledWireIds)
|
|
1091
|
+
this.state.remember(wire);
|
|
1092
|
+
if (recovery)
|
|
1093
|
+
try {
|
|
1094
|
+
this.attachmentRecovery.remove(recovery.id);
|
|
1095
|
+
}
|
|
1096
|
+
catch { }
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
finally {
|
|
1100
|
+
if (requestDir)
|
|
1101
|
+
await removeRequestDirectory(requestDir).catch(error => {
|
|
1102
|
+
this.logError('managed-agent attachment cleanup failed', error);
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
managedAttachmentReplyWire(group, captionWire) {
|
|
1107
|
+
const captionReply = group.caption?.reply_to?.wire_id;
|
|
1108
|
+
const candidates = new Set();
|
|
1109
|
+
if (captionReply)
|
|
1110
|
+
candidates.add(captionReply);
|
|
1111
|
+
for (const file of group.files) {
|
|
1112
|
+
const wire = file.replyTo?.wire_id;
|
|
1113
|
+
if (!wire || wire === captionWire)
|
|
1114
|
+
continue;
|
|
1115
|
+
candidates.add(wire);
|
|
1116
|
+
}
|
|
1117
|
+
if (candidates.size > 1)
|
|
1118
|
+
throw new Error('managed-agent caption/file group has conflicting owner reply wires');
|
|
1119
|
+
return candidates.values().next().value;
|
|
1120
|
+
}
|
|
939
1121
|
/**
|
|
940
1122
|
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
941
1123
|
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
@@ -11,7 +11,7 @@ export declare class OwnerChannelState {
|
|
|
11
11
|
has(wireId: string): boolean;
|
|
12
12
|
remember(wireId: string): void;
|
|
13
13
|
}
|
|
14
|
-
export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner';
|
|
14
|
+
export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner' | 'source-wire';
|
|
15
15
|
interface OwnerProactiveSend {
|
|
16
16
|
id: string;
|
|
17
17
|
contact: string;
|
|
@@ -28,6 +28,7 @@ interface OwnerProactiveSend {
|
|
|
28
28
|
export declare class OwnerConversationState {
|
|
29
29
|
private readonly path;
|
|
30
30
|
private conversations;
|
|
31
|
+
private routes;
|
|
31
32
|
private sends;
|
|
32
33
|
private corruptReason?;
|
|
33
34
|
constructor(path: string);
|
|
@@ -41,12 +42,17 @@ export declare class OwnerConversationState {
|
|
|
41
42
|
contact: string;
|
|
42
43
|
basis: OwnerConversationRouteBasis;
|
|
43
44
|
};
|
|
45
|
+
routeForWire(wireId: string, effective: Set<string>): {
|
|
46
|
+
contact: string;
|
|
47
|
+
basis: OwnerConversationRouteBasis;
|
|
48
|
+
};
|
|
44
49
|
beginSend(contact: string, digest: string, now?: number, minIntervalMs?: number, dedupe?: 'contact' | 'all'): OwnerProactiveSend;
|
|
45
50
|
finishSend(id: string, status: 'delivered' | 'uncertain'): void;
|
|
46
51
|
private mutate;
|
|
47
52
|
private persist;
|
|
48
53
|
private assertHealthy;
|
|
49
54
|
private validConversation;
|
|
55
|
+
private validRoute;
|
|
50
56
|
private validSend;
|
|
51
57
|
}
|
|
52
58
|
export type OwnerSource = 'baseline' | 'dynamic';
|
|
@@ -42,6 +42,7 @@ export class OwnerChannelState {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
const CONVERSATION_LIMIT = 64;
|
|
45
|
+
const WIRE_ROUTE_LIMIT = 512;
|
|
45
46
|
const PROACTIVE_SEND_LIMIT = 256;
|
|
46
47
|
const PROACTIVE_MIN_INTERVAL_MS = 30_000;
|
|
47
48
|
const HEX_64_LOWER = /^[a-f0-9]{64}$/;
|
|
@@ -55,6 +56,7 @@ const CID = /^[A-Fa-f0-9]{64}$/;
|
|
|
55
56
|
export class OwnerConversationState {
|
|
56
57
|
path;
|
|
57
58
|
conversations = [];
|
|
59
|
+
routes = [];
|
|
58
60
|
sends = [];
|
|
59
61
|
corruptReason;
|
|
60
62
|
constructor(path) {
|
|
@@ -63,18 +65,29 @@ export class OwnerConversationState {
|
|
|
63
65
|
return;
|
|
64
66
|
try {
|
|
65
67
|
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
66
|
-
|
|
68
|
+
const legacy = raw.version === 1;
|
|
69
|
+
const routes = legacy
|
|
70
|
+
? (raw.conversations ?? []).map(record => ({
|
|
71
|
+
contact: record.contact, wireId: record.lastInboundWireId, at: record.lastInboundAt,
|
|
72
|
+
}))
|
|
73
|
+
: raw.routes;
|
|
74
|
+
if (![1, 2].includes(raw.version ?? 0)
|
|
75
|
+
|| !Array.isArray(raw.conversations) || !Array.isArray(routes) || !Array.isArray(raw.sends)
|
|
67
76
|
|| raw.conversations.length > CONVERSATION_LIMIT
|
|
77
|
+
|| routes.length > WIRE_ROUTE_LIMIT
|
|
68
78
|
|| raw.sends.length > PROACTIVE_SEND_LIMIT
|
|
69
79
|
|| !raw.conversations.every(record => this.validConversation(record))
|
|
80
|
+
|| !routes.every(route => this.validRoute(route))
|
|
70
81
|
|| !raw.sends.every(send => this.validSend(send)))
|
|
71
82
|
throw new Error('invalid or unbounded conversation state');
|
|
72
83
|
if (new Set(raw.conversations.map(record => record.contact)).size !== raw.conversations.length
|
|
84
|
+
|| new Set(routes.map(route => route.wireId)).size !== routes.length
|
|
73
85
|
|| new Set(raw.sends.map(send => send.id)).size !== raw.sends.length)
|
|
74
86
|
throw new Error('duplicate conversation state entry');
|
|
75
87
|
this.conversations = raw.conversations.map(record => ({ ...record }));
|
|
88
|
+
this.routes = routes.map(route => ({ ...route }));
|
|
76
89
|
this.sends = raw.sends.map(send => ({ ...send }));
|
|
77
|
-
let recovered =
|
|
90
|
+
let recovered = legacy;
|
|
78
91
|
for (const send of this.sends) {
|
|
79
92
|
if (send.status === 'sending') {
|
|
80
93
|
send.status = 'uncertain';
|
|
@@ -88,6 +101,7 @@ export class OwnerConversationState {
|
|
|
88
101
|
catch {
|
|
89
102
|
this.corruptReason = 'invalid persisted owner conversation state';
|
|
90
103
|
this.conversations = [];
|
|
104
|
+
this.routes = [];
|
|
91
105
|
this.sends = [];
|
|
92
106
|
try {
|
|
93
107
|
chmodSync(path, 0o600);
|
|
@@ -118,6 +132,9 @@ export class OwnerConversationState {
|
|
|
118
132
|
throw new Error(`owner conversations are limited to ${CONVERSATION_LIMIT}`);
|
|
119
133
|
this.conversations.push({ contact, lastInboundAt: acceptedAt, lastInboundWireId: wireId });
|
|
120
134
|
}
|
|
135
|
+
this.routes = this.routes.filter(route => route.wireId !== wireId);
|
|
136
|
+
this.routes.push({ contact, wireId, at: acceptedAt });
|
|
137
|
+
this.routes = this.routes.slice(-WIRE_ROUTE_LIMIT);
|
|
121
138
|
});
|
|
122
139
|
}
|
|
123
140
|
remove(contact) {
|
|
@@ -127,6 +144,7 @@ export class OwnerConversationState {
|
|
|
127
144
|
return;
|
|
128
145
|
this.mutate(() => {
|
|
129
146
|
this.conversations = this.conversations.filter(record => canonicalCid(record.contact) !== canonical);
|
|
147
|
+
this.routes = this.routes.filter(route => canonicalCid(route.contact) !== canonical);
|
|
130
148
|
});
|
|
131
149
|
}
|
|
132
150
|
route(effective) {
|
|
@@ -146,6 +164,14 @@ export class OwnerConversationState {
|
|
|
146
164
|
return { contact: [...effective][0], basis: 'sole-owner' };
|
|
147
165
|
throw new Error('no authenticated owner conversation route is available yet');
|
|
148
166
|
}
|
|
167
|
+
routeForWire(wireId, effective) {
|
|
168
|
+
this.assertHealthy();
|
|
169
|
+
const route = [...this.routes].reverse().find(item => item.wireId === wireId);
|
|
170
|
+
const allowed = new Set([...effective].map(canonicalCid));
|
|
171
|
+
if (!route || !allowed.has(canonicalCid(route.contact)))
|
|
172
|
+
throw new Error('no authenticated owner route matches the source wire');
|
|
173
|
+
return { contact: route.contact, basis: 'source-wire' };
|
|
174
|
+
}
|
|
149
175
|
beginSend(contact, digest, now = Date.now(), minIntervalMs = PROACTIVE_MIN_INTERVAL_MS, dedupe = 'contact') {
|
|
150
176
|
this.assertHealthy();
|
|
151
177
|
if (!CID.test(contact) || !HEX_64_LOWER.test(digest))
|
|
@@ -177,7 +203,9 @@ export class OwnerConversationState {
|
|
|
177
203
|
this.mutate(() => { send.status = status; });
|
|
178
204
|
}
|
|
179
205
|
mutate(change) {
|
|
180
|
-
const snapshot = JSON.stringify({
|
|
206
|
+
const snapshot = JSON.stringify({
|
|
207
|
+
conversations: this.conversations, routes: this.routes, sends: this.sends,
|
|
208
|
+
});
|
|
181
209
|
change();
|
|
182
210
|
try {
|
|
183
211
|
this.persist();
|
|
@@ -185,13 +213,14 @@ export class OwnerConversationState {
|
|
|
185
213
|
catch (error) {
|
|
186
214
|
const old = JSON.parse(snapshot);
|
|
187
215
|
this.conversations = old.conversations;
|
|
216
|
+
this.routes = old.routes;
|
|
188
217
|
this.sends = old.sends;
|
|
189
218
|
throw error;
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
221
|
persist() {
|
|
193
222
|
replaceFileAtomically(this.path, JSON.stringify({
|
|
194
|
-
version:
|
|
223
|
+
version: 2, conversations: this.conversations, routes: this.routes, sends: this.sends,
|
|
195
224
|
}) + '\n', 0o600);
|
|
196
225
|
chmodSync(this.path, 0o600);
|
|
197
226
|
}
|
|
@@ -207,6 +236,14 @@ export class OwnerConversationState {
|
|
|
207
236
|
&& record.lastInboundAt >= 0 && typeof record.lastInboundWireId === 'string'
|
|
208
237
|
&& record.lastInboundWireId.length > 0 && record.lastInboundWireId.length <= 1_024;
|
|
209
238
|
}
|
|
239
|
+
validRoute(value) {
|
|
240
|
+
if (!value || typeof value !== 'object')
|
|
241
|
+
return false;
|
|
242
|
+
const route = value;
|
|
243
|
+
return CID.test(route.contact) && typeof route.wireId === 'string'
|
|
244
|
+
&& route.wireId.length > 0 && route.wireId.length <= 1_024
|
|
245
|
+
&& Number.isSafeInteger(route.at) && route.at >= 0;
|
|
246
|
+
}
|
|
210
247
|
validSend(value) {
|
|
211
248
|
if (!value || typeof value !== 'object')
|
|
212
249
|
return false;
|