@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.
@@ -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
- writeToChild(rewritten);
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
+ }
@@ -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`);
@@ -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);
@@ -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;
@@ -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 = MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
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;
@@ -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. */
@@ -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);