@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 +1 -1
- package/dist/agent-ours/bridge.js +21 -11
- package/dist/agent-ours/file-rpc.d.ts +6 -2
- package/dist/agent-ours/file-rpc.js +11 -7
- package/dist/agent-ours/harness.js +2 -46
- package/dist/agent-ours/mcp-endpoint.d.ts +0 -1
- package/dist/agent-ours/mcp-endpoint.js +3 -2
- package/dist/agent-ours/service.d.ts +15 -0
- package/dist/agent-ours/service.js +41 -35
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +10 -4
- package/dist/config.js +8 -3
- package/dist/file-delivery/reader.d.ts +10 -12
- package/dist/file-delivery/reader.js +33 -64
- package/dist/harness/codex-app-server-proxy.js +2 -6
- package/dist/harness/codex.js +1 -2
- package/docs/acp-files.md +99 -55
- package/package.json +1 -1
- package/dist/harness/codex-file-inventory.d.ts +0 -15
- package/dist/harness/codex-file-inventory.js +0 -113
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
|
|
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 {
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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,
|
|
70
|
+
async openRead(path, extra, chat = false) {
|
|
68
71
|
const call = String(extra.requestId), handle = randomUUID();
|
|
69
|
-
const meta = await this.rpc(call,
|
|
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 {
|
|
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 (
|
|
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 ? {
|
|
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 {
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 {
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.58",
|
|
3
|
+
"buildId": "724339272df9",
|
|
4
|
+
"commit": "adcfbe7c6d818a2068b0d22274c39bc66f159c9a",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
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
|
|
47
|
-
directory
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
||
|
|
37
|
-
||
|
|
38
|
-
throw new ConfigError('file_delivery
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
3
|
-
import { isAbsolute,
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
await
|
|
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
|
-
|
|
495
|
-
writeToChild(rewritten);
|
|
491
|
+
writeToChild(rewritten);
|
|
496
492
|
});
|
|
497
493
|
output.on('line', line => {
|
|
498
|
-
if (!finished &&
|
|
494
|
+
if (!finished && recovery.observeServerLine(line))
|
|
499
495
|
writeToClient(line);
|
|
500
496
|
});
|
|
501
497
|
input.once('close', () => {
|
package/dist/harness/codex.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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":"
|
|
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":"
|
|
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.
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
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
|
-
}
|