@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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, mkdirSync, readFileSync, readdirSync, lstatSync, openSync, fstatSync, closeSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { open, unlink } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { stateRoot } from '../paths.js';
|
|
6
|
+
import { openDirectory, MAX_FILE_BYTES } from './reader.js';
|
|
7
|
+
export function artifactDirectory(stateDir) {
|
|
8
|
+
const incarnation = readFileSync(join(stateDir, '.session-id'), 'utf8').trim();
|
|
9
|
+
if (!/^[a-f0-9-]{36}$/.test(incarnation))
|
|
10
|
+
throw Error('INVALID_ROLE_INCARNATION');
|
|
11
|
+
const key = createHash('sha256').update(JSON.stringify([stateDir, incarnation])).digest('hex');
|
|
12
|
+
return join(stateRoot(), 'private-file-delivery', key);
|
|
13
|
+
}
|
|
14
|
+
function privateDirectory(path) {
|
|
15
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
16
|
+
const stat = lstatSync(path);
|
|
17
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o077)
|
|
18
|
+
throw Error('INVALID_ARTIFACT_DIRECTORY');
|
|
19
|
+
}
|
|
20
|
+
export class ArtifactStore {
|
|
21
|
+
stateDir;
|
|
22
|
+
constructor(stateDir) {
|
|
23
|
+
this.stateDir = stateDir;
|
|
24
|
+
}
|
|
25
|
+
async copy(source, name, mimeType, binding, signal) {
|
|
26
|
+
if (!Number.isSafeInteger(source.size) || source.size < 0 || source.size > MAX_FILE_BYTES)
|
|
27
|
+
throw Error('FILE_EXCEEDS_20_MIB');
|
|
28
|
+
const path = artifactDirectory(this.stateDir);
|
|
29
|
+
privateDirectory(join(stateRoot(), 'private-file-delivery'));
|
|
30
|
+
privateDirectory(path);
|
|
31
|
+
const root = await openDirectory(path);
|
|
32
|
+
const id = randomUUID(), base = `/proc/self/fd/${root.fd}/`, partial = base + id + '.part';
|
|
33
|
+
let file;
|
|
34
|
+
const reader = source.body.getReader();
|
|
35
|
+
try {
|
|
36
|
+
// Orphans count toward the same bounded quota. They are never republished.
|
|
37
|
+
const entries = readdirSync(base).filter(p => /\.(data|part)$/.test(p));
|
|
38
|
+
const bytes = entries.reduce((sum, p) => sum + lstatSync(base + p).size, 0);
|
|
39
|
+
if (entries.length >= 100 || bytes + source.size > 200 * 1024 * 1024)
|
|
40
|
+
throw Error('ARTIFACT_QUOTA_EXCEEDED');
|
|
41
|
+
file = await open(partial, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
42
|
+
const hash = createHash('sha256');
|
|
43
|
+
let size = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
signal.throwIfAborted();
|
|
46
|
+
const chunk = await reader.read();
|
|
47
|
+
if (chunk.done)
|
|
48
|
+
break;
|
|
49
|
+
size += chunk.value.length;
|
|
50
|
+
if (size > source.size || size > MAX_FILE_BYTES)
|
|
51
|
+
throw Error('SOURCE_SIZE_CHANGED');
|
|
52
|
+
hash.update(chunk.value);
|
|
53
|
+
let offset = 0;
|
|
54
|
+
while (offset < chunk.value.length) {
|
|
55
|
+
const { bytesWritten } = await file.write(chunk.value, offset, chunk.value.length - offset);
|
|
56
|
+
if (!bytesWritten)
|
|
57
|
+
throw Error('SHORT_ARTIFACT_WRITE');
|
|
58
|
+
offset += bytesWritten;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (size !== source.size)
|
|
62
|
+
throw Error('SOURCE_SIZE_CHANGED');
|
|
63
|
+
signal.throwIfAborted();
|
|
64
|
+
await file.chmod(0o400);
|
|
65
|
+
await file.sync();
|
|
66
|
+
await file.close();
|
|
67
|
+
file = undefined;
|
|
68
|
+
const record = { id, name, mimeType, size, sha256: hash.digest('hex'), ...binding };
|
|
69
|
+
renameSync(partial, base + id + '.data');
|
|
70
|
+
writeFileSync(base + id + '.json', JSON.stringify(record), { flag: 'wx', mode: 0o400, flush: true });
|
|
71
|
+
// No pending-send record or recovery: a later failure leaves only unreferenced stored bytes.
|
|
72
|
+
await root.sync();
|
|
73
|
+
// The newly created incarnation directory and store root must also survive
|
|
74
|
+
// a crash before a durable conversation event can reference this file.
|
|
75
|
+
for (const parent of [join(stateRoot(), 'private-file-delivery'), stateRoot()]) {
|
|
76
|
+
const handle = await openDirectory(parent);
|
|
77
|
+
try {
|
|
78
|
+
await handle.sync();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await handle.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return record;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await file?.close();
|
|
88
|
+
await reader.cancel().catch(() => { });
|
|
89
|
+
await source.close();
|
|
90
|
+
await unlink(partial).catch(() => { });
|
|
91
|
+
await root.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function readDeliveredFile(stateDir, id) {
|
|
96
|
+
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
97
|
+
throw Error('ARTIFACT_NOT_FOUND');
|
|
98
|
+
const root = await openDirectory(artifactDirectory(stateDir));
|
|
99
|
+
const read = (suffix, max) => {
|
|
100
|
+
const fd = openSync(`/proc/self/fd/${root.fd}/${id}.${suffix}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
101
|
+
try {
|
|
102
|
+
const stat = fstatSync(fd);
|
|
103
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || stat.size > max)
|
|
104
|
+
throw Error('INVALID_STORED_ARTIFACT');
|
|
105
|
+
const bytes = readFileSync(fd);
|
|
106
|
+
if (bytes.length > max)
|
|
107
|
+
throw Error('ARTIFACT_TOO_LARGE');
|
|
108
|
+
return bytes;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
const record = JSON.parse(read('json', 4096).toString());
|
|
116
|
+
const bytes = read('data', MAX_FILE_BYTES);
|
|
117
|
+
if (record.id !== id || record.size !== bytes.length || record.sha256 !== createHash('sha256').update(bytes).digest('hex') || typeof record.name !== 'string')
|
|
118
|
+
throw Error('ARTIFACT_INTEGRITY_FAILED');
|
|
119
|
+
return { record, bytes };
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await root.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface FileDeliveryBinding {
|
|
2
|
+
sessionGeneration: string;
|
|
3
|
+
acpSessionId: string;
|
|
4
|
+
turnId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DeliveredFile extends FileDeliveryBinding {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
size: number;
|
|
11
|
+
sha256: string;
|
|
12
|
+
}
|
|
13
|
+
export interface FileDeliveryInput {
|
|
14
|
+
path: string;
|
|
15
|
+
filename?: string;
|
|
16
|
+
mime?: string;
|
|
17
|
+
}
|
|
18
|
+
export type CopyChatFile = (binding: FileDeliveryBinding) => Promise<DeliveredFile>;
|
|
19
|
+
export type SendChatFile = (input: FileDeliveryInput, signal: AbortSignal, copy: CopyChatFile) => Promise<DeliveredFile>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { CodexFileInventory } from './codex-file-inventory.js';
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
4
5
|
import { createInterface } from 'node:readline';
|
|
@@ -446,6 +447,7 @@ export function runCodexAppServerProxy() {
|
|
|
446
447
|
});
|
|
447
448
|
}
|
|
448
449
|
};
|
|
450
|
+
const inventory = process.env.FLEET_CURRENT_CHAT_FILES === '1' ? new CodexFileInventory(writeToChild, writeToClient) : undefined;
|
|
449
451
|
const recovery = new CodexTerminalRecovery({
|
|
450
452
|
sendToCodex: writeToChild,
|
|
451
453
|
emitToClient: writeToClient,
|
|
@@ -456,6 +458,7 @@ export function runCodexAppServerProxy() {
|
|
|
456
458
|
return;
|
|
457
459
|
finished = true;
|
|
458
460
|
recovery.close();
|
|
461
|
+
inventory?.close();
|
|
459
462
|
input.close();
|
|
460
463
|
process.stdin.destroy();
|
|
461
464
|
// stdout/stderr are often pipes under Fleet and tests. Setting exitCode
|
|
@@ -488,10 +491,11 @@ export function runCodexAppServerProxy() {
|
|
|
488
491
|
return;
|
|
489
492
|
const rewritten = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp, managedOursTools);
|
|
490
493
|
recovery.observeClientLine(rewritten);
|
|
491
|
-
|
|
494
|
+
if (!inventory || inventory.observeClientLine(rewritten))
|
|
495
|
+
writeToChild(rewritten);
|
|
492
496
|
});
|
|
493
497
|
output.on('line', line => {
|
|
494
|
-
if (!finished && recovery.observeServerLine(line))
|
|
498
|
+
if (!finished && (!inventory || inventory.observeServerLine(line)) && recovery.observeServerLine(line))
|
|
495
499
|
writeToClient(line);
|
|
496
500
|
});
|
|
497
501
|
input.once('close', () => {
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
}
|
package/dist/harness/codex.js
CHANGED
|
@@ -262,6 +262,7 @@ 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 });
|
|
265
266
|
const windows = process.platform === 'win32';
|
|
266
267
|
const command = join(runtimeDir, windows ? 'codex-app-server-proxy.cmd' : 'codex-app-server-proxy');
|
|
267
268
|
const script = windows
|
|
@@ -482,7 +483,7 @@ export function makeCodexAdapter(exec = realExec, transport, nativeTransport) {
|
|
|
482
483
|
catch (error) {
|
|
483
484
|
throw new Error(`Codex ACP runtime check failed: ${error.message}; check CODEX_PATH or reinstall Fleet with optional dependencies`);
|
|
484
485
|
}
|
|
485
|
-
const minimum = role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
|
|
486
|
+
const minimum = role.file_delivery?.enabled ? '0.159.0' : role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
|
|
486
487
|
? '0.153.3' : '0.145.0';
|
|
487
488
|
if (!codexVersionAtLeast(runtime.version, minimum))
|
|
488
489
|
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/dist/isolation/policy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stateRoot } from '../paths.js';
|
|
1
2
|
import { realpathSync } from 'node:fs';
|
|
2
3
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
4
|
import { BACKENDS, ON_UNAVAILABLE, NETWORK_MODES, } from './types.js';
|
|
@@ -57,7 +58,7 @@ const SYSTEM_RO = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc'];
|
|
|
57
58
|
/** Ephemeral scratch mounts. */
|
|
58
59
|
const scratchTmpfs = (home) => ['/tmp', join(home, '.cache')];
|
|
59
60
|
/** Home-relative sensitive paths never exposed (the blocklist's teeth). */
|
|
60
|
-
const SENSITIVE_HOME = ['.ssh', '.aws', '.docker', '.gnupg', '.ours', 'fleet.yaml', 'fleet.d'];
|
|
61
|
+
const SENSITIVE_HOME = ['.ssh', '.aws', '.docker', '.gnupg', '.ours', '.ours-fleet/private-ours', '.ours-fleet/private-file-delivery', 'fleet.yaml', 'fleet.d'];
|
|
61
62
|
const ISOLATION_KEYS = ['backend', 'on_unavailable', 'fs', 'network', 'allow_hosts', 'resources', 'secrets'];
|
|
62
63
|
const FS_KEYS = ['read', 'write'];
|
|
63
64
|
const RESOURCE_KEYS = ['cpu', 'mem', 'pids'];
|
|
@@ -189,6 +190,8 @@ export function resolveIsolation(cfg, ctx) {
|
|
|
189
190
|
const agentsRoot = dirname(stateDir);
|
|
190
191
|
const blocklist = [
|
|
191
192
|
...SENSITIVE_HOME.map(p => join(home, p)),
|
|
193
|
+
join(stateRoot(), 'private-ours'),
|
|
194
|
+
join(stateRoot(), 'private-file-delivery'),
|
|
192
195
|
agentsRoot, // sibling agents' state dirs (this agent's own is explicitly mounted)
|
|
193
196
|
];
|
|
194
197
|
// ENFORCE the list, before anything builds a backend argv. Until now it was
|
package/dist/runner.js
CHANGED
|
@@ -675,6 +675,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
675
675
|
cwd: runCwd, stateDir: dir, mode, permissions: perms,
|
|
676
676
|
permissionMode: effectivePermissionMode(role), log: deps.log,
|
|
677
677
|
}));
|
|
678
|
+
managedService.setFileDelivery?.(agentSession.sendFileToChat?.bind(agentSession));
|
|
678
679
|
startupTelemetry.mark('harness_ready');
|
|
679
680
|
pid = agentSession.pid;
|
|
680
681
|
arbiter = new RoleTurnArbiter(agentSession);
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FileDeliveryInput, CopyChatFile, DeliveredFile } from '../file-delivery/types.js';
|
|
1
2
|
import { type TaskNoticeBinding, type TaskCreatedNotice } from './task-notice.js';
|
|
2
3
|
import * as acp from '@agentclientprotocol/sdk';
|
|
3
4
|
import type { CommonPermissions } from '../config.js';
|
|
@@ -176,6 +177,7 @@ export declare class AcpSession implements AgentSession {
|
|
|
176
177
|
private stallTimer?;
|
|
177
178
|
private stallAttempt?;
|
|
178
179
|
private readonly toolBoundaryWaiters;
|
|
180
|
+
private fileExportBusy;
|
|
179
181
|
private activeTurn?;
|
|
180
182
|
private constructor();
|
|
181
183
|
static start(options: AcpSessionOptions): Promise<AcpSession>;
|
|
@@ -303,6 +305,8 @@ export declare class AcpSession implements AgentSession {
|
|
|
303
305
|
*/
|
|
304
306
|
private settlePendingAutomatically;
|
|
305
307
|
exitResult(): ExitRecord | null;
|
|
308
|
+
/** One attempt, tied to the managed live turn captured before any approval/read. */
|
|
309
|
+
sendFileToChat(input: FileDeliveryInput, signal: AbortSignal, copy: CopyChatFile, policy: CommonPermissions): Promise<DeliveredFile>;
|
|
306
310
|
close(): Promise<void>;
|
|
307
311
|
/** ACP requires the field. Bundled agents treat [] as no client-added servers. */
|
|
308
312
|
private declaredMcpServers;
|
package/dist/session/acp.js
CHANGED
|
@@ -278,6 +278,7 @@ export class AcpSession {
|
|
|
278
278
|
stallTimer;
|
|
279
279
|
stallAttempt;
|
|
280
280
|
toolBoundaryWaiters = new Set();
|
|
281
|
+
fileExportBusy = false;
|
|
281
282
|
activeTurn;
|
|
282
283
|
constructor(options, child, connection) {
|
|
283
284
|
this.options = options;
|
|
@@ -823,6 +824,8 @@ export class AcpSession {
|
|
|
823
824
|
if (!this.sessionId)
|
|
824
825
|
return;
|
|
825
826
|
const active = this.activeTurn;
|
|
827
|
+
if (active)
|
|
828
|
+
active.fileDeliveryRevoked = true;
|
|
826
829
|
const previousSource = active?.cancellationSource;
|
|
827
830
|
if (active && (source === 'owner' || source === 'local-console' || !previousSource))
|
|
828
831
|
active.cancellationSource = source;
|
|
@@ -1026,6 +1029,54 @@ export class AcpSession {
|
|
|
1026
1029
|
exitResult() {
|
|
1027
1030
|
return this.exit;
|
|
1028
1031
|
}
|
|
1032
|
+
/** One attempt, tied to the managed live turn captured before any approval/read. */
|
|
1033
|
+
async sendFileToChat(input, signal, copy, policy) {
|
|
1034
|
+
const active = this.activeTurn, sessionId = this.sessionId;
|
|
1035
|
+
const check = () => {
|
|
1036
|
+
signal.throwIfAborted();
|
|
1037
|
+
if (!active || !sessionId || this.activeTurn !== active || this.sessionId !== sessionId || active.fileDeliveryRevoked
|
|
1038
|
+
|| active.cancellationSource || this.closing || !this.isAlive() || this.replaying || this.steeringOccupied
|
|
1039
|
+
|| active.origin?.kind === 'scheduled-loop' || policy.approval === 'deny')
|
|
1040
|
+
throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE');
|
|
1041
|
+
};
|
|
1042
|
+
check();
|
|
1043
|
+
if (this.fileExportBusy)
|
|
1044
|
+
throw Error('FILE_DELIVERY_BUSY');
|
|
1045
|
+
this.fileExportBusy = true;
|
|
1046
|
+
const toolCallId = 'fleet-file-export-' + randomUUID();
|
|
1047
|
+
try {
|
|
1048
|
+
const onAbort = () => {
|
|
1049
|
+
for (const [id, pending] of this.pendingPermissions)
|
|
1050
|
+
if (pending.toolCallId === toolCallId)
|
|
1051
|
+
this.settlePendingAutomatically(id, pending, 'cancelled', undefined, 'File send cancelled');
|
|
1052
|
+
};
|
|
1053
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1054
|
+
let answer;
|
|
1055
|
+
try {
|
|
1056
|
+
answer = await this.requestPermission({ sessionId: sessionId, toolCall: {
|
|
1057
|
+
toolCallId, title: 'Send file to this ACP chat: ' + (input.filename ?? input.path), kind: 'read', status: 'pending',
|
|
1058
|
+
locations: [{ path: resolve(this.options.cwd, input.path) }],
|
|
1059
|
+
}, options: [{ optionId: 'send', name: 'Send file', kind: 'allow_once' }, { optionId: 'deny', name: 'Do not send', kind: 'reject_once' }] }, policy);
|
|
1060
|
+
}
|
|
1061
|
+
finally {
|
|
1062
|
+
signal.removeEventListener('abort', onAbort);
|
|
1063
|
+
}
|
|
1064
|
+
check();
|
|
1065
|
+
if (answer.outcome.outcome !== 'selected' || answer.outcome.optionId !== 'send')
|
|
1066
|
+
throw Error('FILE_SEND_DENIED');
|
|
1067
|
+
const attachment = await copy({ sessionGeneration: this.sessionGeneration, acpSessionId: sessionId, turnId: active.id });
|
|
1068
|
+
// Synchronous check + durable append establishes order with cancellation/stop.
|
|
1069
|
+
// No await, pending-send journal, replay or recovery exists after this point.
|
|
1070
|
+
check();
|
|
1071
|
+
this.conversation.append({ kind: 'file.attached', source: 'agent', sessionGeneration: this.sessionGeneration,
|
|
1072
|
+
acpSessionId: sessionId, promptId: active.id, turnId: active.id, payload: { attachment } });
|
|
1073
|
+
return attachment;
|
|
1074
|
+
}
|
|
1075
|
+
finally {
|
|
1076
|
+
this.releaseTool(toolCallId);
|
|
1077
|
+
this.fileExportBusy = false;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1029
1080
|
async close() {
|
|
1030
1081
|
this.closing = true;
|
|
1031
1082
|
if (this.stallTimer)
|
|
@@ -1414,6 +1465,8 @@ export class AcpSession {
|
|
|
1414
1465
|
}
|
|
1415
1466
|
async steerPrompt(text) {
|
|
1416
1467
|
this.steeringWasUsed = true;
|
|
1468
|
+
if (this.activeTurn)
|
|
1469
|
+
this.activeTurn.fileDeliveryRevoked = true;
|
|
1417
1470
|
if (!this.sessionId || !this.isAlive())
|
|
1418
1471
|
return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
|
|
1419
1472
|
this.steeringRequests++;
|
|
@@ -1445,7 +1498,7 @@ export class AcpSession {
|
|
|
1445
1498
|
this.steeringRequests--;
|
|
1446
1499
|
}
|
|
1447
1500
|
}
|
|
1448
|
-
requestPermission(params) {
|
|
1501
|
+
requestPermission(params, neutralPolicy) {
|
|
1449
1502
|
// `kinds` is a PRIORITY order. Scanning the agent's option array instead
|
|
1450
1503
|
// (`options.find(o => kinds.includes(o.kind))`) hands the choice to whatever
|
|
1451
1504
|
// order the agent happened to list, which is exactly how an automatic denial
|
|
@@ -1463,7 +1516,7 @@ export class AcpSession {
|
|
|
1463
1516
|
// Permission is part of the tool lifecycle. Reserve before any policy or
|
|
1464
1517
|
// human decision so a monitor wake cannot slip between request and answer.
|
|
1465
1518
|
this.reservePermission(toolCallId, permissionId);
|
|
1466
|
-
if (this.isEffectiveCodexProtectedMcpApproval(params)) {
|
|
1519
|
+
if (!neutralPolicy && this.isEffectiveCodexProtectedMcpApproval(params)) {
|
|
1467
1520
|
// Protected MCP approval is already the tool's narrow gate. Never turn
|
|
1468
1521
|
// this one decision into an adapter-wide standing grant.
|
|
1469
1522
|
const option = choose(['allow_once']);
|
|
@@ -1474,9 +1527,9 @@ export class AcpSession {
|
|
|
1474
1527
|
this.releasePermission(toolCallId, permissionId);
|
|
1475
1528
|
return Promise.resolve(response);
|
|
1476
1529
|
}
|
|
1477
|
-
if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
|
|
1530
|
+
if ((neutralPolicy ?? this.options.permissions).approval === 'allow' && this.withinAutomaticBoundary(params, neutralPolicy)) {
|
|
1478
1531
|
const option = choose(['allow_always', 'allow_once']);
|
|
1479
|
-
const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
|
|
1532
|
+
const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${(neutralPolicy ?? this.options.permissions).filesystem} boundary`);
|
|
1480
1533
|
if (option)
|
|
1481
1534
|
this.allowPermission(toolCallId, permissionId);
|
|
1482
1535
|
else
|
|
@@ -1486,8 +1539,8 @@ export class AcpSession {
|
|
|
1486
1539
|
// A live grace window still counts as attended: the controller may be
|
|
1487
1540
|
// mid-reconnect, and denying instantly is exactly what the grace prevents.
|
|
1488
1541
|
const unattended = this.controllerCount === 0 && !this.controllerGrace
|
|
1489
|
-
&& this.options.permissions.unattended === 'deny';
|
|
1490
|
-
if (this.options.permissions.approval === 'deny' || unattended) {
|
|
1542
|
+
&& (neutralPolicy ?? this.options.permissions).unattended === 'deny';
|
|
1543
|
+
if ((neutralPolicy ?? this.options.permissions).approval === 'deny' || unattended) {
|
|
1491
1544
|
// reject_once FIRST: `reject_always` teaches the agent a standing rule from
|
|
1492
1545
|
// a decision no human made, so one unattended denial would silently disable
|
|
1493
1546
|
// the tool for the rest of the session.
|
|
@@ -1588,8 +1641,8 @@ export class AcpSession {
|
|
|
1588
1641
|
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
1589
1642
|
: { outcome: { outcome: 'cancelled' } };
|
|
1590
1643
|
}
|
|
1591
|
-
withinAutomaticBoundary(params) {
|
|
1592
|
-
const filesystem = this.options.permissions.filesystem;
|
|
1644
|
+
withinAutomaticBoundary(params, policy) {
|
|
1645
|
+
const filesystem = (policy ?? this.options.permissions).filesystem;
|
|
1593
1646
|
if (filesystem === 'unrestricted')
|
|
1594
1647
|
return true;
|
|
1595
1648
|
if (filesystem === 'read-only' && params.toolCall.kind !== 'read')
|
|
@@ -8,7 +8,7 @@ import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.
|
|
|
8
8
|
* file touches the wire: transport updates are reduced into these shapes by a
|
|
9
9
|
* session adapter, and the store assigns `seq`/`eventId`/timestamps.
|
|
10
10
|
*/
|
|
11
|
-
export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
|
|
11
|
+
export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
|
|
12
12
|
/** A well-formed transport update this version cannot represent. Bounded, never a crash. */
|
|
13
13
|
| 'unsupported';
|
|
14
14
|
/** Where a conversation record came from. Typed provenance, never prompt text. */
|
|
@@ -228,7 +228,9 @@ export interface BoundedJson {
|
|
|
228
228
|
digest?: string;
|
|
229
229
|
redacted?: true;
|
|
230
230
|
}
|
|
231
|
-
export type ConversationPayload =
|
|
231
|
+
export type ConversationPayload = {
|
|
232
|
+
attachment: import('../file-delivery/types.js').DeliveredFile;
|
|
233
|
+
} | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
|
|
232
234
|
export interface ConversationEventV1 {
|
|
233
235
|
schemaVersion: 1;
|
|
234
236
|
roleId: string;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -284,6 +284,7 @@ export interface ConversationHandlePage {
|
|
|
284
284
|
* on any concrete transport which implements it.
|
|
285
285
|
*/
|
|
286
286
|
export interface AgentSession {
|
|
287
|
+
sendFileToChat?: (input: import('../file-delivery/types.js').FileDeliveryInput, signal: AbortSignal, copy: import('../file-delivery/types.js').CopyChatFile, policy: import('../config.js').CommonPermissions) => Promise<import('../file-delivery/types.js').DeliveredFile>;
|
|
287
288
|
readonly backend: SessionBackendId;
|
|
288
289
|
readonly pid: number;
|
|
289
290
|
/** Live capabilities; optional only for source compatibility with external adapters. */
|
package/dist/web/server.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { conversationHasAttachment } from '../application/conversation-history.js';
|
|
2
|
+
import { readDeliveredFile } from '../file-delivery/store.js';
|
|
1
3
|
import { WorkspaceDeviceAuthError } from './workspace-devices.js';
|
|
2
4
|
import { readFileSync } from 'node:fs';
|
|
3
5
|
import { packagedPresetRoot } from '../preset-bootstrap.js';
|
|
@@ -756,6 +758,28 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
756
758
|
const page = await control.conversationPage({ after: request.query.after, limit });
|
|
757
759
|
return { ...page, events: page.events.map(present) };
|
|
758
760
|
});
|
|
761
|
+
app.get('/api/v1/roles/:id/artifacts/:artifactId', async (request, reply) => {
|
|
762
|
+
auth.authenticate(request);
|
|
763
|
+
const role = await services.repository.get(request.params.id);
|
|
764
|
+
if (!role)
|
|
765
|
+
throw new FleetError('role_not_found', 'Agent not found');
|
|
766
|
+
const stateDir = services.repository.stateDir(role);
|
|
767
|
+
if (!stateDir)
|
|
768
|
+
throw new FleetError('resource_not_found', 'Agent file not found');
|
|
769
|
+
try {
|
|
770
|
+
const { record, bytes } = await readDeliveredFile(stateDir, request.params.artifactId);
|
|
771
|
+
if (!await conversationHasAttachment(stateDir, record))
|
|
772
|
+
throw Error('ARTIFACT_NOT_PUBLISHED');
|
|
773
|
+
reply.header('Cache-Control', 'private, no-store');
|
|
774
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
775
|
+
reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
|
|
776
|
+
reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
|
|
777
|
+
return reply.type('application/octet-stream').send(bytes);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
throw new FleetError('resource_not_found', 'Agent file unavailable');
|
|
781
|
+
}
|
|
782
|
+
});
|
|
759
783
|
app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
|
|
760
784
|
auth.authenticate(request);
|
|
761
785
|
const role = await services.repository.get(request.params.id);
|