@ours.network/fleet 1.2.0-nightly.53 → 1.2.0-nightly.55

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.
Files changed (108) hide show
  1. package/README.md +22 -12
  2. package/dist/agent-ours/bridge.js +10 -3
  3. package/dist/agent-ours/file-rpc.d.ts +8 -0
  4. package/dist/agent-ours/file-rpc.js +11 -1
  5. package/dist/agent-ours/harness.js +47 -3
  6. package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
  7. package/dist/agent-ours/mcp-endpoint.js +4 -0
  8. package/dist/agent-ours/service.d.ts +6 -1
  9. package/dist/agent-ours/service.js +73 -8
  10. package/dist/application/conversation-history.d.ts +2 -0
  11. package/dist/application/conversation-history.js +25 -2
  12. package/dist/application/log-service.js +8 -8
  13. package/dist/application/role-creation-service.js +2 -1
  14. package/dist/application/role-removal-service.js +7 -1
  15. package/dist/application/role-repository.js +17 -0
  16. package/dist/application/supervisor-ours-tools.js +3 -1
  17. package/dist/application/task-layouts.js +12 -2
  18. package/dist/application/task-room-service.js +19 -17
  19. package/dist/application/types.d.ts +2 -0
  20. package/dist/build-info.json +4 -4
  21. package/dist/cli.js +31 -3
  22. package/dist/config.d.ts +11 -0
  23. package/dist/config.js +13 -2
  24. package/dist/file-delivery/reader.d.ts +17 -0
  25. package/dist/file-delivery/reader.js +94 -0
  26. package/dist/file-delivery/store.d.ts +15 -0
  27. package/dist/file-delivery/store.js +124 -0
  28. package/dist/file-delivery/types.d.ts +19 -0
  29. package/dist/file-delivery/types.js +1 -0
  30. package/dist/harness/agent-session.d.ts +2 -0
  31. package/dist/harness/claude-code-session.js +1 -1
  32. package/dist/harness/codex-app-server-proxy.js +6 -2
  33. package/dist/harness/codex-file-inventory.d.ts +15 -0
  34. package/dist/harness/codex-file-inventory.js +113 -0
  35. package/dist/harness/codex-session.js +2 -2
  36. package/dist/harness/codex.js +2 -1
  37. package/dist/isolation/policy.js +4 -1
  38. package/dist/managed-recovery.d.ts +13 -0
  39. package/dist/managed-recovery.js +31 -0
  40. package/dist/ops.d.ts +2 -0
  41. package/dist/ops.js +15 -2
  42. package/dist/rooms-tasks/cli.js +21 -0
  43. package/dist/rooms-tasks/close.d.ts +2 -0
  44. package/dist/rooms-tasks/close.js +66 -1
  45. package/dist/rooms-tasks/deletion.js +10 -0
  46. package/dist/rooms-tasks/erasure.js +4 -2
  47. package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
  48. package/dist/rooms-tasks/layout-member-retirement.js +90 -0
  49. package/dist/rooms-tasks/layout-service.d.ts +1 -0
  50. package/dist/rooms-tasks/layout-service.js +31 -13
  51. package/dist/rooms-tasks/layout.d.ts +7 -0
  52. package/dist/rooms-tasks/layout.js +35 -0
  53. package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
  54. package/dist/rooms-tasks/legacy-task-member.js +116 -0
  55. package/dist/rooms-tasks/member-ownership.d.ts +5 -0
  56. package/dist/rooms-tasks/member-ownership.js +40 -0
  57. package/dist/rooms-tasks/provision.js +26 -1
  58. package/dist/rooms-tasks/recovery.d.ts +19 -0
  59. package/dist/rooms-tasks/recovery.js +110 -0
  60. package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
  61. package/dist/rooms-tasks/task-service-retirement.js +21 -0
  62. package/dist/rooms-tasks/task-state.d.ts +1 -0
  63. package/dist/rooms-tasks/task-state.js +16 -1
  64. package/dist/rooms-tasks/types.d.ts +3 -0
  65. package/dist/rooms-tasks/workspace.d.ts +2 -0
  66. package/dist/rooms-tasks/workspace.js +5 -0
  67. package/dist/runner.d.ts +6 -0
  68. package/dist/runner.js +161 -22
  69. package/dist/session/acp.d.ts +5 -0
  70. package/dist/session/acp.js +68 -8
  71. package/dist/session/arbiter.d.ts +5 -0
  72. package/dist/session/arbiter.js +16 -2
  73. package/dist/session/codex-app-server.d.ts +1 -0
  74. package/dist/session/codex-app-server.js +5 -0
  75. package/dist/session/conversation-types.d.ts +4 -2
  76. package/dist/session/types.d.ts +3 -0
  77. package/dist/spawn.d.ts +5 -1
  78. package/dist/spawn.js +32 -3
  79. package/dist/startup-readiness.d.ts +1 -1
  80. package/dist/startup-readiness.js +3 -3
  81. package/dist/supervisor/adoption.d.ts +8 -0
  82. package/dist/supervisor/adoption.js +78 -0
  83. package/dist/supervisor/catalog.d.ts +62 -0
  84. package/dist/supervisor/catalog.js +254 -0
  85. package/dist/supervisor/fleet.d.ts +8 -0
  86. package/dist/supervisor/fleet.js +89 -0
  87. package/dist/supervisor/index.js +3 -6
  88. package/dist/supervisor/launchd.d.ts +1 -1
  89. package/dist/supervisor/launchd.js +24 -12
  90. package/dist/supervisor/legacy.d.ts +35 -0
  91. package/dist/supervisor/legacy.js +524 -0
  92. package/dist/supervisor/manager.d.ts +15 -0
  93. package/dist/supervisor/manager.js +258 -0
  94. package/dist/supervisor/scope.d.ts +4 -0
  95. package/dist/supervisor/scope.js +10 -0
  96. package/dist/supervisor/systemd.d.ts +2 -2
  97. package/dist/supervisor/systemd.js +14 -14
  98. package/dist/task-supervision.d.ts +9 -0
  99. package/dist/task-supervision.js +145 -0
  100. package/dist/temp-lifecycle.d.ts +17 -7
  101. package/dist/temp-lifecycle.js +85 -53
  102. package/dist/temp-supervisor-recovery.d.ts +1 -1
  103. package/dist/temp-supervisor-recovery.js +2 -2
  104. package/dist/web/server.js +24 -0
  105. package/docs/acp-files.md +102 -0
  106. package/docs/fleet-migration.md +54 -0
  107. package/docs/task-workspaces.md +26 -0
  108. package/package.json +7 -3
package/README.md CHANGED
@@ -10,8 +10,9 @@ turns such sessions into **roles**: long-lived agents that
10
10
 
11
11
  - **run through structured managed sessions** behind one provider-neutral
12
12
  agent-session interface, which you can inspect or prompt,
13
- - are **supervised** — systemd (Linux) or launchd (macOS) restarts them on crash
14
- and brings them back after a reboot,
13
+ - are **supervised** — one Fleet systemd service (Linux) or LaunchAgent (macOS)
14
+ runs their existing agent runners, restarts them on crash, and restores the
15
+ desired running roster at boot or login,
15
16
  - **resume their context** across restarts (when the harness supports it),
16
17
  - **bind an ours.network identity**, so you — and every other agent — can message
17
18
  them by name over an end-to-end-encrypted channel
@@ -44,11 +45,11 @@ brain: { inline: { harness: codex, session: codex-app-server } }
44
45
 
45
46
  ```
46
47
  ~/fleet.yaml + ~/fleet/{agents,agent_templates,roles,brains,room_templates}/*.yaml your declaration
47
- │ ours-fleet up
48
+ │ ours-fleet init, then up
48
49
  ▼
49
- briefing.md per role ──► agent session adapter ──► native app-server or ACP transport
50
- ▲ │
51
- systemd --user / launchd ───────┘ restart on crash, start at boot/login
50
+ one Fleet OS service ──► private member catalog + existing agent runners
51
+ │
52
+ briefing.md per member ──► session adapter ──► native app-server or ACP transport
52
53
  ```
53
54
 
54
55
  Each role gets a state dir (`~/.ours-fleet/agents/<Name>/`) holding its briefing,
@@ -81,9 +82,10 @@ The state dir contract:
81
82
  | a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
82
83
  | `ours` CLI + shared daemon | identity + agent-to-agent messaging | `ours-install client --config /absolute/private/profile.json --integrations fleet --fleet-settings /absolute/fleet-settings.json` |
83
84
 
84
- Linux only: `ours-fleet init` enables *linger* so roles run without a login session
85
- and survive reboots. macOS: launchd agents start **at login** (no linger
86
- equivalent); logs land in `~/.ours-fleet/logs/`.
85
+ Linux only: `ours-fleet init` enables *linger* so Fleet runs without a login session
86
+ and restores running members after reboot. On macOS the Fleet LaunchAgent starts
87
+ **at login** (no linger equivalent). Member logs live in each member's state
88
+ directory; use `ours-fleet logs <Name>` to read them.
87
89
 
88
90
  ## Install
89
91
 
@@ -103,6 +105,12 @@ maintained adapters require Node 22 or newer.
103
105
  Each OS user manages their own fleet — to host roles under a sandboxed account,
104
106
  become that account and repeat.
105
107
 
108
+ When upgrading from per-agent services, coordinate package installation with
109
+ explicit `ours-fleet init`. Preserve operator readiness hooks on the common
110
+ parent before migration; new task/temporary creation requires that parent.
111
+ See the [migration and rollback runbook](docs/fleet-migration.md)
112
+ for changed stop/restart behaviour and legacy task transfer limits.
113
+
106
114
  ## Quickstart
107
115
 
108
116
  ```sh
@@ -331,9 +339,9 @@ Installs predating the stamp are compared by hashing their `dist/`, so two of
331
339
  those are still told apart.
332
340
 
333
341
  A permanent role brought up via `-c custom.yaml` remembers that file (`.config-path`
334
- in its state dir) across supervisor-triggered restarts — systemd/launchd re-invoke the
335
- agent process with no arguments, so without this the role would silently fall back to
336
- the default `~/fleet.yaml` on its very first crash-restart and fail to resolve.
342
+ in its state dir and its private member registration) across supervisor-triggered
343
+ restarts. Its runner resolves that original configuration instead of falling back
344
+ to the default `~/fleet.yaml`.
337
345
 
338
346
  ## split configuration field reference
339
347
 
@@ -1920,3 +1928,5 @@ Prerelease rollout requires this Fleet capability to be published first, then th
1920
1928
  installer's normal generated version/integrity lock updated, then installer and
1921
1929
  App released. The currently pinned Fleet nightly.41 does not support v2 grants.
1922
1930
  These source changes do not publish, merge, or deploy any component.
1931
+
1932
+ 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
- handles.set(args.handle, { file, call, write: op === 'write-open' });
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. */
@@ -20,4 +22,7 @@ export declare function readRoomReadiness(roomCid: string, name: string): {
20
22
  generation: number;
21
23
  } | undefined;
22
24
  /** Explicit logical-instance termination, independent of harness/bridge transport. */
23
- export declare function releaseManagedAgent(role: ResolvedRole): Promise<void>;
25
+ export declare function releaseManagedAgent(role: ResolvedRole, expected?: {
26
+ cid: string;
27
+ action: string;
28
+ }): Promise<void>;
@@ -1,9 +1,13 @@
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
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
7
  import { join } from 'node:path';
5
8
  import { attachOursClient } from '@ours.network/sdk/client';
6
9
  import { ApplicationIdentityStore } from '@ours.network/mcp/application-identities';
10
+ import { assertSafeAncestors } from '../rooms-tasks/workspace.js';
7
11
  import { readClientProfile } from '../client-profile.js';
8
12
  import { stateRoot } from '../paths.js';
9
13
  import { createCoworkAdapter } from '../rooms-tasks/cowork-adapter.js';
@@ -94,6 +98,8 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
94
98
  }
95
99
  const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance);
96
100
  let closeClient;
101
+ let exportRoot;
102
+ let fileSender;
97
103
  let partialEndpoint;
98
104
  try {
99
105
  let prior = controller.journal.read();
@@ -210,6 +216,18 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
210
216
  const bridgeDir = join(stateDir, '.ours-bridge');
211
217
  mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
212
218
  const capability = randomBytes(32).toString('hex'), socket = privateSocketPath(join(bridgeDir, `g${generation}.sock`));
219
+ const cwd = role.cwd && existsSync(role.cwd) ? resolve(role.cwd) : resolve(stateDir);
220
+ if (role.file_delivery?.enabled) {
221
+ if (role.session !== 'acp' || !['codex', 'claude-code'].includes(role.harness))
222
+ throw Error('FILE_DELIVERY_REQUIRES_CODEX_OR_CLAUDE_ACP');
223
+ const rootPath = resolve(cwd, role.file_delivery.directory);
224
+ const forbidden = [privateRuntimeRoot(), join(stateRoot(), 'private-file-delivery'), profile.configPath, profile.credentialPath];
225
+ if (forbidden.some(p => rootPath === resolve(p) || rootPath.startsWith(resolve(p) + '/') || resolve(p).startsWith(rootPath + '/')))
226
+ throw Error('PRIVATE_STATE_CANNOT_BE_EXPORTED');
227
+ await ensureExportDirectory(cwd, role.file_delivery.directory);
228
+ exportRoot = await pinExportRoot(rootPath);
229
+ }
230
+ const artifactStore = new ArtifactStore(stateDir);
213
231
  const endpoint = await startMcpEndpoint({
214
232
  socket,
215
233
  capability,
@@ -218,6 +236,26 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
218
236
  client,
219
237
  identities,
220
238
  remoteDaemonFiles: true,
239
+ ...(exportRoot ? { currentChatFileDirectory: role.file_delivery.directory, currentChatFile: (async (input, extra, files) => {
240
+ if (!fileSender || !exportRoot)
241
+ throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE');
242
+ exportPath(exportRoot.root, cwd, input.path); // validate before approval/read
243
+ const name = input.filename ?? basename(input.path);
244
+ if (!name || Buffer.byteLength(name) > 240 || /[\/\\\x00-\x1f]/.test(name) || name === '.' || name === '..')
245
+ throw Error('INVALID_FILENAME');
246
+ const mime = input.mime ?? { '.txt': 'text/plain', '.md': 'text/markdown', '.pdf': 'application/pdf', '.json': 'application/json', '.html': 'text/html', '.png': 'image/png' }[extname(name).toLowerCase()] ?? 'application/octet-stream';
247
+ if (mime.length > 200 || !/^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(mime))
248
+ throw Error('INVALID_MIME');
249
+ return fileSender(input, extra.signal, async (binding) => {
250
+ const source = await files.readExport(input.path, exportRoot.root, extra);
251
+ try {
252
+ return await artifactStore.copy(source, name, mime, binding, extra.signal);
253
+ }
254
+ finally {
255
+ await source.close();
256
+ }
257
+ }, role.permissions);
258
+ }) } : {}),
221
259
  });
222
260
  partialEndpoint = endpoint;
223
261
  const descriptor = join(bridgeDir, 'descriptor.json');
@@ -225,10 +263,13 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
225
263
  return {
226
264
  runtime,
227
265
  descriptor,
228
- privatePaths: [root, profile.configPath, profile.credentialPath],
266
+ privatePaths: [root, profile.configPath, profile.credentialPath, join(stateRoot(), 'private-file-delivery')],
267
+ setFileDelivery: sender => { fileSender = sender; },
229
268
  close: async (terminal) => {
230
269
  try {
270
+ fileSender = undefined;
231
271
  await endpoint.close();
272
+ await exportRoot?.handle.close();
232
273
  if (terminal)
233
274
  await runtime.terminal();
234
275
  else
@@ -248,6 +289,7 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
248
289
  catch (error) {
249
290
  try {
250
291
  await partialEndpoint?.close();
292
+ await exportRoot?.handle.close();
251
293
  }
252
294
  finally {
253
295
  try {
@@ -318,24 +360,47 @@ export function readRoomReadiness(roomCid, name) {
318
360
  }
319
361
  }
320
362
  /** Explicit logical-instance termination, independent of harness/bridge transport. */
321
- export async function releaseManagedAgent(role) {
363
+ export async function releaseManagedAgent(role, expected) {
322
364
  const profile = readClientProfile({ ...process.env, ...role.env });
323
365
  if (!profile)
324
366
  throw Error('MANAGED_OURS_REQUIRES_EXPLICIT_DAEMON_PROFILE');
325
367
  const root = privateRuntimeRoot(), dir = join(root, binderKey(profile.expectedInstanceId, role.identity));
326
- if (!existsSync(join(dir, 'instance.json')))
368
+ if (!existsSync(join(dir, 'instance.json'))) {
369
+ if (expected)
370
+ throw Error('MEMBER_RUNTIME_RETIREMENT_MISSING');
327
371
  return;
328
- const instance = JSON.parse(readFileSync(join(dir, 'instance.json'), 'utf8'));
372
+ }
373
+ const read = (file) => {
374
+ const path = join(dir, file);
375
+ if (expected) {
376
+ assertSafeAncestors(dir);
377
+ const stat = lstatSync(path);
378
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024)
379
+ throw Error('MEMBER_RUNTIME_UNSAFE_PROOF');
380
+ }
381
+ return JSON.parse(readFileSync(path, 'utf8'));
382
+ };
383
+ const instance = read('instance.json');
384
+ if (expected) {
385
+ read('state.json');
386
+ read('owner.json');
387
+ }
329
388
  if (instance.role !== role.name)
330
389
  throw Error('IDENTITY_ASSIGNED_TO_OTHER_ROLE');
331
390
  const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance.instance);
332
391
  try {
333
392
  const state = controller.journal.read();
334
- if (!state || state.phase === 'RELEASED')
393
+ if (expected && (!state || state.name !== role.identity || state.daemon !== profile.expectedInstanceId
394
+ || state.instance !== instance.instance || state.lifetime !== 'temporary' || instance.temporary !== true
395
+ || state.cid !== expected.cid || state.action !== expected.action))
396
+ throw Error('MEMBER_RUNTIME_RETIREMENT_MISMATCH');
397
+ if (!state)
335
398
  return;
336
- const owner = JSON.parse(readFileSync(join(dir, 'owner.json'), 'utf8'));
337
- if (owner.instance !== state.instance)
399
+ const owner = read('owner.json');
400
+ if (owner.instance !== state.instance || typeof owner.token !== 'string' || !owner.token)
338
401
  throw Error('OWNER_RECORD_MISMATCH');
402
+ if (state.phase === 'RELEASED')
403
+ return;
339
404
  const client = await attachOursClient({
340
405
  endpoint: profile.endpoint,
341
406
  expectedInstanceId: profile.expectedInstanceId,
@@ -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
+ }
@@ -3,7 +3,6 @@ import { createHmac, randomBytes } from 'node:crypto';
3
3
  import { join } from 'node:path';
4
4
  import { realExec } from '../exec.js';
5
5
  import { logsRoot } from '../paths.js';
6
- import { unitFor } from '../supervisor/systemd.js';
7
6
  import { ROLE_NAME_RE } from '../config.js';
8
7
  import { FleetError } from './errors.js';
9
8
  const STANDARD_REDACTIONS = [
@@ -78,12 +77,13 @@ export class StructuredLogService {
78
77
  }
79
78
  async tail(roleId, requested, cursor) {
80
79
  const limit = Math.min(Math.max(requested, 1), 1_000);
81
- const args = this.backend.id === 'systemd'
82
- ? {
83
- cmd: 'journalctl',
84
- args: ['--user', '-u', unitFor(roleId), '-o', 'json', '--no-pager', '-n', String(limit)],
85
- }
86
- : this.backend.logsArgs(roleId, false);
80
+ const source = this.backend.logsArgs(roleId, false);
81
+ const args = { cmd: source.cmd, args: [...source.args] };
82
+ const count = args.args.indexOf('-n');
83
+ if (count >= 0)
84
+ args.args[count + 1] = String(limit);
85
+ if (args.cmd === 'journalctl')
86
+ args.args.push('-o', 'json', '--no-pager', '-n', String(limit));
87
87
  if (!['journalctl', 'tail', 'tmux'].includes(args.cmd))
88
88
  throw new FleetError('capability_unavailable', 'log backend is unsupported');
89
89
  const result = await this.exec(args.cmd, args.args);
@@ -93,7 +93,7 @@ export class StructuredLogService {
93
93
  const parsed = lines.map((line, index) => {
94
94
  let text = line;
95
95
  let at;
96
- if (this.backend.id === 'systemd') {
96
+ if (args.cmd === 'journalctl') {
97
97
  try {
98
98
  const json = JSON.parse(line);
99
99
  text = typeof json.MESSAGE === 'string' ? json.MESSAGE : line;
@@ -59,7 +59,8 @@ export class RoleCreationService {
59
59
  }
60
60
  async createManaged(caller, requested) {
61
61
  const plan = this.previewSpawn({ origin: 'managed', caller, options: requested });
62
- const creationActionId = randomUUID();
62
+ const creationActionId = requested.roomMemberStartup?.task_id
63
+ ? requested.creationActionId ?? randomUUID() : randomUUID();
63
64
  plan.options.creationActionId = creationActionId;
64
65
  const statePath = await this.launchSync(plan.options);
65
66
  const selectionSummary = (kind) => {
@@ -3,7 +3,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs'
3
3
  import { basename, dirname, join } from 'node:path';
4
4
  import { loadConfig, ROLE_NAME_RE } from '../config.js';
5
5
  import { agentDir, stateRoot } from '../paths.js';
6
- import { rmRole } from '../ops.js';
6
+ import { assertStandaloneRoleRemoval, rmRole } from '../ops.js';
7
7
  import { FleetError } from './errors.js';
8
8
  import { provesGeneratedAgentSource } from '../generated-agent-source.js';
9
9
  export class RoleRemovalService {
@@ -19,6 +19,12 @@ export class RoleRemovalService {
19
19
  if (!ROLE_NAME_RE.test(requested))
20
20
  throw new FleetError('invalid_request', 'invalid role name');
21
21
  const cfg = loadConfig(this.options.configPath);
22
+ try {
23
+ assertStandaloneRoleRemoval(cfg, requested);
24
+ }
25
+ catch (error) {
26
+ throw new FleetError('conflict', error instanceof Error ? error.message : String(error));
27
+ }
22
28
  const names = new Set(cfg.roles.map(role => role.name));
23
29
  for (const root of [dirname(agentDir('_')), dirname(agentDir('_', true))]) {
24
30
  try {
@@ -5,6 +5,21 @@ import { loadConfig, resolveSnapshotMonitor, resolvePermissions, ROLE_NAME_RE, }
5
5
  import { agentsRoot, tmpRoot } from '../paths.js';
6
6
  import { readSpawnLineage } from '../web/topology.js';
7
7
  const MAX_SNAPSHOT_BYTES = 256 * 1024;
8
+ function retainedTaskId(dir, name) {
9
+ try {
10
+ const path = join(dir, '.temp-supervisor.json');
11
+ const stat = lstatSync(path);
12
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024)
13
+ return undefined;
14
+ const record = JSON.parse(readFileSync(path, 'utf8'));
15
+ const id = record?.taskOwner?.taskId;
16
+ return record?.version === 1 && record?.role === name && typeof id === 'string'
17
+ && /^[0-9a-z]{9}[0-9a-f]{8}$/.test(id) ? id : undefined;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
8
23
  function safeDirs(root) {
9
24
  try {
10
25
  return readdirSync(root, { withFileTypes: true })
@@ -128,10 +143,12 @@ export class RoleRepository {
128
143
  const stateRef = inTemporary ? { lifetime: 'temporary' }
129
144
  : inPermanent ? { lifetime: 'permanent' } : undefined;
130
145
  const tempStateDir = inTemporary ? join(temporaryRoot, name) : undefined;
146
+ const taskId = !config && tempStateDir ? retainedTaskId(tempStateDir, name) : undefined;
131
147
  return {
132
148
  id: name,
133
149
  lifetime: config ? 'permanent' : inTemporary ? 'temporary' : 'orphan',
134
150
  configured: Boolean(config),
151
+ ...(taskId ? { taskId } : {}),
135
152
  config: config ? view(config) : tempRole ? view(tempRole) : undefined,
136
153
  stateRef,
137
154
  stateHealth,
@@ -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
- if (legacy && descriptor.socket !== join(selected.dir, '.ours-bridge', `g${descriptor.generation}.sock`))
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)