@ours.network/fleet 1.2.0-nightly.53 → 1.2.0-nightly.54
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 +2 -0
- package/dist/agent-ours/bridge.js +10 -3
- package/dist/agent-ours/file-rpc.d.ts +8 -0
- package/dist/agent-ours/file-rpc.js +11 -1
- package/dist/agent-ours/harness.js +47 -3
- package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
- package/dist/agent-ours/mcp-endpoint.js +4 -0
- package/dist/agent-ours/service.d.ts +2 -0
- package/dist/agent-ours/service.js +42 -1
- package/dist/application/conversation-history.d.ts +2 -0
- package/dist/application/conversation-history.js +25 -2
- package/dist/application/supervisor-ours-tools.js +3 -1
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +9 -0
- package/dist/config.js +13 -2
- package/dist/file-delivery/reader.d.ts +17 -0
- package/dist/file-delivery/reader.js +94 -0
- package/dist/file-delivery/store.d.ts +15 -0
- package/dist/file-delivery/store.js +124 -0
- package/dist/file-delivery/types.d.ts +19 -0
- package/dist/file-delivery/types.js +1 -0
- package/dist/harness/codex-app-server-proxy.js +6 -2
- package/dist/harness/codex-file-inventory.d.ts +15 -0
- package/dist/harness/codex-file-inventory.js +113 -0
- package/dist/harness/codex.js +2 -1
- package/dist/isolation/policy.js +4 -1
- package/dist/runner.js +1 -0
- package/dist/session/acp.d.ts +4 -0
- package/dist/session/acp.js +61 -8
- package/dist/session/conversation-types.d.ts +4 -2
- package/dist/session/types.d.ts +1 -0
- package/dist/web/server.js +24 -0
- package/docs/acp-files.md +102 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1920,3 +1920,5 @@ Prerelease rollout requires this Fleet capability to be published first, then th
|
|
|
1920
1920
|
installer's normal generated version/integrity lock updated, then installer and
|
|
1921
1921
|
App released. The currently pinned Fleet nightly.41 does not support v2 grants.
|
|
1922
1922
|
These source changes do not publish, merge, or deploy any component.
|
|
1923
|
+
|
|
1924
|
+
See [ACP file sending](docs/acp-files.md) for the opt-in same-chat `send_file` flow and supported configurations.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { openExportFile } from '../file-delivery/reader.js';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import { connect } from 'node:net';
|
|
4
5
|
import { access, mkdir, open, readFile } from 'node:fs/promises';
|
|
@@ -71,7 +72,7 @@ export async function runBridge(descriptorPath) {
|
|
|
71
72
|
value = false;
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
else if (op === 'read-open' || op === 'write-open') {
|
|
75
|
+
else if (op === 'read-open' || op === 'write-open' || op === 'export-open') {
|
|
75
76
|
if (handles.size >= 32 ||
|
|
76
77
|
typeof args.handle !== 'string' ||
|
|
77
78
|
handles.has(args.handle) ||
|
|
@@ -80,8 +81,9 @@ export async function runBridge(descriptorPath) {
|
|
|
80
81
|
const path = resolve(args.path);
|
|
81
82
|
if (op === 'write-open')
|
|
82
83
|
await mkdir(dirname(path), { recursive: true });
|
|
83
|
-
const file = await open(path, op === 'write-open' ? 'w' : 'r');
|
|
84
|
-
|
|
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 } } : {}) });
|
|
85
87
|
value =
|
|
86
88
|
op === 'write-open'
|
|
87
89
|
? path
|
|
@@ -105,6 +107,11 @@ export async function runBridge(descriptorPath) {
|
|
|
105
107
|
value = null;
|
|
106
108
|
}
|
|
107
109
|
else if (op === 'read' && !row.write) {
|
|
110
|
+
if (row.original) {
|
|
111
|
+
const stat = await row.file.stat();
|
|
112
|
+
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');
|
|
114
|
+
}
|
|
108
115
|
const buffer = Buffer.alloc(MAX_CHUNK_BYTES);
|
|
109
116
|
const { bytesRead } = await row.file.read(buffer);
|
|
110
117
|
value = bytesRead ? buffer.subarray(0, bytesRead).toString('base64') : null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExportRoot } from '../file-delivery/reader.js';
|
|
1
2
|
import type { FileExecutionContext } from '@ours.network/mcp/server';
|
|
2
3
|
import type { ToolRequestExtra } from '@ours.network/mcp/dist/types/mcp/tool.js';
|
|
3
4
|
import { type Wire } from './wire.js';
|
|
@@ -16,6 +17,13 @@ export declare class FileCallbacks implements FileExecutionContext {
|
|
|
16
17
|
body: ReadableStream<Uint8Array<ArrayBufferLike>>;
|
|
17
18
|
close: () => Promise<void>;
|
|
18
19
|
}>;
|
|
20
|
+
readExport(path: string, root: ExportRoot, extra: ToolRequestExtra): Promise<{
|
|
21
|
+
filename: any;
|
|
22
|
+
size: any;
|
|
23
|
+
body: ReadableStream<Uint8Array<ArrayBufferLike>>;
|
|
24
|
+
close: () => Promise<void>;
|
|
25
|
+
}>;
|
|
26
|
+
private openRead;
|
|
19
27
|
write(path: string, body: ReadableStream<Uint8Array>, extra: ToolRequestExtra): Promise<{
|
|
20
28
|
path: string;
|
|
21
29
|
size: number;
|
|
@@ -59,14 +59,24 @@ export class FileCallbacks {
|
|
|
59
59
|
return (await this.rpc(String(extra.requestId), 'probe', { path }, extra.signal)) === true;
|
|
60
60
|
}
|
|
61
61
|
async read(path, extra) {
|
|
62
|
+
return this.openRead(path, extra);
|
|
63
|
+
}
|
|
64
|
+
async readExport(path, root, extra) {
|
|
65
|
+
return this.openRead(path, extra, root);
|
|
66
|
+
}
|
|
67
|
+
async openRead(path, extra, root) {
|
|
62
68
|
const call = String(extra.requestId), handle = randomUUID();
|
|
63
|
-
const meta = await this.rpc(call, 'read-open', { path, handle }, extra.signal);
|
|
69
|
+
const meta = await this.rpc(call, root ? 'export-open' : 'read-open', { path, handle, ...(root ? { root } : {}) }, extra.signal);
|
|
64
70
|
if (!meta ||
|
|
65
71
|
typeof meta.filename !== 'string' ||
|
|
66
72
|
!Number.isSafeInteger(meta.size) ||
|
|
67
73
|
meta.size < 0)
|
|
68
74
|
throw Error('INVALID_FILE_METADATA');
|
|
75
|
+
let closed = false;
|
|
69
76
|
const close = async () => {
|
|
77
|
+
if (closed)
|
|
78
|
+
return;
|
|
79
|
+
closed = true;
|
|
70
80
|
await this.rpc(call, 'close', { handle }, new AbortController().signal).catch(() => { });
|
|
71
81
|
};
|
|
72
82
|
const body = new ReadableStream({
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
2
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolveBundledAcpAgent } from '../harness/acp-agent.js';
|
|
3
6
|
/** Do not pass the supervisor's ours connection credentials to harness children. */
|
|
4
7
|
export function managedChildEnvironment(input) {
|
|
5
8
|
const out = {};
|
|
6
9
|
for (const [key, value] of Object.entries(input)) {
|
|
7
|
-
if (value === undefined || (/^OURS_/i.test(key) && !key.startsWith('OURS_FLEET_')))
|
|
10
|
+
if (key === 'FLEET_CURRENT_CHAT_FILES' || value === undefined || (/^OURS_/i.test(key) && !key.startsWith('OURS_FLEET_')))
|
|
8
11
|
continue;
|
|
9
12
|
out[key] = value;
|
|
10
13
|
}
|
|
@@ -13,6 +16,9 @@ export function managedChildEnvironment(input) {
|
|
|
13
16
|
}
|
|
14
17
|
/** Replace ours only; retain the user's ordinary harness configuration and plugins. */
|
|
15
18
|
export function prepareManagedHarness(role, stateDir, cwd, descriptor, env) {
|
|
19
|
+
const child = managedChildEnvironment({ ...process.env, ...env });
|
|
20
|
+
if (role.file_delivery?.enabled)
|
|
21
|
+
qualifyFileHarness(role, cwd, child);
|
|
16
22
|
const bridge = fileURLToPath(new URL('./bridge.js', import.meta.url));
|
|
17
23
|
const server = {
|
|
18
24
|
name: 'ours',
|
|
@@ -37,7 +43,6 @@ export function prepareManagedHarness(role, stateDir, cwd, descriptor, env) {
|
|
|
37
43
|
},
|
|
38
44
|
plugins: { 'ours@ours-codex-marketplace': { enabled: false } },
|
|
39
45
|
};
|
|
40
|
-
const child = managedChildEnvironment({ ...process.env, ...env });
|
|
41
46
|
if (role.harness === 'codex') {
|
|
42
47
|
const config = child.CODEX_CONFIG ? JSON.parse(child.CODEX_CONFIG) : {};
|
|
43
48
|
child.CODEX_CONFIG = JSON.stringify({
|
|
@@ -70,3 +75,42 @@ export function managedClaudeMeta(base) {
|
|
|
70
75
|
},
|
|
71
76
|
};
|
|
72
77
|
}
|
|
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
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { FileDeliveryInput, DeliveredFile } from '../file-delivery/types.js';
|
|
2
|
+
import type { ToolRequestExtra } from '@ours.network/mcp/dist/types/mcp/tool.js';
|
|
1
3
|
import type { ApplicationIdentityStore } from '@ours.network/mcp/application-identities';
|
|
2
4
|
import type { OursClient } from '@ours.network/sdk/client';
|
|
5
|
+
import { FileCallbacks } from './file-rpc.js';
|
|
3
6
|
import type { AgentOursRuntime } from './runtime.js';
|
|
4
7
|
export interface EndpointOptions {
|
|
5
8
|
socket: string;
|
|
@@ -9,6 +12,8 @@ export interface EndpointOptions {
|
|
|
9
12
|
client: OursClient;
|
|
10
13
|
identities: ApplicationIdentityStore;
|
|
11
14
|
remoteDaemonFiles: boolean;
|
|
15
|
+
currentChatFileDirectory?: string;
|
|
16
|
+
currentChatFile?: (input: FileDeliveryInput, extra: ToolRequestExtra, files: FileCallbacks) => Promise<DeliveredFile>;
|
|
12
17
|
}
|
|
13
18
|
export declare function startMcpEndpoint(options: EndpointOptions): Promise<{
|
|
14
19
|
close(): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as managedMcp from '@ours.network/mcp/server';
|
|
1
2
|
import { createServer } from 'node:net';
|
|
2
3
|
import { chmod, unlink } from 'node:fs/promises';
|
|
3
4
|
import { timingSafeEqual } from 'node:crypto';
|
|
@@ -6,6 +7,8 @@ import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
|
6
7
|
import { FileCallbacks } from './file-rpc.js';
|
|
7
8
|
import { Wire } from './wire.js';
|
|
8
9
|
export async function startMcpEndpoint(options) {
|
|
10
|
+
if (options.currentChatFile && managedMcp.MANAGED_FILE_DELIVERY_VERSION !== 1)
|
|
11
|
+
throw Error('INCOMPATIBLE_MANAGED_FILE_DELIVERY');
|
|
9
12
|
const sockets = new Set();
|
|
10
13
|
const server = createServer((socket) => {
|
|
11
14
|
if (sockets.size >= 4) {
|
|
@@ -33,6 +36,7 @@ export async function startMcpEndpoint(options) {
|
|
|
33
36
|
};
|
|
34
37
|
const mcp = createManagedOursMcpServer(options.client, 'managed-v1', options.identities, {
|
|
35
38
|
remoteDaemonFiles: options.remoteDaemonFiles,
|
|
39
|
+
...(options.currentChatFile ? { currentChatFileDirectory: options.currentChatFileDirectory, currentChatFile: (input, extra) => options.currentChatFile(input, extra, files) } : {}),
|
|
36
40
|
fileContext: files,
|
|
37
41
|
admit: async () => {
|
|
38
42
|
if (!authenticated || inFlight >= 32)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentSession } from '../session/types.js';
|
|
1
2
|
import type { ResolvedRole } from '../config.js';
|
|
2
3
|
import { AgentOursRuntime } from './runtime.js';
|
|
3
4
|
export declare const privateRuntimeRoot: () => string;
|
|
@@ -5,6 +6,7 @@ export interface ManagedAgentService {
|
|
|
5
6
|
runtime: AgentOursRuntime;
|
|
6
7
|
descriptor: string;
|
|
7
8
|
privatePaths: string[];
|
|
9
|
+
setFileDelivery?(sender: AgentSession['sendFileToChat']): void;
|
|
8
10
|
close(terminal: boolean): Promise<void>;
|
|
9
11
|
}
|
|
10
12
|
/** Only trusted launch orchestration may write this descriptor, never the child. */
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { pinExportRoot, exportPath, ensureExportDirectory } from '../file-delivery/reader.js';
|
|
2
|
+
import { ArtifactStore } from '../file-delivery/store.js';
|
|
3
|
+
import { resolve, basename, extname } from 'node:path';
|
|
1
4
|
import { socketPath as privateSocketPath } from '../socket-path.js';
|
|
2
5
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
6
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
@@ -94,6 +97,8 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
94
97
|
}
|
|
95
98
|
const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance);
|
|
96
99
|
let closeClient;
|
|
100
|
+
let exportRoot;
|
|
101
|
+
let fileSender;
|
|
97
102
|
let partialEndpoint;
|
|
98
103
|
try {
|
|
99
104
|
let prior = controller.journal.read();
|
|
@@ -210,6 +215,18 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
210
215
|
const bridgeDir = join(stateDir, '.ours-bridge');
|
|
211
216
|
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
212
217
|
const capability = randomBytes(32).toString('hex'), socket = privateSocketPath(join(bridgeDir, `g${generation}.sock`));
|
|
218
|
+
const cwd = role.cwd && existsSync(role.cwd) ? resolve(role.cwd) : resolve(stateDir);
|
|
219
|
+
if (role.file_delivery?.enabled) {
|
|
220
|
+
if (role.session !== 'acp' || !['codex', 'claude-code'].includes(role.harness))
|
|
221
|
+
throw Error('FILE_DELIVERY_REQUIRES_CODEX_OR_CLAUDE_ACP');
|
|
222
|
+
const rootPath = resolve(cwd, role.file_delivery.directory);
|
|
223
|
+
const forbidden = [privateRuntimeRoot(), join(stateRoot(), 'private-file-delivery'), profile.configPath, profile.credentialPath];
|
|
224
|
+
if (forbidden.some(p => rootPath === resolve(p) || rootPath.startsWith(resolve(p) + '/') || resolve(p).startsWith(rootPath + '/')))
|
|
225
|
+
throw Error('PRIVATE_STATE_CANNOT_BE_EXPORTED');
|
|
226
|
+
await ensureExportDirectory(cwd, role.file_delivery.directory);
|
|
227
|
+
exportRoot = await pinExportRoot(rootPath);
|
|
228
|
+
}
|
|
229
|
+
const artifactStore = new ArtifactStore(stateDir);
|
|
213
230
|
const endpoint = await startMcpEndpoint({
|
|
214
231
|
socket,
|
|
215
232
|
capability,
|
|
@@ -218,6 +235,26 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
218
235
|
client,
|
|
219
236
|
identities,
|
|
220
237
|
remoteDaemonFiles: true,
|
|
238
|
+
...(exportRoot ? { currentChatFileDirectory: role.file_delivery.directory, currentChatFile: (async (input, extra, files) => {
|
|
239
|
+
if (!fileSender || !exportRoot)
|
|
240
|
+
throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE');
|
|
241
|
+
exportPath(exportRoot.root, cwd, input.path); // validate before approval/read
|
|
242
|
+
const name = input.filename ?? basename(input.path);
|
|
243
|
+
if (!name || Buffer.byteLength(name) > 240 || /[\/\\\x00-\x1f]/.test(name) || name === '.' || name === '..')
|
|
244
|
+
throw Error('INVALID_FILENAME');
|
|
245
|
+
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';
|
|
246
|
+
if (mime.length > 200 || !/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(mime))
|
|
247
|
+
throw Error('INVALID_MIME');
|
|
248
|
+
return fileSender(input, extra.signal, async (binding) => {
|
|
249
|
+
const source = await files.readExport(input.path, exportRoot.root, extra);
|
|
250
|
+
try {
|
|
251
|
+
return await artifactStore.copy(source, name, mime, binding, extra.signal);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
await source.close();
|
|
255
|
+
}
|
|
256
|
+
}, role.permissions);
|
|
257
|
+
}) } : {}),
|
|
221
258
|
});
|
|
222
259
|
partialEndpoint = endpoint;
|
|
223
260
|
const descriptor = join(bridgeDir, 'descriptor.json');
|
|
@@ -225,10 +262,13 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
225
262
|
return {
|
|
226
263
|
runtime,
|
|
227
264
|
descriptor,
|
|
228
|
-
privatePaths: [root, profile.configPath, profile.credentialPath],
|
|
265
|
+
privatePaths: [root, profile.configPath, profile.credentialPath, join(stateRoot(), 'private-file-delivery')],
|
|
266
|
+
setFileDelivery: sender => { fileSender = sender; },
|
|
229
267
|
close: async (terminal) => {
|
|
230
268
|
try {
|
|
269
|
+
fileSender = undefined;
|
|
231
270
|
await endpoint.close();
|
|
271
|
+
await exportRoot?.handle.close();
|
|
232
272
|
if (terminal)
|
|
233
273
|
await runtime.terminal();
|
|
234
274
|
else
|
|
@@ -248,6 +288,7 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
248
288
|
catch (error) {
|
|
249
289
|
try {
|
|
250
290
|
await partialEndpoint?.close();
|
|
291
|
+
await exportRoot?.handle.close();
|
|
251
292
|
}
|
|
252
293
|
finally {
|
|
253
294
|
try {
|
|
@@ -23,3 +23,5 @@ export declare function resumedConversationPage(stateDir: string, request: {
|
|
|
23
23
|
after?: string;
|
|
24
24
|
limit?: number;
|
|
25
25
|
}, live: ConversationPageView, expectedSessionId: string): Promise<ConversationPageView>;
|
|
26
|
+
/** Download authorization requires an actual published attachment, never an orphan copy. */
|
|
27
|
+
export declare function conversationHasAttachment(stateDir: string, attachment: import('../file-delivery/types.js').DeliveredFile): Promise<boolean>;
|
|
@@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs';
|
|
|
2
2
|
import { open, readFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
|
-
const HISTORICAL_KINDS = ['fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'];
|
|
5
|
+
const HISTORICAL_KINDS = ['file.attached', 'fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'];
|
|
6
6
|
/** Whether a ledger event belongs in the conversation: the live generation, or history of the resumed ACP session. */
|
|
7
7
|
export function conversationEventVisible(event, sessionGeneration, resumedSessionId) {
|
|
8
8
|
return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
|
|
@@ -162,7 +162,7 @@ export async function resumedConversationPage(stateDir, request, live, expectedS
|
|
|
162
162
|
const cursor = Number(event.seq);
|
|
163
163
|
if (!Number.isSafeInteger(cursor) || cursor <= after)
|
|
164
164
|
continue;
|
|
165
|
-
const historical = ['fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'].includes(event.kind);
|
|
165
|
+
const historical = ['file.attached', 'fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'].includes(event.kind);
|
|
166
166
|
const visible = event.source !== 'agent_replay' && (event.sessionGeneration === live.snapshot.sessionGeneration || (historical && event.acpSessionId === sessionId));
|
|
167
167
|
if (!visible) {
|
|
168
168
|
next = String(cursor);
|
|
@@ -187,3 +187,26 @@ export async function resumedConversationPage(stateDir, request, live, expectedS
|
|
|
187
187
|
return live;
|
|
188
188
|
return { ...live, events, firstAvailableCursor: first, nextCursor: next, hasMore };
|
|
189
189
|
}
|
|
190
|
+
/** Download authorization requires an actual published attachment, never an orphan copy. */
|
|
191
|
+
export async function conversationHasAttachment(stateDir, attachment) {
|
|
192
|
+
const dir = join(stateDir, '.conversation');
|
|
193
|
+
const names = (await readdir(dir)).filter(n => /^events-\d+\.jsonl$/.test(n)).sort().reverse();
|
|
194
|
+
let found = false;
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
await readLinesBackward(join(dir, name), line => {
|
|
197
|
+
try {
|
|
198
|
+
const e = JSON.parse(line);
|
|
199
|
+
const a = e.payload.attachment;
|
|
200
|
+
if (e.kind === 'file.attached' && e.source === 'agent' && a?.id === attachment.id
|
|
201
|
+
&& e.sessionGeneration === attachment.sessionGeneration && e.acpSessionId === attachment.acpSessionId
|
|
202
|
+
&& e.turnId === attachment.turnId && JSON.stringify(a) === JSON.stringify(attachment))
|
|
203
|
+
found = true;
|
|
204
|
+
}
|
|
205
|
+
catch { /* unreadable lines cannot authorize a download */ }
|
|
206
|
+
return !found;
|
|
207
|
+
});
|
|
208
|
+
if (found)
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
return found;
|
|
212
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { socketPath } from '../socket-path.js';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
@@ -35,7 +36,8 @@ export class SupervisorOursTools {
|
|
|
35
36
|
const identity = legacy
|
|
36
37
|
? legacySupervisorIdentity(role, identityName, selected.temporary, descriptor.generation)
|
|
37
38
|
: { name: identityName, cid: descriptor.cid, generation: descriptor.generation };
|
|
38
|
-
|
|
39
|
+
const legacySocket = join(selected.dir, '.ours-bridge', `g${descriptor.generation}.sock`);
|
|
40
|
+
if (legacy && descriptor.socket !== legacySocket && descriptor.socket !== socketPath(legacySocket))
|
|
39
41
|
throw new FleetError('capability_unavailable', 'legacy supervisor socket does not match the selected agent');
|
|
40
42
|
if ((!legacy && (descriptor.role !== role || descriptor.identity !== identityName))
|
|
41
43
|
|| !/^[a-f0-9]{64}$/i.test(identity.cid ?? '') || !Number.isSafeInteger(descriptor.generation) || descriptor.generation < 1)
|
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.54",
|
|
3
|
+
"buildId": "b10c893ceda0",
|
|
4
|
+
"commit": "4da7d4782c1caf686836806bc8606fc50887edc5",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-08T10:22:35.148Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"managed-cli.codex-reasoning-effort-v1",
|
|
9
9
|
"managed-cli.setup-v1",
|
package/dist/config.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ 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
|
+
export interface FileDeliveryConfig {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
directory: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function validateFileDelivery(value: unknown): FileDeliveryConfig | undefined;
|
|
45
50
|
export interface CommonPermissions {
|
|
46
51
|
approval: ApprovalMode;
|
|
47
52
|
filesystem: FilesystemMode;
|
|
@@ -154,6 +159,8 @@ export interface RoleConfig {
|
|
|
154
159
|
auth_proxy?: Partial<AuthProxyConfig>;
|
|
155
160
|
/** Fleet-prepared CLI workflows this role may run outside its command sandbox. */
|
|
156
161
|
managed_cli?: ManagedCliWorkflowId[];
|
|
162
|
+
/** Explicit per-Agent export opt-in; not inherited from manifest defaults. */
|
|
163
|
+
file_delivery?: FileDeliveryConfig;
|
|
157
164
|
}
|
|
158
165
|
export type AgentSelection<T extends Record<string, unknown> = Record<string, unknown>> = {
|
|
159
166
|
ref: string;
|
|
@@ -177,6 +184,8 @@ export interface AgentDefinition {
|
|
|
177
184
|
auth_proxy?: Partial<AuthProxyConfig>;
|
|
178
185
|
/** Fleet-prepared CLI workflows this Agent may run outside its command sandbox. */
|
|
179
186
|
managed_cli?: ManagedCliWorkflowId[];
|
|
187
|
+
/** Explicit per-Agent export opt-in; not inherited from manifest defaults. */
|
|
188
|
+
file_delivery?: FileDeliveryConfig;
|
|
180
189
|
/** Scheduled turns scoped only to temporary launches; persistent Agents reject this field. */
|
|
181
190
|
loops?: AgentLoopsConfig;
|
|
182
191
|
}
|
package/dist/config.js
CHANGED
|
@@ -29,6 +29,15 @@ 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
|
+
export function validateFileDelivery(value) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
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');
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
32
41
|
/** Default wake sources when a role does not list its own. */
|
|
33
42
|
export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
|
|
34
43
|
const MONITOR_KEYS = [
|
|
@@ -144,7 +153,7 @@ export const ROLE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
|
144
153
|
const ROLE_KEYS = [
|
|
145
154
|
'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
|
|
146
155
|
'briefing_file', 'model', 'effort', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
|
|
147
|
-
'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'managed_cli',
|
|
156
|
+
'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'managed_cli', 'file_delivery',
|
|
148
157
|
];
|
|
149
158
|
export const ROLE_PRESET_KEYS = ['mission', 'persona', 'bio', 'briefing_file'];
|
|
150
159
|
export const BRAIN_PRESET_KEYS = [
|
|
@@ -153,7 +162,7 @@ export const BRAIN_PRESET_KEYS = [
|
|
|
153
162
|
];
|
|
154
163
|
export const AGENT_KEYS = [
|
|
155
164
|
'role', 'brain', 'permissions', 'identity', 'cwd', 'coordinator', 'env', 'oversee',
|
|
156
|
-
'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'loops', 'managed_cli',
|
|
165
|
+
'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'loops', 'managed_cli', 'file_delivery',
|
|
157
166
|
];
|
|
158
167
|
const AGENT_INSTANCE_KEYS = ['template', 'overrides'];
|
|
159
168
|
const TEMPLATE_FORBIDDEN_KEYS = ['identity'];
|
|
@@ -199,6 +208,7 @@ export function validateBrainValue(value, file, pointer) {
|
|
|
199
208
|
}
|
|
200
209
|
/** Pure launch-boundary validation for a fully resolved temporary Agent definition. */
|
|
201
210
|
export function validateEffectiveAgentTemplate(definition, id = 'room-member') {
|
|
211
|
+
validateFileDelivery(definition.file_delivery);
|
|
202
212
|
const value = structuredClone(definition);
|
|
203
213
|
const bad = Object.keys(value).filter(key => !AGENT_KEYS.includes(key));
|
|
204
214
|
if (bad.length)
|
|
@@ -646,6 +656,7 @@ export function loadConfig(configPath, options = {}) {
|
|
|
646
656
|
const bad = Object.keys(r).filter(k => !ROLE_KEYS.includes(k));
|
|
647
657
|
if (bad.length)
|
|
648
658
|
throw new ConfigError(`${file}: role '${name}' has unknown key(s) ${bad.join(', ')}; allowed: ${ROLE_KEYS.join(', ')}`);
|
|
659
|
+
validateFileDelivery(r.file_delivery);
|
|
649
660
|
const isolation = r.isolation ?? defaults.isolation;
|
|
650
661
|
const session = resolveSession(r.session ?? defaults.session, file, name);
|
|
651
662
|
const sessionOptions = resolveSessionOptions(defaults.session_options, r.session_options, session, file, name);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type FileHandle } from 'node:fs/promises';
|
|
2
|
+
export declare const MAX_FILE_BYTES: number;
|
|
3
|
+
export interface ExportRoot {
|
|
4
|
+
path: string;
|
|
5
|
+
dev: number;
|
|
6
|
+
ino: number;
|
|
7
|
+
}
|
|
8
|
+
/** Pin every component; neither trusted root ancestors nor agent components follow symlinks. */
|
|
9
|
+
export declare function openDirectory(path: string): Promise<FileHandle>;
|
|
10
|
+
export declare function pinExportRoot(path: string): Promise<{
|
|
11
|
+
root: ExportRoot;
|
|
12
|
+
handle: FileHandle;
|
|
13
|
+
}>;
|
|
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>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, open } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
export const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
5
|
+
const FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
6
|
+
/** Pin every component; neither trusted root ancestors nor agent components follow symlinks. */
|
|
7
|
+
export async function openDirectory(path) {
|
|
8
|
+
if (process.platform !== 'linux' || !isAbsolute(path) || path !== resolve(path))
|
|
9
|
+
throw Error('FILE_DELIVERY_REQUIRES_LINUX_ROOT');
|
|
10
|
+
let dir = await open('/', FLAGS | constants.O_DIRECTORY);
|
|
11
|
+
try {
|
|
12
|
+
for (const part of path.split('/').filter(Boolean)) {
|
|
13
|
+
const next = await open(`/proc/self/fd/${dir.fd}/${part}`, FLAGS | constants.O_DIRECTORY);
|
|
14
|
+
await dir.close();
|
|
15
|
+
dir = next;
|
|
16
|
+
}
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
await dir.close();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function pinExportRoot(path) {
|
|
25
|
+
const handle = await openDirectory(path);
|
|
26
|
+
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 };
|
|
31
|
+
}
|
|
32
|
+
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();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await dir.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function ensureExportDirectory(cwd, directory) {
|
|
78
|
+
let dir = await openDirectory(cwd);
|
|
79
|
+
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
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await dir.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DeliveredFile, FileDeliveryBinding } from './types.js';
|
|
2
|
+
export declare function artifactDirectory(stateDir: string): string;
|
|
3
|
+
export declare class ArtifactStore {
|
|
4
|
+
private readonly stateDir;
|
|
5
|
+
constructor(stateDir: string);
|
|
6
|
+
copy(source: {
|
|
7
|
+
size: number;
|
|
8
|
+
body: ReadableStream<Uint8Array>;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}, name: string, mimeType: string, binding: FileDeliveryBinding, signal: AbortSignal): Promise<DeliveredFile>;
|
|
11
|
+
}
|
|
12
|
+
export declare function readDeliveredFile(stateDir: string, id: string): Promise<{
|
|
13
|
+
record: DeliveredFile;
|
|
14
|
+
bytes: Buffer;
|
|
15
|
+
}>;
|