@ours.network/fleet 1.2.0-nightly.57 → 1.2.0-nightly.58

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 CHANGED
@@ -1929,4 +1929,4 @@ installer's normal generated version/integrity lock updated, then installer and
1929
1929
  App released. The currently pinned Fleet nightly.41 does not support v2 grants.
1930
1930
  These source changes do not publish, merge, or deploy any component.
1931
1931
 
1932
- See [ACP file sending](docs/acp-files.md) for the opt-in same-chat `send_file` flow and supported configurations.
1932
+ See [ACP file sending](docs/acp-files.md) for the same-chat `send_file` flow, available to every Agent by default, and its supported sessions.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { openExportFile } from '../file-delivery/reader.js';
2
+ import { openChatFile } from '../file-delivery/reader.js';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { connect } from 'node:net';
5
5
  import { access, mkdir, open, readFile } from 'node:fs/promises';
@@ -7,6 +7,9 @@ import { constants } from 'node:fs';
7
7
  import { basename, dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { MAX_CHUNK_BYTES, MAX_FRAME_BYTES, Wire } from './wire.js';
10
+ /** A current-chat refusal the agent may see; it names a reason, never a path or content. */
11
+ class ChatFileError extends Error {
12
+ }
10
13
  /** Runs inside exactly the harness OS sandbox. Only this process opens agent paths. */
11
14
  export async function runBridge(descriptorPath) {
12
15
  const descriptor = JSON.parse(await readFile(descriptorPath, 'utf8'));
@@ -81,13 +84,20 @@ export async function runBridge(descriptorPath) {
81
84
  const path = resolve(args.path);
82
85
  if (op === 'write-open')
83
86
  await mkdir(dirname(path), { recursive: true });
84
- const file = op === 'export-open' ? await openExportFile(args.root, process.cwd(), args.path) : await open(path, op === 'write-open' ? 'w' : 'r');
85
- const stat = await file.stat();
86
- handles.set(args.handle, { file, call, write: op === 'write-open', ...(op === 'export-open' ? { original: { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs } } : {}) });
87
- value =
88
- op === 'write-open'
89
- ? path
90
- : { filename: basename(path), size: (await file.stat()).size };
87
+ if (op === 'export-open') {
88
+ const opened = await openChatFile(process.cwd(), args.path).catch((error) => { throw new ChatFileError(error.message); });
89
+ const stat = await opened.file.stat();
90
+ handles.set(args.handle, { file: opened.file, call, write: false, original: { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs } });
91
+ value = { filename: basename(opened.path), size: stat.size, path: opened.path };
92
+ }
93
+ else {
94
+ const file = await open(path, op === 'write-open' ? 'w' : 'r');
95
+ handles.set(args.handle, { file, call, write: op === 'write-open' });
96
+ value =
97
+ op === 'write-open'
98
+ ? path
99
+ : { filename: basename(path), size: (await file.stat()).size };
100
+ }
91
101
  }
92
102
  else {
93
103
  const row = handles.get(args.handle);
@@ -110,7 +120,7 @@ export async function runBridge(descriptorPath) {
110
120
  if (row.original) {
111
121
  const stat = await row.file.stat();
112
122
  if (!stat.isFile() || stat.nlink !== 1 || stat.size !== row.original.size || stat.mtimeMs !== row.original.mtimeMs || stat.ctimeMs !== row.original.ctimeMs)
113
- throw Error('EXPORT_SOURCE_CHANGED');
123
+ throw new ChatFileError('CHAT_FILE_CHANGED');
114
124
  }
115
125
  const buffer = Buffer.alloc(MAX_CHUNK_BYTES);
116
126
  const { bytesRead } = await row.file.read(buffer);
@@ -136,8 +146,8 @@ export async function runBridge(descriptorPath) {
136
146
  }
137
147
  await wire.send({ kind: 'fs-result', id: raw.id, value });
138
148
  }
139
- catch {
140
- await wire.send({ kind: 'fs-result', id: raw.id, error: 'FILE_OPERATION_FAILED' });
149
+ catch (error) {
150
+ await wire.send({ kind: 'fs-result', id: raw.id, error: error instanceof ChatFileError ? error.message : 'FILE_OPERATION_FAILED' });
141
151
  }
142
152
  })
143
153
  .catch(fail);
@@ -1,4 +1,3 @@
1
- import type { ExportRoot } from '../file-delivery/reader.js';
2
1
  import type { FileExecutionContext } from '@ours.network/mcp/server';
3
2
  import type { ToolRequestExtra } from '@ours.network/mcp/dist/types/mcp/tool.js';
4
3
  import { type Wire } from './wire.js';
@@ -12,16 +11,21 @@ export declare class FileCallbacks implements FileExecutionContext {
12
11
  private rpc;
13
12
  canRead(path: string, extra: ToolRequestExtra): Promise<boolean>;
14
13
  read(path: string, extra: ToolRequestExtra): Promise<{
14
+ path?: string | undefined;
15
15
  filename: any;
16
16
  size: any;
17
17
  body: ReadableStream<Uint8Array<ArrayBufferLike>>;
18
18
  close: () => Promise<void>;
19
19
  }>;
20
- readExport(path: string, root: ExportRoot, extra: ToolRequestExtra): Promise<{
20
+ /** Current-chat source: integrity-checked by the bridge, with its real path. */
21
+ readChatFile(path: string, extra: ToolRequestExtra): Promise<{
22
+ path?: string | undefined;
21
23
  filename: any;
22
24
  size: any;
23
25
  body: ReadableStream<Uint8Array<ArrayBufferLike>>;
24
26
  close: () => Promise<void>;
27
+ } & {
28
+ path: string;
25
29
  }>;
26
30
  private openRead;
27
31
  write(path: string, body: ReadableStream<Uint8Array>, extra: ToolRequestExtra): Promise<{
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { MAX_CHUNK_BYTES } from './wire.js';
3
+ /** Bridge refusals of a current-chat file that are safe to relay verbatim. */
4
+ const CHAT_FILE_REFUSAL = /^(INVALID_CHAT_FILE_PATH|CHAT_FILE_[A-Z_]{1,40}|FILE_EXCEEDS_20_MIB)$/;
3
5
  /** Call-scoped callbacks carry no daemon credentials or upload receipts. */
4
6
  export class FileCallbacks {
5
7
  wire;
@@ -16,7 +18,7 @@ export class FileCallbacks {
16
18
  this.pending.delete(frame.id);
17
19
  clearTimeout(p.timer);
18
20
  if (frame.error)
19
- p.reject(Error('AGENT_FILE_OPERATION_FAILED'));
21
+ p.reject(Error(typeof frame.error === 'string' && CHAT_FILE_REFUSAL.test(frame.error) ? frame.error : 'AGENT_FILE_OPERATION_FAILED'));
20
22
  else
21
23
  p.resolve(frame.value);
22
24
  }
@@ -61,16 +63,18 @@ export class FileCallbacks {
61
63
  async read(path, extra) {
62
64
  return this.openRead(path, extra);
63
65
  }
64
- async readExport(path, root, extra) {
65
- return this.openRead(path, extra, root);
66
+ /** Current-chat source: integrity-checked by the bridge, with its real path. */
67
+ async readChatFile(path, extra) {
68
+ return this.openRead(path, extra, true);
66
69
  }
67
- async openRead(path, extra, root) {
70
+ async openRead(path, extra, chat = false) {
68
71
  const call = String(extra.requestId), handle = randomUUID();
69
- const meta = await this.rpc(call, root ? 'export-open' : 'read-open', { path, handle, ...(root ? { root } : {}) }, extra.signal);
72
+ const meta = await this.rpc(call, chat ? 'export-open' : 'read-open', { path, handle }, extra.signal);
70
73
  if (!meta ||
71
74
  typeof meta.filename !== 'string' ||
72
75
  !Number.isSafeInteger(meta.size) ||
73
- meta.size < 0)
76
+ meta.size < 0 ||
77
+ (chat && typeof meta.path !== 'string'))
74
78
  throw Error('INVALID_FILE_METADATA');
75
79
  let closed = false;
76
80
  const close = async () => {
@@ -101,7 +105,7 @@ export class FileCallbacks {
101
105
  },
102
106
  cancel: close,
103
107
  });
104
- return { filename: meta.filename, size: meta.size, body, close };
108
+ return { filename: meta.filename, size: meta.size, body, close, ...(chat ? { path: meta.path } : {}) };
105
109
  }
106
110
  async write(path, body, extra) {
107
111
  const call = String(extra.requestId), handle = randomUUID();
@@ -1,13 +1,10 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
3
- import { homedir } from 'node:os';
1
+ import { readFileSync } from 'node:fs';
4
2
  import { fileURLToPath } from 'node:url';
5
- import { resolveBundledAcpAgent } from '../harness/acp-agent.js';
6
3
  /** Do not pass the supervisor's ours connection credentials to harness children. */
7
4
  export function managedChildEnvironment(input) {
8
5
  const out = {};
9
6
  for (const [key, value] of Object.entries(input)) {
10
- if (key === 'FLEET_CURRENT_CHAT_FILES' || value === undefined || (/^OURS_/i.test(key) && !key.startsWith('OURS_FLEET_')))
7
+ if (value === undefined || (/^OURS_/i.test(key) && !key.startsWith('OURS_FLEET_')))
11
8
  continue;
12
9
  out[key] = value;
13
10
  }
@@ -17,8 +14,6 @@ export function managedChildEnvironment(input) {
17
14
  /** Replace ours only; retain the user's ordinary harness configuration and plugins. */
18
15
  export function prepareManagedHarness(role, stateDir, cwd, descriptor, env) {
19
16
  const child = managedChildEnvironment({ ...process.env, ...env });
20
- if (role.file_delivery?.enabled)
21
- qualifyFileHarness(role, cwd, child);
22
17
  const bridge = fileURLToPath(new URL('./bridge.js', import.meta.url));
23
18
  const server = {
24
19
  name: 'ours',
@@ -75,42 +70,3 @@ export function managedClaudeMeta(base) {
75
70
  },
76
71
  };
77
72
  }
78
- /** Bounded stock-adapter support; opt-in never silently erases unrelated tools. */
79
- function qualifyFileHarness(role, cwd, child) {
80
- if (process.platform !== 'linux')
81
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: Linux required');
82
- if (role.session !== 'acp' || role.session_options?.acp?.command)
83
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: stock ACP adapter required');
84
- if (role.harness === 'claude-code') {
85
- if (resolveBundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp').version !== '0.63.0')
86
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: qualified Claude ACP 0.63.0 required');
87
- const options = role.harness_options ?? {}, servers = options.mcp_servers;
88
- if (options.mcp_servers_only !== true || !servers || Object.keys(servers).length !== 1 || !servers.ours)
89
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: Claude requires explicit mcp_servers_only and a single ours connector');
90
- return;
91
- }
92
- if (role.harness !== 'codex' || !child.OURS_FLEET_CODEX_ACP_MANIFEST || child.OURS_FLEET_CODEX_DISABLE_INHERITED_MCP === '1')
93
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: managed Codex inventory proxy required');
94
- const config = JSON.parse(child.CODEX_CONFIG ?? '{}');
95
- if (config.mcp_servers?.ours?.enabled === false)
96
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: ours MCP disabled');
97
- // Public inventory sees effective injected config. Before replacing ours, refuse
98
- // inherited TOML connector configuration, including disabled/project/profile
99
- // entries. No TOML guessing or override of an operator's explicit disable.
100
- const home = child.CODEX_HOME ?? join(child.HOME ?? homedir(), '.codex');
101
- const paths = [join(home, 'config.toml')];
102
- const profile = role.harness_options?.profile;
103
- if (typeof profile === 'string')
104
- paths.push(join(home, profile + '.config.toml'));
105
- for (let p = resolve(cwd); p !== resolve(child.HOME ?? homedir());) {
106
- paths.push(join(p, '.codex/config.toml'));
107
- const next = dirname(p);
108
- if (next === p)
109
- break;
110
- p = next;
111
- }
112
- for (const path of new Set(paths))
113
- if (existsSync(path) && /mcp_servers|mcpServers/.test(readFileSync(path, 'utf8')))
114
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: inherited Codex MCP configuration requires inventory support before replacement');
115
- child.FLEET_CURRENT_CHAT_FILES = '1';
116
- }
@@ -12,7 +12,6 @@ export interface EndpointOptions {
12
12
  client: OursClient;
13
13
  identities: ApplicationIdentityStore;
14
14
  remoteDaemonFiles: boolean;
15
- currentChatFileDirectory?: string;
16
15
  currentChatFile?: (input: FileDeliveryInput, extra: ToolRequestExtra, files: FileCallbacks) => Promise<DeliveredFile>;
17
16
  }
18
17
  export declare function startMcpEndpoint(options: EndpointOptions): Promise<{
@@ -7,8 +7,9 @@ import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
7
7
  import { FileCallbacks } from './file-rpc.js';
8
8
  import { Wire } from './wire.js';
9
9
  export async function startMcpEndpoint(options) {
10
+ // Every managed Agent offers current-chat delivery, so a package without the contract is a broken install, not a quiet downgrade.
10
11
  if (options.currentChatFile && managedMcp.MANAGED_FILE_DELIVERY_VERSION !== 1)
11
- throw Error('INCOMPATIBLE_MANAGED_FILE_DELIVERY');
12
+ throw Error('INCOMPATIBLE_MANAGED_FILE_DELIVERY: installed @ours.network/mcp lacks MANAGED_FILE_DELIVERY_VERSION 1; reinstall Fleet with its pinned @ours.network/mcp');
12
13
  const sockets = new Set();
13
14
  const server = createServer((socket) => {
14
15
  if (sockets.size >= 4) {
@@ -36,7 +37,7 @@ export async function startMcpEndpoint(options) {
36
37
  };
37
38
  const mcp = createManagedOursMcpServer(options.client, 'managed-v1', options.identities, {
38
39
  remoteDaemonFiles: options.remoteDaemonFiles,
39
- ...(options.currentChatFile ? { currentChatFileDirectory: options.currentChatFileDirectory, currentChatFile: (input, extra) => options.currentChatFile(input, extra, files) } : {}),
40
+ ...(options.currentChatFile ? { currentChatFile: (input, extra) => options.currentChatFile(input, extra, files) } : {}),
40
41
  fileContext: files,
41
42
  admit: async () => {
42
43
  if (!authenticated || inFlight >= 32)
@@ -1,6 +1,8 @@
1
1
  import type { AgentSession } from '../session/types.js';
2
+ import { ArtifactStore } from '../file-delivery/store.js';
2
3
  import type { ResolvedRole } from '../config.js';
3
4
  import { AgentOursRuntime } from './runtime.js';
5
+ import { startMcpEndpoint } from './mcp-endpoint.js';
4
6
  export declare const privateRuntimeRoot: () => string;
5
7
  export interface ManagedAgentService {
6
8
  runtime: AgentOursRuntime;
@@ -9,6 +11,19 @@ export interface ManagedAgentService {
9
11
  setFileDelivery?(sender: AgentSession['sendFileToChat']): void;
10
12
  close(terminal: boolean): Promise<void>;
11
13
  }
14
+ /**
15
+ * `send_file` without a contact, offered to every managed Agent with no Agent
16
+ * setting and no export directory. A backend without a tracked ACP turn gets a
17
+ * precise refusal instead of a missing tool.
18
+ */
19
+ export declare function currentChatFileHandler(options: {
20
+ role: Pick<ResolvedRole, 'harness' | 'session' | 'permissions'>;
21
+ cwd: string;
22
+ store: Pick<ArtifactStore, 'copy'>;
23
+ sender: () => AgentSession['sendFileToChat'];
24
+ /** Fleet's own credentials and stored attachments are never chat content. */
25
+ privateState: string[];
26
+ }): NonNullable<Parameters<typeof startMcpEndpoint>[0]['currentChatFile']>;
12
27
  /** Only trusted launch orchestration may write this descriptor, never the child. */
13
28
  export declare function storeRoomSecret(role: ResolvedRole): void;
14
29
  export declare function storeTemporaryLaunch(role: ResolvedRole, action: string): void;
@@ -1,4 +1,4 @@
1
- import { pinExportRoot, exportPath, ensureExportDirectory } from '../file-delivery/reader.js';
1
+ import { chatFilePath } from '../file-delivery/reader.js';
2
2
  import { ArtifactStore } from '../file-delivery/store.js';
3
3
  import { resolve, basename, extname } from 'node:path';
4
4
  import { socketPath as privateSocketPath } from '../socket-path.js';
@@ -16,6 +16,42 @@ import { atomicPrivateWrite, binderKey } from './state.js';
16
16
  import { AgentOursRuntime } from './runtime.js';
17
17
  import { startMcpEndpoint } from './mcp-endpoint.js';
18
18
  export const privateRuntimeRoot = () => join(stateRoot(), 'private-ours');
19
+ /**
20
+ * `send_file` without a contact, offered to every managed Agent with no Agent
21
+ * setting and no export directory. A backend without a tracked ACP turn gets a
22
+ * precise refusal instead of a missing tool.
23
+ */
24
+ export function currentChatFileHandler(options) {
25
+ const { role, cwd, store } = options, privateState = options.privateState.map(p => resolve(p));
26
+ const isPrivate = (path) => privateState.some(p => path === p || path.startsWith(p + '/'));
27
+ return async (input, extra, files) => {
28
+ if (role.session !== 'acp' || !['codex', 'claude-code'].includes(role.harness))
29
+ throw Error(`CURRENT_CHAT_DELIVERY_UNAVAILABLE: sending a file to this chat requires a Codex or Claude ACP session; this Agent runs ${role.harness}/${role.session}. Pass contact to send through Ours instead`);
30
+ const sender = options.sender();
31
+ if (!sender)
32
+ throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE: this session has no ACP chat to attach to');
33
+ if (isPrivate(chatFilePath(cwd, input.path)))
34
+ throw Error('PRIVATE_STATE_CANNOT_BE_SENT'); // before approval/read
35
+ const name = input.filename ?? basename(input.path);
36
+ if (!name || Buffer.byteLength(name) > 240 || /[\/\\\x00-\x1f]/.test(name) || name === '.' || name === '..')
37
+ throw Error('INVALID_FILENAME');
38
+ const mime = input.mime ?? { '.txt': 'text/plain', '.md': 'text/markdown', '.pdf': 'application/pdf', '.json': 'application/json', '.html': 'text/html', '.png': 'image/png' }[extname(name).toLowerCase()] ?? 'application/octet-stream';
39
+ if (mime.length > 200 || !/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(mime))
40
+ throw Error('INVALID_MIME');
41
+ return sender(input, extra.signal, async (binding) => {
42
+ const source = await files.readChatFile(input.path, extra);
43
+ try {
44
+ // The bridge reports the opened file's real path, so a symlinked ancestor cannot reach private state.
45
+ if (isPrivate(source.path))
46
+ throw Error('PRIVATE_STATE_CANNOT_BE_SENT');
47
+ return await store.copy(source, name, mime, binding, extra.signal);
48
+ }
49
+ finally {
50
+ await source.close();
51
+ }
52
+ }, role.permissions);
53
+ };
54
+ }
19
55
  function roomSecretPath(role) {
20
56
  const startup = role.roomMemberStartup;
21
57
  return join(privateRuntimeRoot(), 'room-inputs', binderKey(startup.room_identity_cid, JSON.stringify([role.name, startup.invite_id])) + '.json');
@@ -98,7 +134,6 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
98
134
  }
99
135
  const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance);
100
136
  let closeClient;
101
- let exportRoot;
102
137
  let fileSender;
103
138
  let partialEndpoint;
104
139
  try {
@@ -217,17 +252,6 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
217
252
  mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
218
253
  const capability = randomBytes(32).toString('hex'), socket = privateSocketPath(join(bridgeDir, `g${generation}.sock`));
219
254
  const cwd = role.cwd && existsSync(role.cwd) ? resolve(role.cwd) : resolve(stateDir);
220
- if (role.file_delivery?.enabled) {
221
- if (role.session !== 'acp' || !['codex', 'claude-code'].includes(role.harness))
222
- throw Error('FILE_DELIVERY_REQUIRES_CODEX_OR_CLAUDE_ACP');
223
- const rootPath = resolve(cwd, role.file_delivery.directory);
224
- const forbidden = [privateRuntimeRoot(), join(stateRoot(), 'private-file-delivery'), profile.configPath, profile.credentialPath];
225
- if (forbidden.some(p => rootPath === resolve(p) || rootPath.startsWith(resolve(p) + '/') || resolve(p).startsWith(rootPath + '/')))
226
- throw Error('PRIVATE_STATE_CANNOT_BE_EXPORTED');
227
- await ensureExportDirectory(cwd, role.file_delivery.directory);
228
- exportRoot = await pinExportRoot(rootPath);
229
- }
230
- const artifactStore = new ArtifactStore(stateDir);
231
255
  const endpoint = await startMcpEndpoint({
232
256
  socket,
233
257
  capability,
@@ -236,26 +260,10 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
236
260
  client,
237
261
  identities,
238
262
  remoteDaemonFiles: true,
239
- ...(exportRoot ? { currentChatFileDirectory: role.file_delivery.directory, currentChatFile: (async (input, extra, files) => {
240
- if (!fileSender || !exportRoot)
241
- throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE');
242
- exportPath(exportRoot.root, cwd, input.path); // validate before approval/read
243
- const name = input.filename ?? basename(input.path);
244
- if (!name || Buffer.byteLength(name) > 240 || /[\/\\\x00-\x1f]/.test(name) || name === '.' || name === '..')
245
- throw Error('INVALID_FILENAME');
246
- const mime = input.mime ?? { '.txt': 'text/plain', '.md': 'text/markdown', '.pdf': 'application/pdf', '.json': 'application/json', '.html': 'text/html', '.png': 'image/png' }[extname(name).toLowerCase()] ?? 'application/octet-stream';
247
- if (mime.length > 200 || !/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(mime))
248
- throw Error('INVALID_MIME');
249
- return fileSender(input, extra.signal, async (binding) => {
250
- const source = await files.readExport(input.path, exportRoot.root, extra);
251
- try {
252
- return await artifactStore.copy(source, name, mime, binding, extra.signal);
253
- }
254
- finally {
255
- await source.close();
256
- }
257
- }, role.permissions);
258
- }) } : {}),
263
+ currentChatFile: currentChatFileHandler({
264
+ role, cwd, store: new ArtifactStore(stateDir), sender: () => fileSender,
265
+ privateState: [privateRuntimeRoot(), join(stateRoot(), 'private-file-delivery'), profile.configPath, profile.credentialPath],
266
+ }),
259
267
  });
260
268
  partialEndpoint = endpoint;
261
269
  const descriptor = join(bridgeDir, 'descriptor.json');
@@ -269,7 +277,6 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
269
277
  try {
270
278
  fileSender = undefined;
271
279
  await endpoint.close();
272
- await exportRoot?.handle.close();
273
280
  if (terminal)
274
281
  await runtime.terminal();
275
282
  else
@@ -289,7 +296,6 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
289
296
  catch (error) {
290
297
  try {
291
298
  await partialEndpoint?.close();
292
- await exportRoot?.handle.close();
293
299
  }
294
300
  finally {
295
301
  try {
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.57",
3
- "buildId": "e1efd9a9bfd5",
4
- "commit": "c94b8b63da1f7f0bab4ba1dec0c43a9e455b2366",
2
+ "version": "1.2.0-nightly.58",
3
+ "buildId": "724339272df9",
4
+ "commit": "adcfbe7c6d818a2068b0d22274c39bc66f159c9a",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-10T18:15:52.108Z",
6
+ "builtAt": "2026-10-13T06:33:11.731Z",
7
7
  "capabilities": [
8
8
  "managed-cli.codex-reasoning-effort-v1",
9
9
  "managed-cli.setup-v1",
package/dist/config.d.ts CHANGED
@@ -42,10 +42,16 @@ export type MonitorInterrupt = boolean | 'after_tool';
42
42
  */
43
43
  export declare const MANAGED_CLI_WORKFLOW_IDS: readonly ["task-workflow"];
44
44
  export type ManagedCliWorkflowId = (typeof MANAGED_CLI_WORKFLOW_IDS)[number];
45
+ /** Retired opt-in. Both keys are accepted so existing manifests load, and neither has any effect. */
45
46
  export interface FileDeliveryConfig {
46
- enabled: boolean;
47
- directory: string;
47
+ enabled?: boolean;
48
+ directory?: string;
48
49
  }
50
+ /**
51
+ * Current-chat file delivery is on for every Agent and takes the path the Agent
52
+ * provides. A legacy block is shape-checked only: `enabled: false` does not turn
53
+ * delivery off and `directory` does not restrict or create anything.
54
+ */
49
55
  export declare function validateFileDelivery(value: unknown): FileDeliveryConfig | undefined;
50
56
  export interface CommonPermissions {
51
57
  approval: ApprovalMode;
@@ -159,7 +165,7 @@ export interface RoleConfig {
159
165
  auth_proxy?: Partial<AuthProxyConfig>;
160
166
  /** Fleet-prepared CLI workflows this role may run outside its command sandbox. */
161
167
  managed_cli?: ManagedCliWorkflowId[];
162
- /** Explicit per-Agent export opt-in; not inherited from manifest defaults. */
168
+ /** Retired and ignored; current-chat file delivery needs no Agent setting. */
163
169
  file_delivery?: FileDeliveryConfig;
164
170
  }
165
171
  export type AgentSelection<T extends Record<string, unknown> = Record<string, unknown>> = {
@@ -184,7 +190,7 @@ export interface AgentDefinition {
184
190
  auth_proxy?: Partial<AuthProxyConfig>;
185
191
  /** Fleet-prepared CLI workflows this Agent may run outside its command sandbox. */
186
192
  managed_cli?: ManagedCliWorkflowId[];
187
- /** Explicit per-Agent export opt-in; not inherited from manifest defaults. */
193
+ /** Retired and ignored; current-chat file delivery needs no Agent setting. */
188
194
  file_delivery?: FileDeliveryConfig;
189
195
  /** Scheduled turns scoped only to temporary launches; persistent Agents reject this field. */
190
196
  loops?: AgentLoopsConfig;
package/dist/config.js CHANGED
@@ -29,13 +29,18 @@ export const NOTIFY_EVENT_TYPES = [
29
29
  * Agent; never inferred from a role's name or persona, and never inherited.
30
30
  */
31
31
  export const MANAGED_CLI_WORKFLOW_IDS = ['task-workflow'];
32
+ /**
33
+ * Current-chat file delivery is on for every Agent and takes the path the Agent
34
+ * provides. A legacy block is shape-checked only: `enabled: false` does not turn
35
+ * delivery off and `directory` does not restrict or create anything.
36
+ */
32
37
  export function validateFileDelivery(value) {
33
38
  if (value === undefined)
34
39
  return undefined;
35
40
  if (!isPlainObject(value) || Object.keys(value).some(k => !['enabled', 'directory'].includes(k))
36
- || typeof value.enabled !== 'boolean' || typeof value.directory !== 'string'
37
- || !value.directory || value.directory.split('/').some(p => !p || p.startsWith('.') || /[\\\x00-\x1f]/.test(p)))
38
- throw new ConfigError('file_delivery requires enabled:boolean and a relative non-hidden directory');
41
+ || (value.enabled !== undefined && typeof value.enabled !== 'boolean')
42
+ || (value.directory !== undefined && typeof value.directory !== 'string'))
43
+ throw new ConfigError('file_delivery is retired and ignored; it accepts only the legacy enabled:boolean and directory:string keys');
39
44
  return value;
40
45
  }
41
46
  /** Default wake sources when a role does not list its own. */
@@ -1,17 +1,15 @@
1
1
  import { type FileHandle } from 'node:fs/promises';
2
2
  export declare const MAX_FILE_BYTES: number;
3
- export interface ExportRoot {
4
- path: string;
5
- dev: number;
6
- ino: number;
7
- }
8
3
  /** Pin every component; neither trusted root ancestors nor agent components follow symlinks. */
9
4
  export declare function openDirectory(path: string): Promise<FileHandle>;
10
- export declare function pinExportRoot(path: string): Promise<{
11
- root: ExportRoot;
12
- handle: FileHandle;
5
+ /** The agent's provided path, resolved from its working directory; no export subtree. */
6
+ export declare function chatFilePath(cwd: string, input: string): string;
7
+ /**
8
+ * Runs in the harness bridge, never in the supervisor's OS context. Ancestor
9
+ * symlinks resolve normally; the named file itself must be one regular,
10
+ * singly-linked file within the size limit. `path` is the opened file's real path.
11
+ */
12
+ export declare function openChatFile(cwd: string, input: string): Promise<{
13
+ file: FileHandle;
14
+ path: string;
13
15
  }>;
14
- export declare function exportPath(root: ExportRoot, cwd: string, input: string): string[];
15
- /** Runs in the harness bridge, never in the supervisor's OS context. */
16
- export declare function openExportFile(root: ExportRoot, cwd: string, input: string): Promise<FileHandle>;
17
- export declare function ensureExportDirectory(cwd: string, directory: string): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { constants } from 'node:fs';
2
- import { mkdir, open } from 'node:fs/promises';
3
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { open, readlink } from 'node:fs/promises';
3
+ import { isAbsolute, resolve } from 'node:path';
4
4
  export const MAX_FILE_BYTES = 20 * 1024 * 1024;
5
5
  const FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
6
6
  /** Pin every component; neither trusted root ancestors nor agent components follow symlinks. */
@@ -21,74 +21,43 @@ export async function openDirectory(path) {
21
21
  throw error;
22
22
  }
23
23
  }
24
- export async function pinExportRoot(path) {
25
- const handle = await openDirectory(path);
24
+ /** The agent's provided path, resolved from its working directory; no export subtree. */
25
+ export function chatFilePath(cwd, input) {
26
+ if (typeof input !== 'string' || !input || input.length > 4096 || /[\x00-\x1f]/.test(input))
27
+ throw Error('INVALID_CHAT_FILE_PATH');
28
+ return resolve(cwd, input);
29
+ }
30
+ /**
31
+ * Runs in the harness bridge, never in the supervisor's OS context. Ancestor
32
+ * symlinks resolve normally; the named file itself must be one regular,
33
+ * singly-linked file within the size limit. `path` is the opened file's real path.
34
+ */
35
+ export async function openChatFile(cwd, input) {
36
+ if (process.platform !== 'linux')
37
+ throw Error('CHAT_FILE_REQUIRES_LINUX');
38
+ let file;
26
39
  try {
27
- const stat = await handle.stat();
28
- if (stat.uid !== process.getuid?.() || (stat.mode & 0o022))
29
- throw Error('EXPORT_ROOT_NOT_PRIVATE_OWNED');
30
- return { root: { path, dev: stat.dev, ino: stat.ino }, handle };
40
+ file = await open(chatFilePath(cwd, input), FLAGS);
31
41
  }
32
42
  catch (error) {
33
- await handle.close();
34
- throw error;
35
- }
36
- }
37
- export function exportPath(root, cwd, input) {
38
- if (!input || input.length > 4096 || /[\\\x00-\x1f]/.test(input) || input.split('/').some(p => p === '.' || p === '..' || !p) && !isAbsolute(input))
39
- throw Error('INVALID_EXPORT_PATH');
40
- if (input.split('/').some(p => p === '..' || p === '.'))
41
- throw Error('INVALID_EXPORT_PATH');
42
- const path = relative(root.path, resolve(cwd, input));
43
- const parts = path.split('/');
44
- if (!path || isAbsolute(path) || parts.some(p => !p || p === '..' || p === '.' || p.startsWith('.') || /^(credentials?|secrets?|tokens?)(\.|$)/i.test(p)))
45
- throw Error('OUTSIDE_EXPORT_DIRECTORY');
46
- return parts;
47
- }
48
- /** Runs in the harness bridge, never in the supervisor's OS context. */
49
- export async function openExportFile(root, cwd, input) {
50
- const parts = exportPath(root, cwd, input);
51
- let dir = await openDirectory(root.path);
52
- try {
53
- const stat = await dir.stat();
54
- if (stat.dev !== root.dev || stat.ino !== root.ino)
55
- throw Error('EXPORT_ROOT_CHANGED');
56
- for (const part of parts.slice(0, -1)) {
57
- const next = await open(`/proc/self/fd/${dir.fd}/${part}`, FLAGS | constants.O_DIRECTORY);
58
- await dir.close();
59
- dir = next;
60
- }
61
- const file = await open(`/proc/self/fd/${dir.fd}/${parts.at(-1)}`, FLAGS);
62
- try {
63
- const stat = await file.stat();
64
- if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.nlink !== 1 || stat.size > MAX_FILE_BYTES)
65
- throw Error('INVALID_EXPORT_FILE');
66
- return file;
67
- }
68
- catch (error) {
69
- await file.close();
43
+ const code = error.code;
44
+ if (error instanceof Error && error.message === 'INVALID_CHAT_FILE_PATH')
70
45
  throw error;
71
- }
72
- }
73
- finally {
74
- await dir.close();
46
+ throw Error(code === 'ELOOP' ? 'CHAT_FILE_IS_SYMLINK' : code === 'ENOENT' || code === 'ENOTDIR' ? 'CHAT_FILE_NOT_FOUND'
47
+ : code === 'EACCES' || code === 'EPERM' ? 'CHAT_FILE_NOT_READABLE' : 'CHAT_FILE_OPEN_FAILED');
75
48
  }
76
- }
77
- export async function ensureExportDirectory(cwd, directory) {
78
- let dir = await openDirectory(cwd);
79
49
  try {
80
- for (const part of directory.split('/')) {
81
- if (!part || part.startsWith('.') || /[\\\x00-\x1f]/.test(part))
82
- throw Error('INVALID_EXPORT_DIRECTORY');
83
- const path = `/proc/self/fd/${dir.fd}/${part}`;
84
- await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
85
- throw error; });
86
- const next = await open(path, FLAGS | constants.O_DIRECTORY);
87
- await dir.close();
88
- dir = next;
89
- }
50
+ const stat = await file.stat();
51
+ if (!stat.isFile())
52
+ throw Error('CHAT_FILE_NOT_REGULAR');
53
+ if (stat.nlink !== 1)
54
+ throw Error('CHAT_FILE_HARDLINKED');
55
+ if (stat.size > MAX_FILE_BYTES)
56
+ throw Error('FILE_EXCEEDS_20_MIB');
57
+ return { file, path: await readlink(`/proc/self/fd/${file.fd}`) };
90
58
  }
91
- finally {
92
- await dir.close();
59
+ catch (error) {
60
+ await file.close();
61
+ throw error;
93
62
  }
94
63
  }
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { CodexFileInventory } from './codex-file-inventory.js';
3
2
  import { spawn } from 'node:child_process';
4
3
  import { createRequire } from 'node:module';
5
4
  import { createInterface } from 'node:readline';
@@ -447,7 +446,6 @@ export function runCodexAppServerProxy() {
447
446
  });
448
447
  }
449
448
  };
450
- const inventory = process.env.FLEET_CURRENT_CHAT_FILES === '1' ? new CodexFileInventory(writeToChild, writeToClient) : undefined;
451
449
  const recovery = new CodexTerminalRecovery({
452
450
  sendToCodex: writeToChild,
453
451
  emitToClient: writeToClient,
@@ -458,7 +456,6 @@ export function runCodexAppServerProxy() {
458
456
  return;
459
457
  finished = true;
460
458
  recovery.close();
461
- inventory?.close();
462
459
  input.close();
463
460
  process.stdin.destroy();
464
461
  // stdout/stderr are often pipes under Fleet and tests. Setting exitCode
@@ -491,11 +488,10 @@ export function runCodexAppServerProxy() {
491
488
  return;
492
489
  const rewritten = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp, managedOursTools);
493
490
  recovery.observeClientLine(rewritten);
494
- if (!inventory || inventory.observeClientLine(rewritten))
495
- writeToChild(rewritten);
491
+ writeToChild(rewritten);
496
492
  });
497
493
  output.on('line', line => {
498
- if (!finished && (!inventory || inventory.observeServerLine(line)) && recovery.observeServerLine(line))
494
+ if (!finished && recovery.observeServerLine(line))
499
495
  writeToClient(line);
500
496
  });
501
497
  input.once('close', () => {
@@ -262,7 +262,6 @@ function codexAcpEnvironment(role, dirs) {
262
262
  if (!source)
263
263
  throw new Error('Codex app-server proxy is missing; rebuild ours-fleet');
264
264
  writeFileSync(proxyModule, readFileSync(source, 'utf8'), { mode: 0o600 });
265
- writeFileSync(join(runtimeDir, 'codex-file-inventory.js'), readFileSync(join(dirname(source), 'codex-file-inventory.js'), 'utf8'), { mode: 0o600 });
266
265
  const windows = process.platform === 'win32';
267
266
  const command = join(runtimeDir, windows ? 'codex-app-server-proxy.cmd' : 'codex-app-server-proxy');
268
267
  const script = windows
@@ -483,7 +482,7 @@ export function makeCodexAdapter(exec = realExec, transport, nativeTransport) {
483
482
  catch (error) {
484
483
  throw new Error(`Codex ACP runtime check failed: ${error.message}; check CODEX_PATH or reinstall Fleet with optional dependencies`);
485
484
  }
486
- const minimum = role.file_delivery?.enabled ? '0.159.0' : role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
485
+ const minimum = role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
487
486
  ? '0.153.3' : '0.145.0';
488
487
  if (!codexVersionAtLeast(runtime.version, minimum))
489
488
  throw new Error(`${role.model ?? 'Codex ACP'} requires a newer Codex (>=${minimum}): ACP selects ${runtime.executable} (${runtime.version}); upgrade Fleet or set CODEX_PATH to a supported executable in the agent/service environment`);
package/docs/acp-files.md CHANGED
@@ -1,22 +1,24 @@
1
1
  # Sending files in an ACP chat
2
2
 
3
- An opted-in managed Agent uses the existing Ours `send_file` tool:
3
+ Every managed Agent can attach a file to its current ACP chat with the existing
4
+ Ours `send_file` tool. No Agent setting turns this on and no export directory is
5
+ configured or created:
4
6
 
5
7
  ```json
6
- {"path":"deliverables/report.pdf"}
8
+ {"path":"/home/me/project/build/report.pdf"}
7
9
  ```
8
10
 
9
11
  Omitting `contact` sends to the current ACP chat. Supplying a non-empty contact
10
12
  keeps ordinary Ours delivery:
11
13
 
12
14
  ```json
13
- {"contact":"Alice","path":"deliverables/report.pdf"}
15
+ {"contact":"Alice","path":"build/report.pdf"}
14
16
  ```
15
17
 
16
18
  Only an absent contact chooses the chat. Null, empty or whitespace-only contacts,
17
- unknown fields and unknown recipients fail without falling back to chat. Outside
18
- opt-in, contact remains required. Current-chat sending accepts `path`, optional
19
- `filename` and `mime`; inline base64 and Ours reply fields are unsupported.
19
+ unknown fields and unknown recipients fail without falling back to chat.
20
+ Current-chat sending accepts `path`, optional `filename` and `mime`; inline
21
+ base64 and Ours reply fields are unsupported.
20
22
 
21
23
  Each call makes one attempt. Failures are returned to the agent. There is no
22
24
  request ID, deduplication, automatic retry or recovery of failed sends. If a user
@@ -24,52 +26,84 @@ asks to resend, another tool call makes a new attempt and can produce another
24
26
  attachment. UUIDs identify stored attachments; the stored SHA-256 checks integrity.
25
27
  Neither is a retry key.
26
28
 
27
- Set this on the Agent definition, not manifest defaults or a Brain:
29
+ ## Paths
30
+
31
+ The Agent passes the path of the file it wants to send. Relative paths resolve
32
+ from the Agent's working directory; `..`, absolute paths, hidden directories and
33
+ symlinked parent directories are all accepted. The file does not have to be
34
+ inside the working directory or any particular subtree. It is opened by the
35
+ Fleet bridge inside the Agent's own OS sandbox, so the Agent can send exactly
36
+ what it can already read.
37
+
38
+ The file itself must pass ordinary integrity checks, each with its own result:
39
+
40
+ | Result | Meaning |
41
+ | --- | --- |
42
+ | `CHAT_FILE_NOT_FOUND` | nothing exists at the path |
43
+ | `CHAT_FILE_NOT_READABLE` | the Agent's OS user or sandbox cannot read it |
44
+ | `CHAT_FILE_IS_SYMLINK` | the named file is itself a symlink; pass its target |
45
+ | `CHAT_FILE_HARDLINKED` | the file has more than one hard link |
46
+ | `CHAT_FILE_NOT_REGULAR` | a directory, FIFO, socket or device |
47
+ | `FILE_EXCEEDS_20_MIB` | larger than the 20 MiB limit |
48
+ | `CHAT_FILE_CHANGED` / `SOURCE_SIZE_CHANGED` | modified while it was being read; finish writing first |
49
+ | `CHAT_FILE_REQUIRES_LINUX` | the Agent's bridge is not running on Linux |
50
+ | `PRIVATE_STATE_CANNOT_BE_SENT` | Fleet's own credentials, connector profile or stored attachments |
51
+
52
+ `PRIVATE_STATE_CANNOT_BE_SENT` covers only Fleet's private Ours runtime
53
+ directory, its attachment store and the connector profile and credential files,
54
+ checked on the provided path before approval and on the opened file's real path
55
+ afterwards. It is not configurable and is not a general path policy.
56
+
57
+ ## Supported sessions
58
+
59
+ Current-chat delivery needs Linux, the managed Ours connector and a Codex or
60
+ Claude ACP session with a tracked active turn. Those Agents need no special
61
+ `harness_options`, MCP restriction or clean configuration home: inherited and
62
+ additional MCP connectors stay as they are, and Fleet's bound bridge is the
63
+ `ours` connector as it already is for every managed Agent.
64
+
65
+ Any other backend still sees the optional `contact`, and omitting it returns a
66
+ precise result rather than a missing tool:
28
67
 
29
- ```yaml
30
- file_delivery:
31
- enabled: true
32
- directory: deliverables
33
68
  ```
69
+ CURRENT_CHAT_DELIVERY_UNAVAILABLE: sending a file to this chat requires a Codex or Claude ACP session; this Agent runs <harness>/<session>. Pass contact to send through Ours instead
70
+ ```
71
+
72
+ Idle, scheduled, steered, cancelled and replaced turns return
73
+ `CURRENT_CHAT_DELIVERY_UNAVAILABLE`; a second send while one is in progress
74
+ returns `FILE_DELIVERY_BUSY`.
75
+
76
+ A file is published only for a call that arrives over the Agent's
77
+ capability-authenticated bridge and binds to the turn that is live in that
78
+ Agent's own session. Connector inventory is not consulted, so unrelated or
79
+ similarly named connectors neither enable nor block delivery.
34
80
 
35
- The tool description advertises this directory and the 20 MiB limit to the Agent.
36
- The directory is a non-hidden subtree of the Agent's working directory. Both
37
- routes interpret relative paths from that working directory; absolute paths keep
38
- their usual meaning. Chat sending additionally enforces the export directory.
39
- The Agent should finish writing the file before calling `send_file`. Symlinks,
40
- hardlinks, hidden/credential paths, changed files and files over 20 MiB fail.
41
-
42
- Initial support requires Linux, the managed Ours connector, a stock Codex or
43
- Claude ACP adapter, and a tracked active turn. Idle, scheduled, steered, cancelled,
44
- replaced and unsupported sessions cannot export. Existing native tool approval
45
- and the neutral role permission policy both apply before bytes are read. An
46
- `ask` decision needs an attached controller; `deny` refuses. A native policy that
47
- refuses MCP execution (including Codex `never` without a native tool allowance)
48
- can still refuse before Fleet receives the call.
49
-
50
- Codex requires native runtime 0.159.0 or newer and Fleet's managed App Server
51
- proxy. Before each turn, its public exact-thread, paginated MCP inventory must
52
- show a connected managed `ours` with the expected optional-contact schema.
53
- Ours aliases, filtered aliases, ambiguous identity, failed discovery and an
54
- incompatible schema make the turn unavailable. Unrelated identified tools are
55
- preserved. Explicitly disabled Ours is rejected before replacement. Inherited
56
- TOML MCP settings, including project/profile sources, are conservatively
57
- unsupported until their disabling semantics can be qualified before replacement.
58
- Use a clean Codex configuration home for this initial opt-in.
59
-
60
- Claude ACP 0.63.0's installed wrapper has no public same-session inventory RPC. Initial
61
- support therefore requires an operator's explicit restricted MCP configuration:
81
+ ## Permissions
82
+
83
+ Existing native tool approval and the neutral role permission policy both apply
84
+ before bytes are read. With `approval: allow`, a path inside the role's
85
+ `filesystem` boundary is allowed automatically; a path outside it is put to the
86
+ attached controller like any other out-of-boundary request, and is denied when
87
+ nobody is attached and `unattended: deny`. An `ask` decision needs an attached
88
+ controller; `deny` refuses with `CURRENT_CHAT_DELIVERY_UNAVAILABLE`, and a
89
+ declined request returns `FILE_SEND_DENIED`. A native policy that refuses MCP
90
+ execution (including Codex `never` without a native tool allowance) can still
91
+ refuse before Fleet receives the call.
92
+
93
+ ## Retired configuration
94
+
95
+ `file_delivery` is retired. An existing block still loads so manifests need no
96
+ edit, and has no effect:
62
97
 
63
98
  ```yaml
64
- harness_options:
65
- mcp_servers_only: true
66
- mcp_servers:
67
- ours: { command: ours-mcp, args: [proxy] }
99
+ file_delivery:
100
+ enabled: false # ignored: delivery stays available
101
+ directory: deliverables # ignored: not created, not enforced
68
102
  ```
69
103
 
70
- Fleet replaces that connector with its bound bridge. Additional explicit MCP
71
- servers and custom ACP commands are unsupported for this feature. Default-off
72
- Agents retain their existing configuration.
104
+ Remove it at your convenience. Unknown keys inside it are still rejected.
105
+
106
+ ## Storage and download
73
107
 
74
108
  Successful sends copy complete bytes into private immutable storage and append a
75
109
  server-origin `file.attached` event containing the actual session generation,
@@ -90,13 +124,23 @@ saving after a workspace change.
90
124
 
91
125
  This source requires the companion MCP implementation's
92
126
  `MANAGED_FILE_DELIVERY_VERSION = 1`. Fleet pins published
93
- `@ours.network/mcp@1.2.0-nightly.13`, which includes it. Older MCP packages without
94
- this capability fail closed when file delivery is enabled. Changes to the MCP
95
- pin require clean installed-package qualification without a built MCP overlay.
96
-
97
- The deterministic native fixture uses real ACP adapters, the real managed
98
- connector and an isolated daemon. A local scripted provider supplies only tool
99
- intents; approval, read, copy, publication and bytes come from the implementation.
100
- It does not qualify external-model inference, deployment or arbitrary native
101
- MCP request-to-ACP tool-call ID mapping. Correlation is the authenticated bridge
102
- and the actual active Fleet turn, which is revoked by cancellation/steering.
127
+ `@ours.network/mcp@1.2.0-nightly.13`, which includes it and already treats the
128
+ export-directory hint as optional, so default delivery needs no MCP change. An
129
+ installed MCP package without the capability fails Agent startup with
130
+ `INCOMPATIBLE_MANAGED_FILE_DELIVERY` instead of quietly requiring a contact.
131
+ Changes to the MCP pin require clean installed-package qualification without a
132
+ built MCP overlay.
133
+
134
+ The deterministic native fixture (`test/file-delivery-native.integration.mjs`)
135
+ uses real ACP adapters, the real managed connector and an isolated daemon. Its
136
+ Agent has no `file_delivery` block and no MCP restriction, and it sends a file
137
+ from a hidden directory outside the working directory. It then downloads the
138
+ generated attachment over the authenticated HTTP route and compares bytes and
139
+ SHA-256, after checking that an anonymous and a forged-cookie request are
140
+ refused. `FLEET_FILE_NATIVE_VARIANT=connectors` adds unrelated MCP connectors
141
+ and requires them to stay usable. A local scripted provider supplies only tool
142
+ intents; approval, read, copy, publication and bytes come from the
143
+ implementation. It does not qualify external-model inference, deployment or
144
+ arbitrary native MCP request-to-ACP tool-call ID mapping. Correlation is the
145
+ authenticated bridge and the actual active Fleet turn, which is revoked by
146
+ cancellation/steering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.57",
3
+ "version": "1.2.0-nightly.58",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -1,15 +0,0 @@
1
- export declare class CodexFileInventory {
2
- private readonly sendToCodex;
3
- private readonly emitToClient;
4
- private checks;
5
- private sequence;
6
- constructor(sendToCodex: (line: string) => void, emitToClient: (line: string) => void);
7
- /** Hold only a turn/start; unrelated tools/configuration are preserved. */
8
- observeClientLine(line: string): boolean;
9
- observeServerLine(line: string): boolean;
10
- close(): void;
11
- private query;
12
- private qualified;
13
- private finish;
14
- private reject;
15
- }
@@ -1,113 +0,0 @@
1
- const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
2
- const parse = (line) => { try {
3
- const v = JSON.parse(line);
4
- return object(v) ? v : undefined;
5
- }
6
- catch {
7
- return undefined;
8
- } };
9
- export class CodexFileInventory {
10
- sendToCodex;
11
- emitToClient;
12
- checks = new Map();
13
- sequence = 0;
14
- constructor(sendToCodex, emitToClient) {
15
- this.sendToCodex = sendToCodex;
16
- this.emitToClient = emitToClient;
17
- }
18
- /** Hold only a turn/start; unrelated tools/configuration are preserved. */
19
- observeClientLine(line) {
20
- const message = parse(line);
21
- if (message?.method !== 'turn/start')
22
- return true;
23
- const threadId = object(message.params) ? message.params.threadId : undefined;
24
- if (typeof threadId !== 'string' || !threadId || message.id === undefined || this.checks.size >= 8) {
25
- this.reject(message);
26
- return false;
27
- }
28
- const check = { request: message, threadId, servers: [], cursors: new Set(), timer: setTimeout(() => {
29
- for (const [id, c] of this.checks)
30
- if (c === check)
31
- this.checks.delete(id);
32
- this.reject(message);
33
- }, 30_000) };
34
- this.query(check);
35
- return false;
36
- }
37
- observeServerLine(line) {
38
- const message = parse(line), id = message?.id;
39
- if (typeof id !== 'string')
40
- return true;
41
- const check = this.checks.get(id);
42
- if (!check)
43
- return true;
44
- this.checks.delete(id);
45
- const result = message?.result;
46
- if (!object(result) || !Array.isArray(result.data) || result.data.some(s => !object(s)) || check.servers.length + result.data.length > 1000) {
47
- this.finish(check, false);
48
- return false;
49
- }
50
- check.servers.push(...result.data);
51
- if (result.nextCursor !== null) {
52
- const cursor = result.nextCursor;
53
- if (typeof cursor !== 'string' || !cursor || check.cursors.has(cursor) || check.cursors.size >= 10)
54
- this.finish(check, false);
55
- else {
56
- check.cursors.add(cursor);
57
- this.query(check, cursor);
58
- }
59
- return false;
60
- }
61
- this.finish(check, this.qualified(check.servers));
62
- return false;
63
- }
64
- close() { for (const c of this.checks.values())
65
- clearTimeout(c.timer); this.checks.clear(); }
66
- query(check, cursor) {
67
- const id = `ours-fleet-file-inventory-${++this.sequence}`;
68
- this.checks.set(id, check);
69
- this.sendToCodex(JSON.stringify({ id, method: 'mcpServerStatus/list', params: { threadId: check.threadId, detail: 'full', limit: 100, ...(cursor ? { cursor } : {}) } }));
70
- }
71
- qualified(servers) {
72
- let managed = 0;
73
- for (const server of servers) {
74
- // Unknown or failed discovery cannot establish absence of another connector.
75
- if (server.toolsError !== null || !object(server.tools))
76
- return false;
77
- if (!object(server.serverInfo) || typeof server.serverInfo.name !== 'string')
78
- return false;
79
- const tools = Object.values(server.tools).filter(object);
80
- const names = new Set(tools.map(t => t.name));
81
- if (server.name !== 'ours') {
82
- if (server.serverInfo.name === 'ours')
83
- return false;
84
- if (tools.some(t => t.name === 'send_file' && object(t.inputSchema) && object(t.inputSchema.properties) && 'contact' in t.inputSchema.properties))
85
- return false;
86
- if (names.has('current_identity') && names.has('send_file') && names.has('get_messages'))
87
- return false;
88
- continue;
89
- }
90
- managed++;
91
- if (server.serverInfo.name !== 'ours' || server.runtimeStatus !== 'connected')
92
- return false;
93
- const files = tools.filter(t => t.name === 'send_file');
94
- if (files.length !== 1 || !object(files[0].inputSchema))
95
- return false;
96
- const schema = files[0].inputSchema;
97
- if (!object(schema.properties) || !object(schema.properties.contact) || schema.additionalProperties !== false
98
- || (Array.isArray(schema.required) && schema.required.includes('contact')))
99
- return false;
100
- }
101
- return managed === 1;
102
- }
103
- finish(check, allowed) {
104
- clearTimeout(check.timer);
105
- if (allowed)
106
- this.sendToCodex(JSON.stringify(check.request));
107
- else
108
- this.reject(check.request);
109
- }
110
- reject(request) {
111
- this.emitToClient(JSON.stringify({ id: request.id, error: { code: -32000, message: 'CURRENT_CHAT_DELIVERY_UNAVAILABLE: unqualified MCP inventory' } }));
112
- }
113
- }