@ours.network/fleet 0.15.4 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -13
- package/dist/owner-channel/attachments.d.ts +11 -1
- package/dist/owner-channel/attachments.js +24 -3
- package/dist/owner-channel/channel.d.ts +2 -0
- package/dist/owner-channel/channel.js +128 -7
- package/dist/owner-channel/state.d.ts +1 -0
- package/dist/owner-channel/state.js +6 -3
- package/dist/session/acp.d.ts +5 -0
- package/dist/session/acp.js +32 -5
- package/dist/session/events.js +6 -1
- package/dist/session/types.d.ts +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -328,7 +328,7 @@ roles:
|
|
|
328
328
|
owner_channel: # optional trusted owner ingress; requires session: acp
|
|
329
329
|
identity: "Name Owner Channel" # existing, dedicated ours identity bound only by fleet
|
|
330
330
|
owners: [owner-contact-cid] # authenticated ours contact IDs, never display names
|
|
331
|
-
agent: managed-agent-cid # exact role
|
|
331
|
+
agent: managed-agent-cid # exact role CID allowed to relay messages/files outward
|
|
332
332
|
interrupt: false # false queues; true cancels current work first
|
|
333
333
|
progress_interval_ms: 30000 # fleet-generated progress notices; 0 disables
|
|
334
334
|
attachments: # secure inbound documents, images, and voice
|
|
@@ -651,15 +651,16 @@ These commands require a running ACP role with `owner_channel` enabled. Missing,
|
|
|
651
651
|
stopped, tmux, disabled, draining, and unavailable-MCP targets fail without
|
|
652
652
|
starting a second client, binding an identity, or opening a network listener.
|
|
653
653
|
|
|
654
|
-
The managed agent
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
654
|
+
The managed agent can use its ordinary ours `send_message` or `send_file` tool to
|
|
655
|
+
message the channel identity. Fleet checks that the authenticated sender CID
|
|
656
|
+
exactly equals `agent`, then forwards the body/file to a resolved authenticated
|
|
657
|
+
owner route. This applies equally to blockers, suggestions, proactive notes, and
|
|
658
|
+
ordinary files: there is no model-selected owner recipient. An explicit reply wire
|
|
659
|
+
pins a file to that wire's owner; otherwise the latest authenticated owner is used.
|
|
660
|
+
A single configured owner is the safe fallback; multiple owners without route
|
|
661
|
+
history fail closed instead of guessing or broadcasting. Different devices sharing
|
|
662
|
+
one ours identity share one CID, while separate authorized identities naturally
|
|
663
|
+
hand off the unscoped route when either sends.
|
|
663
664
|
|
|
664
665
|
Messages from CIDs which are neither an owner nor the configured agent are never
|
|
665
666
|
injected or relayed. Fleet consumes the attempt, does not answer its sender, and
|
|
@@ -671,18 +672,32 @@ An owner request follows one ordered lifecycle on its authenticated source wire:
|
|
|
671
672
|
|
|
672
673
|
1. Fleet sends an immediate receipt describing started, queued, or interrupting state.
|
|
673
674
|
2. Periodic fleet-generated summaries may report allowlisted ACP activity shapes.
|
|
674
|
-
3.
|
|
675
|
+
3. On maintained Codex ACP adapters, assistant chunks carrying the exact
|
|
676
|
+
`_meta.codex.phase = "commentary"` marker are automatically batched and forwarded
|
|
677
|
+
on this request's fixed owner CID/source wire. Unknown or absent phases are never
|
|
678
|
+
inferred as commentary; thoughts, tools, permissions, prompts, and raw events are
|
|
679
|
+
excluded by event kind. Older adapters therefore retain final-only behavior.
|
|
680
|
+
4. The agent may also send any non-final or out-of-turn message through its
|
|
675
681
|
ordinary ours MCP tool. CID authentication is the message gate; no task ID,
|
|
676
682
|
request ID, phase, reply reference, or routing command is used.
|
|
677
|
-
4. The agent may send a caption and one or more files to the channel identity.
|
|
683
|
+
4. The agent may send a caption and one or more files to the channel identity.
|
|
678
684
|
A reply reference selects the authenticated owner of that exact source wire;
|
|
679
685
|
an uncorrelated group uses the latest authenticated owner (or the sole-owner
|
|
680
686
|
fallback). Fleet resolves that route once, admits every file before emitting
|
|
681
687
|
any part, then sends the caption and files to the same owner on the same route.
|
|
682
|
-
5. Fleet independently emits exactly one final ACP response (or a sanitized
|
|
688
|
+
5. Fleet independently emits exactly one final ACP response (or a sanitized
|
|
683
689
|
terminal outcome). Successful owner-request turns send regular files from the
|
|
684
690
|
request outbox afterward, correlated to the same source wire.
|
|
685
691
|
|
|
692
|
+
Commentary uses a 750 ms latency flush plus paragraph and 1,600-character/6,400-byte
|
|
693
|
+
boundaries, with at most 32 updates and 512 in-memory fragment dedupe keys per turn.
|
|
694
|
+
The terminal boundary flushes commentary before the final. Replay-marked fragments,
|
|
695
|
+
wrong turns, wrong typed origins, missing message IDs, and unsafe/ambiguous content
|
|
696
|
+
fail closed. Commentary plaintext buffers and fragment keys are memory-only; bounded
|
|
697
|
+
wire-and-batch digests plus sending/delivered/uncertain status are persisted before
|
|
698
|
+
transport. Thus reconnect replay and crash recovery never blindly resend the same
|
|
699
|
+
batch, while an uncertain in-flight update may be omitted rather than duplicated.
|
|
700
|
+
|
|
686
701
|
Fleet chooses the stored latest authenticated owner for every managed-agent
|
|
687
702
|
message; the model supplies only text and the channel contact. Relay audit logs
|
|
688
703
|
contain hashed wire prefixes and sizes, never bodies. A durable pre-send marker
|
|
@@ -813,6 +828,19 @@ admission. The host must run an ours-mcp version whose
|
|
|
813
828
|
these guarantees; `ours-mcp voice-status --json` reports whether transcription
|
|
814
829
|
is currently configured.
|
|
815
830
|
|
|
831
|
+
Managed-agent file egress is a separate trust direction. It retains the same
|
|
832
|
+
metadata provenance checks, count/per-file/request byte caps, regular-file and
|
|
833
|
+
no-symlink reads, actual byte-count/SHA-256 verification, filename sanitization,
|
|
834
|
+
private staging, fixed owner routing, and bounded body-free recovery metadata.
|
|
835
|
+
It deliberately does not consult `attachments.enabled` or `allowed_mime`; MIME is
|
|
836
|
+
still detected during byte validation, but a declared-versus-detected mismatch is
|
|
837
|
+
not an egress deny gate. Markdown, HTML, octet-stream, unknown extensions, and normal
|
|
838
|
+
binaries are therefore routable only from the exact configured `agent` CID.
|
|
839
|
+
Owner-to-agent admission above remains byte-for-byte strict. Each outbound file
|
|
840
|
+
gets a durable pre-send marker: a failed/ambiguous send becomes `uncertain` and is
|
|
841
|
+
not blindly retried, while files not yet attempted remain recoverable after restart.
|
|
842
|
+
Logs contain counts and byte totals, never filenames or raw bytes.
|
|
843
|
+
|
|
816
844
|
Owner channels currently require `session: acp`. Fleet needs structured,
|
|
817
845
|
turn-correlated assistant output for automatic replies; scraping a tmux pane
|
|
818
846
|
cannot reliably distinguish the final answer from thoughts, tool output, or
|
|
@@ -45,8 +45,15 @@ export interface AdmittedAttachment {
|
|
|
45
45
|
export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
|
|
46
46
|
export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
|
|
47
47
|
export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
50
|
+
* `enabled` or `allowed_mime`: those are owner -> agent admission policy.
|
|
51
|
+
*/
|
|
52
|
+
export declare function validateAttachmentRelaySelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
|
|
48
53
|
export declare function prepareAttachmentDirectory(root: string, requestId: string): Promise<string>;
|
|
49
|
-
export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig
|
|
54
|
+
export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
|
|
55
|
+
mimePolicy?: 'strict' | 'report-only';
|
|
56
|
+
}): Promise<AdmittedAttachment[]>;
|
|
50
57
|
export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
|
|
51
58
|
export declare function removeRequestDirectory(path: string): Promise<void>;
|
|
52
59
|
export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
|
|
@@ -56,6 +63,9 @@ export interface PendingAttachmentRequest {
|
|
|
56
63
|
originWireId: string;
|
|
57
64
|
fileWireIds: string[];
|
|
58
65
|
createdAt: number;
|
|
66
|
+
/** Present only for exact-managed-agent -> owner relay recovery. */
|
|
67
|
+
relayContact?: string;
|
|
68
|
+
relayReplyTo?: string;
|
|
59
69
|
}
|
|
60
70
|
export declare class AttachmentRecoveryState {
|
|
61
71
|
private readonly path;
|
|
@@ -118,6 +118,23 @@ export function validateAttachmentSelection(files, config) {
|
|
|
118
118
|
}
|
|
119
119
|
return undefined;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
123
|
+
* `enabled` or `allowed_mime`: those are owner -> agent admission policy.
|
|
124
|
+
*/
|
|
125
|
+
export function validateAttachmentRelaySelection(files, config) {
|
|
126
|
+
if (files.length > config.max_files_per_request)
|
|
127
|
+
return `the relay exceeds the ${config.max_files_per_request}-file limit`;
|
|
128
|
+
let total = 0;
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
if (file.size > config.max_file_bytes)
|
|
131
|
+
return `a relayed file exceeds the ${config.max_file_bytes}-byte limit`;
|
|
132
|
+
total += file.size;
|
|
133
|
+
if (!Number.isSafeInteger(total) || total > config.max_request_bytes)
|
|
134
|
+
return `the relay exceeds the ${config.max_request_bytes}-byte total limit`;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
121
138
|
export async function prepareAttachmentDirectory(root, requestId) {
|
|
122
139
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
123
140
|
const rootStat = lstatSync(root);
|
|
@@ -134,7 +151,7 @@ export async function prepareAttachmentDirectory(root, requestId) {
|
|
|
134
151
|
await chmod(dir, 0o700);
|
|
135
152
|
return dir;
|
|
136
153
|
}
|
|
137
|
-
export async function admitAttachments(files, dir, config) {
|
|
154
|
+
export async function admitAttachments(files, dir, config, options = {}) {
|
|
138
155
|
const admitted = [];
|
|
139
156
|
let total = 0;
|
|
140
157
|
for (let index = 0; index < files.length; index++) {
|
|
@@ -163,7 +180,8 @@ export async function admitAttachments(files, dir, config) {
|
|
|
163
180
|
throw new Error('retrieved attachments exceed the request size limit');
|
|
164
181
|
const declaredMime = policyMime(file);
|
|
165
182
|
const detectedMime = detectMime(bytes, declaredMime);
|
|
166
|
-
if (
|
|
183
|
+
if ((options.mimePolicy ?? 'strict') === 'strict'
|
|
184
|
+
&& !mimeCompatible(declaredMime, detectedMime))
|
|
167
185
|
throw new Error(`retrieved attachment content does not match declared MIME ${declaredMime}`);
|
|
168
186
|
const filename = sanitizeFilename(file.filename);
|
|
169
187
|
const finalPath = join(dir, `${index + 1}-${file.wireId.slice(0, 12)}-${filename}`);
|
|
@@ -327,7 +345,10 @@ function validPending(value) {
|
|
|
327
345
|
&& Array.isArray(item.fileWireIds) && item.fileWireIds.length >= 1 && item.fileWireIds.length <= 32
|
|
328
346
|
&& item.fileWireIds.every(wire => WIRE.test(wire))
|
|
329
347
|
&& new Set(item.fileWireIds).size === item.fileWireIds.length
|
|
330
|
-
&& Number.isSafeInteger(item.createdAt) && item.createdAt >= 0
|
|
348
|
+
&& Number.isSafeInteger(item.createdAt) && item.createdAt >= 0
|
|
349
|
+
&& (item.relayContact === undefined || CID.test(item.relayContact))
|
|
350
|
+
&& (item.relayReplyTo === undefined || WIRE.test(item.relayReplyTo))
|
|
351
|
+
&& (item.relayReplyTo === undefined || item.relayContact !== undefined);
|
|
331
352
|
}
|
|
332
353
|
export function safeField(value, max) {
|
|
333
354
|
return Array.from(String(value ?? '').normalize('NFC')
|
|
@@ -225,6 +225,8 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
225
225
|
private effectiveOwners;
|
|
226
226
|
private authorizationIntegrity;
|
|
227
227
|
private complete;
|
|
228
|
+
/** Model-authored commentary only; raw protocol/tool data never reaches here. */
|
|
229
|
+
private safeCommentary;
|
|
228
230
|
private ownerAttachmentPrompt;
|
|
229
231
|
private ownerPrompt;
|
|
230
232
|
private outboxDir;
|
|
@@ -10,7 +10,7 @@ import { OursMcpClient } from './mcp.js';
|
|
|
10
10
|
import { ownerNotices } from './notices.js';
|
|
11
11
|
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
12
12
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
13
|
-
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, } from './attachments.js';
|
|
13
|
+
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
|
|
14
14
|
import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
|
|
15
15
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
16
16
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
@@ -19,6 +19,11 @@ const OWNER_UPDATE_MAX_BYTES = 1_024;
|
|
|
19
19
|
const PROACTIVE_MESSAGE_MAX_CHARS = 4_000;
|
|
20
20
|
const PROACTIVE_MESSAGE_MAX_BYTES = 16_384;
|
|
21
21
|
const RELAY_NACK_MEMORY = 512;
|
|
22
|
+
const COMMENTARY_FLUSH_MS = 750;
|
|
23
|
+
const COMMENTARY_MAX_CHARS = 1_600;
|
|
24
|
+
const COMMENTARY_MAX_BYTES = 6_400;
|
|
25
|
+
const COMMENTARY_MAX_UPDATES = 32;
|
|
26
|
+
const COMMENTARY_DEDUPE_LIMIT = 512;
|
|
22
27
|
/** A relay attempt that failed only because no owner route exists yet. */
|
|
23
28
|
class RelayUnroutableError extends Error {
|
|
24
29
|
}
|
|
@@ -686,6 +691,8 @@ export class OwnerChannel {
|
|
|
686
691
|
const active = {
|
|
687
692
|
contact: sender.id, wireId: originWireId, requestId, outboundTail: receipt,
|
|
688
693
|
finalizing: false, updateCount: 0, updateDigests: new Set(), handledWireIds,
|
|
694
|
+
commentaryBuffer: '', commentaryCount: 0, commentaryKeys: new Set(),
|
|
695
|
+
commentaryDisabled: false,
|
|
689
696
|
};
|
|
690
697
|
this.activeRequests.set(requestId, active);
|
|
691
698
|
const cleanupDir = requestDir;
|
|
@@ -823,6 +830,8 @@ export class OwnerChannel {
|
|
|
823
830
|
const active = {
|
|
824
831
|
contact: sender.id, wireId, requestId, outboundTail: receipt, finalizing: false,
|
|
825
832
|
updateCount: 0, updateDigests: new Set(), handledWireIds: [wireId],
|
|
833
|
+
commentaryBuffer: '', commentaryCount: 0, commentaryKeys: new Set(),
|
|
834
|
+
commentaryDisabled: false,
|
|
826
835
|
};
|
|
827
836
|
this.activeRequests.set(requestId, active);
|
|
828
837
|
const task = this.complete(active, outbox, queued, activityCursor)
|
|
@@ -1007,7 +1016,7 @@ export class OwnerChannel {
|
|
|
1007
1016
|
const contact = await this.routableContact(route);
|
|
1008
1017
|
const rejection = !this.attachmentRecovery.integrity()
|
|
1009
1018
|
? 'attachment recovery state is unavailable'
|
|
1010
|
-
:
|
|
1019
|
+
: validateAttachmentRelaySelection(group.files, this.attachmentConfig);
|
|
1011
1020
|
if (rejection)
|
|
1012
1021
|
throw new Error(rejection);
|
|
1013
1022
|
const transactionId = this.requestId(`managed-agent-attachment:${handledWireIds.slice().sort().join(':')}`);
|
|
@@ -1034,7 +1043,7 @@ export class OwnerChannel {
|
|
|
1034
1043
|
}
|
|
1035
1044
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
1036
1045
|
retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
|
|
1037
|
-
const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
|
|
1046
|
+
const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig, { mimePolicy: 'report-only' });
|
|
1038
1047
|
const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
|
|
1039
1048
|
const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
|
|
1040
1049
|
try {
|
|
@@ -1197,6 +1206,95 @@ export class OwnerChannel {
|
|
|
1197
1206
|
let startedAt;
|
|
1198
1207
|
let phase = 'starting request';
|
|
1199
1208
|
let timer;
|
|
1209
|
+
const flushCommentary = () => {
|
|
1210
|
+
if (active.commentaryTimer)
|
|
1211
|
+
clearTimeout(active.commentaryTimer);
|
|
1212
|
+
active.commentaryTimer = undefined;
|
|
1213
|
+
const text = active.commentaryBuffer.trim();
|
|
1214
|
+
active.commentaryBuffer = '';
|
|
1215
|
+
if (!text || active.commentaryDisabled || active.finalizing)
|
|
1216
|
+
return;
|
|
1217
|
+
if (active.commentaryCount >= COMMENTARY_MAX_UPDATES) {
|
|
1218
|
+
active.commentaryDisabled = true;
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
let safe;
|
|
1222
|
+
try {
|
|
1223
|
+
safe = this.safeCommentary(text);
|
|
1224
|
+
}
|
|
1225
|
+
catch (error) {
|
|
1226
|
+
active.commentaryDisabled = true;
|
|
1227
|
+
this.logError('ACP commentary forwarding disabled for unsafe content', error);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const digest = createHash('sha256')
|
|
1231
|
+
.update(`owner-commentary\0${active.wireId}\0${safe}`).digest('hex');
|
|
1232
|
+
let sending;
|
|
1233
|
+
try {
|
|
1234
|
+
sending = this.conversations.beginSend(active.contact, digest, Date.now(), 0, 'all');
|
|
1235
|
+
}
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
if (error instanceof DuplicateSendError)
|
|
1238
|
+
return;
|
|
1239
|
+
active.commentaryDisabled = true;
|
|
1240
|
+
this.logError('ACP commentary forwarding disabled by dedupe state', error);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
active.commentaryCount++;
|
|
1244
|
+
active.outboundTail = active.outboundTail
|
|
1245
|
+
.then(async () => {
|
|
1246
|
+
try {
|
|
1247
|
+
if (!this.isEffectiveOwner(active.contact))
|
|
1248
|
+
throw new Error('initiating owner is no longer authorized');
|
|
1249
|
+
await this.send(active.contact, safe, active.wireId);
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
this.conversations.finishSend(sending.id, 'uncertain');
|
|
1253
|
+
throw new Error('ACP commentary delivery outcome is uncertain');
|
|
1254
|
+
}
|
|
1255
|
+
this.conversations.finishSend(sending.id, 'delivered');
|
|
1256
|
+
})
|
|
1257
|
+
.catch(error => this.logError('ACP commentary delivery failed', error));
|
|
1258
|
+
};
|
|
1259
|
+
const acceptCommentary = (event) => {
|
|
1260
|
+
if (active.commentaryDisabled || active.finalizing || event.turnId !== queued.promptId
|
|
1261
|
+
|| event.kind !== 'agent_text' || event.messagePhase !== 'commentary'
|
|
1262
|
+
|| event.replayed || event.origin?.kind !== 'owner'
|
|
1263
|
+
|| event.origin.requestId !== active.requestId
|
|
1264
|
+
|| typeof event.messageId !== 'string' || !event.messageId
|
|
1265
|
+
|| typeof event.text !== 'string' || !event.text)
|
|
1266
|
+
return;
|
|
1267
|
+
const key = createHash('sha256')
|
|
1268
|
+
.update(`${event.messageId}\0${event.text}`).digest('hex');
|
|
1269
|
+
if (active.commentaryKeys.has(key))
|
|
1270
|
+
return;
|
|
1271
|
+
active.commentaryKeys.add(key);
|
|
1272
|
+
if (active.commentaryKeys.size > COMMENTARY_DEDUPE_LIMIT)
|
|
1273
|
+
active.commentaryKeys.delete(active.commentaryKeys.values().next().value);
|
|
1274
|
+
let chars = Array.from(active.commentaryBuffer).length;
|
|
1275
|
+
let bytes = Buffer.byteLength(active.commentaryBuffer);
|
|
1276
|
+
for (const point of event.text) {
|
|
1277
|
+
const pointBytes = Buffer.byteLength(point);
|
|
1278
|
+
if (chars >= COMMENTARY_MAX_CHARS || bytes + pointBytes > COMMENTARY_MAX_BYTES) {
|
|
1279
|
+
flushCommentary();
|
|
1280
|
+
if (active.commentaryDisabled)
|
|
1281
|
+
return;
|
|
1282
|
+
chars = 0;
|
|
1283
|
+
bytes = 0;
|
|
1284
|
+
}
|
|
1285
|
+
active.commentaryBuffer += point;
|
|
1286
|
+
chars++;
|
|
1287
|
+
bytes += pointBytes;
|
|
1288
|
+
}
|
|
1289
|
+
if (/\n\s*\n$/u.test(active.commentaryBuffer)) {
|
|
1290
|
+
flushCommentary();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (!active.commentaryTimer) {
|
|
1294
|
+
active.commentaryTimer = setTimeout(flushCommentary, COMMENTARY_FLUSH_MS);
|
|
1295
|
+
active.commentaryTimer.unref?.();
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1200
1298
|
const reportProgress = () => {
|
|
1201
1299
|
const events = this.options.session.eventsSince(lastSeq);
|
|
1202
1300
|
lastSeq = Math.max(lastSeq, this.latestEventSeq(events));
|
|
@@ -1235,19 +1333,28 @@ export class OwnerChannel {
|
|
|
1235
1333
|
timer = setInterval(reportProgress, progressMs);
|
|
1236
1334
|
timer.unref();
|
|
1237
1335
|
};
|
|
1238
|
-
const unsubscribe =
|
|
1239
|
-
? this.options.session.subscribe(event =>
|
|
1240
|
-
|
|
1336
|
+
const unsubscribe = typeof this.options.session.subscribe === 'function'
|
|
1337
|
+
? this.options.session.subscribe(event => {
|
|
1338
|
+
startProgress(event);
|
|
1339
|
+
// Automatic commentary is an ACP phase extension. Other backends and
|
|
1340
|
+
// older adapters retain their established final-only behavior.
|
|
1341
|
+
if (this.options.session.backend === 'acp')
|
|
1342
|
+
acceptCommentary(event);
|
|
1343
|
+
})
|
|
1344
|
+
: () => undefined;
|
|
1241
1345
|
startProgress();
|
|
1242
1346
|
let result;
|
|
1243
1347
|
try {
|
|
1244
1348
|
result = await queued.completion;
|
|
1245
1349
|
}
|
|
1246
1350
|
finally {
|
|
1247
|
-
unsubscribe
|
|
1351
|
+
unsubscribe();
|
|
1248
1352
|
if (timer)
|
|
1249
1353
|
clearInterval(timer);
|
|
1354
|
+
if (active.commentaryTimer)
|
|
1355
|
+
clearTimeout(active.commentaryTimer);
|
|
1250
1356
|
}
|
|
1357
|
+
flushCommentary();
|
|
1251
1358
|
active.finalizing = true;
|
|
1252
1359
|
await active.outboundTail;
|
|
1253
1360
|
const output = result.output?.trim();
|
|
@@ -1268,6 +1375,20 @@ export class OwnerChannel {
|
|
|
1268
1375
|
for (const wire of active.handledWireIds)
|
|
1269
1376
|
this.state.remember(wire);
|
|
1270
1377
|
}
|
|
1378
|
+
/** Model-authored commentary only; raw protocol/tool data never reaches here. */
|
|
1379
|
+
safeCommentary(value) {
|
|
1380
|
+
const message = value.trim().normalize('NFC');
|
|
1381
|
+
if (!message)
|
|
1382
|
+
throw new Error('commentary is empty');
|
|
1383
|
+
if (Array.from(message).length > COMMENTARY_MAX_CHARS
|
|
1384
|
+
|| Buffer.byteLength(message) > COMMENTARY_MAX_BYTES)
|
|
1385
|
+
throw new Error('commentary batch exceeds its bounded size');
|
|
1386
|
+
if (/\u0000|[\u202a-\u202e\u2066-\u2069]/u.test(message))
|
|
1387
|
+
throw new Error('commentary contains unsafe control characters');
|
|
1388
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[_ -]?key|access[_ -]?token|authorization|password|secret)\s*[:=]|(?:chain of thought|private reasoning|internal reasoning)|^(?:stdout|stderr|tool (?:output|result)|command):/imu.test(message))
|
|
1389
|
+
throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
|
|
1390
|
+
return message;
|
|
1391
|
+
}
|
|
1271
1392
|
ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
|
|
1272
1393
|
const lines = [
|
|
1273
1394
|
'[fleet-owner]',
|
|
@@ -43,7 +43,7 @@ export class OwnerChannelState {
|
|
|
43
43
|
}
|
|
44
44
|
const CONVERSATION_LIMIT = 64;
|
|
45
45
|
const WIRE_ROUTE_LIMIT = 512;
|
|
46
|
-
const PROACTIVE_SEND_LIMIT =
|
|
46
|
+
const PROACTIVE_SEND_LIMIT = 2_048;
|
|
47
47
|
const PROACTIVE_MIN_INTERVAL_MS = 30_000;
|
|
48
48
|
const HEX_64_LOWER = /^[a-f0-9]{64}$/;
|
|
49
49
|
const CID = /^[A-Fa-f0-9]{64}$/;
|
|
@@ -158,7 +158,10 @@ export class OwnerConversationState {
|
|
|
158
158
|
if (candidates.length) {
|
|
159
159
|
if (candidates[1]?.lastInboundAt === candidates[0].lastInboundAt)
|
|
160
160
|
throw new Error('proactive owner route is ambiguous');
|
|
161
|
-
return {
|
|
161
|
+
return {
|
|
162
|
+
contact: candidates[0].contact, basis: 'last-inbound',
|
|
163
|
+
replyToWireId: candidates[0].lastInboundWireId,
|
|
164
|
+
};
|
|
162
165
|
}
|
|
163
166
|
if (canonical.size === 1)
|
|
164
167
|
return { contact: [...effective][0], basis: 'sole-owner' };
|
|
@@ -180,7 +183,7 @@ export class OwnerConversationState {
|
|
|
180
183
|
const recent = this.sends.filter(send => canonicalCid(send.contact) === canonical).slice(-128);
|
|
181
184
|
// 'all' scope serves wire-keyed idempotency: a crash replay must not
|
|
182
185
|
// deliver the same wire to a second owner after the route moved.
|
|
183
|
-
const scope = dedupe === 'all' ? this.sends
|
|
186
|
+
const scope = dedupe === 'all' ? this.sends : recent;
|
|
184
187
|
if (scope.some(send => send.digest === digest))
|
|
185
188
|
throw new DuplicateSendError('duplicate proactive owner message refused');
|
|
186
189
|
const last = recent.at(-1);
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -127,6 +127,11 @@ export declare class AcpSession implements SessionHandle {
|
|
|
127
127
|
private settleAutomatically;
|
|
128
128
|
private withinAutomaticBoundary;
|
|
129
129
|
private recordUpdate;
|
|
130
|
+
/**
|
|
131
|
+
* Codex ACP's phase extension is the only currently supported visibility
|
|
132
|
+
* signal. Never infer commentary from text, message order, or unknown meta.
|
|
133
|
+
*/
|
|
134
|
+
private codexMessagePhase;
|
|
130
135
|
/** Normalize every ACP update losslessly into the durable ledger. */
|
|
131
136
|
private recordConversationUpdate;
|
|
132
137
|
conversationPage(request?: {
|
package/dist/session/acp.js
CHANGED
|
@@ -14,6 +14,7 @@ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
|
|
|
14
14
|
/** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
|
|
15
15
|
const CONTROLLER_GRACE_MS = 12_000;
|
|
16
16
|
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
17
|
+
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
17
18
|
const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
|
|
18
19
|
/**
|
|
19
20
|
* Map typed prompt provenance to the conversation ledger's source vocabulary.
|
|
@@ -792,20 +793,29 @@ export class AcpSession {
|
|
|
792
793
|
}
|
|
793
794
|
recordUpdate(update) {
|
|
794
795
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
795
|
-
|
|
796
|
+
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
797
|
+
? this.codexMessagePhase(update) : undefined;
|
|
798
|
+
this.recordConversationUpdate(update, scheduled, messagePhase === 'commentary');
|
|
796
799
|
if (update.sessionUpdate === 'config_option_update')
|
|
797
800
|
this.captureRuntimeMetadata(update.configOptions);
|
|
798
801
|
switch (update.sessionUpdate) {
|
|
799
|
-
case 'agent_message_chunk':
|
|
800
|
-
|
|
802
|
+
case 'agent_message_chunk': {
|
|
803
|
+
const phase = messagePhase;
|
|
804
|
+
if (this.activeTurn && update.content.type === 'text'
|
|
805
|
+
&& phase !== 'commentary' && phase !== 'ambiguous')
|
|
801
806
|
this.activeTurn.output += update.content.text;
|
|
802
807
|
this.events.emit('agent_text', {
|
|
803
808
|
turnId: this.activeTurn?.id,
|
|
804
809
|
origin: this.activeTurn?.origin,
|
|
805
810
|
text: scheduled ? '[scheduled-loop output redacted]'
|
|
806
811
|
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
812
|
+
...(phase === 'commentary' || phase === 'final_answer'
|
|
813
|
+
? { messagePhase: phase } : {}),
|
|
814
|
+
...(typeof update.messageId === 'string' ? { messageId: update.messageId } : {}),
|
|
815
|
+
...(this.replaying ? { replayed: true } : {}),
|
|
807
816
|
});
|
|
808
817
|
break;
|
|
818
|
+
}
|
|
809
819
|
case 'agent_thought_chunk':
|
|
810
820
|
this.events.emit('thought', {
|
|
811
821
|
turnId: this.activeTurn?.id,
|
|
@@ -836,9 +846,26 @@ export class AcpSession {
|
|
|
836
846
|
break;
|
|
837
847
|
}
|
|
838
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Codex ACP's phase extension is the only currently supported visibility
|
|
851
|
+
* signal. Never infer commentary from text, message order, or unknown meta.
|
|
852
|
+
*/
|
|
853
|
+
codexMessagePhase(update) {
|
|
854
|
+
const meta = update._meta;
|
|
855
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta))
|
|
856
|
+
return undefined;
|
|
857
|
+
const codex = meta.codex;
|
|
858
|
+
if (!codex || typeof codex !== 'object' || Array.isArray(codex))
|
|
859
|
+
return undefined;
|
|
860
|
+
const phase = codex.phase;
|
|
861
|
+
if (phase === undefined)
|
|
862
|
+
return undefined;
|
|
863
|
+
return phase === 'commentary' || phase === 'final_answer' ? phase : 'ambiguous';
|
|
864
|
+
}
|
|
839
865
|
/** Normalize every ACP update losslessly into the durable ledger. */
|
|
840
|
-
recordConversationUpdate(update, scheduled) {
|
|
841
|
-
const normalized = normalizeSessionUpdate(update, scheduled ? { redactText: SCHEDULED_LOOP_REDACTION }
|
|
866
|
+
recordConversationUpdate(update, scheduled, commentary = false) {
|
|
867
|
+
const normalized = normalizeSessionUpdate(update, scheduled ? { redactText: SCHEDULED_LOOP_REDACTION }
|
|
868
|
+
: commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
|
|
842
869
|
this.conversation.appendSafe({
|
|
843
870
|
kind: normalized.kind,
|
|
844
871
|
sessionGeneration: this.sessionGeneration,
|
package/dist/session/events.js
CHANGED
|
@@ -24,7 +24,12 @@ export class SessionEvents {
|
|
|
24
24
|
this.events.shift();
|
|
25
25
|
try {
|
|
26
26
|
this.rotateIfNeeded();
|
|
27
|
-
|
|
27
|
+
// Owner-visible commentary remains live/in-memory for the authenticated
|
|
28
|
+
// bridge, but its plaintext must not become diagnostic state on disk.
|
|
29
|
+
const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
|
|
30
|
+
? { ...event, text: '[assistant commentary redacted]' }
|
|
31
|
+
: event;
|
|
32
|
+
appendFileSync(this.path, JSON.stringify(persisted) + '\n', { mode: 0o600 });
|
|
28
33
|
}
|
|
29
34
|
catch { /* diagnostics must never terminate a role */ }
|
|
30
35
|
for (const listener of this.listeners)
|
package/dist/session/types.d.ts
CHANGED
|
@@ -149,6 +149,15 @@ export interface SessionEvent {
|
|
|
149
149
|
toolCallId?: string;
|
|
150
150
|
permissionId?: string;
|
|
151
151
|
text?: string;
|
|
152
|
+
/**
|
|
153
|
+
* Adapter-authenticated presentation phase for assistant text. Only the
|
|
154
|
+
* exact Codex ACP marker is promoted; absence/unknown values stay unset.
|
|
155
|
+
*/
|
|
156
|
+
messagePhase?: 'commentary' | 'final_answer';
|
|
157
|
+
/** Stable adapter message/item id when supplied. */
|
|
158
|
+
messageId?: string;
|
|
159
|
+
/** True when the adapter is replaying history rather than emitting live work. */
|
|
160
|
+
replayed?: boolean;
|
|
152
161
|
title?: string;
|
|
153
162
|
status?: string;
|
|
154
163
|
stopReason?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.5",
|
|
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",
|