@ours.network/fleet 0.17.0 → 0.17.1

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.
@@ -1,4 +1,4 @@
1
- import { effectivePermissionMode } from './permissions.js';
1
+ import { inheritedPermissionMode } from './permissions.js';
2
2
  /** Present only inside a managed role process. The CLI treats it as a routing hint, not authority. */
3
3
  export const FLEET_PROXY_STATE_DIR_ENV = 'OURS_FLEET_PROXY_STATE_DIR';
4
4
  export const FLEET_PROXY_CALLER_ENV = 'OURS_FLEET_PROXY_CALLER';
@@ -21,7 +21,7 @@ export function inheritCallerSpawnDefaults(caller, requested, configPath) {
21
21
  take('cwd', caller.cwd);
22
22
  take('coordinator', caller.name);
23
23
  if (options.approval === undefined)
24
- take('approval', effectivePermissionMode(caller).fleetMode);
24
+ take('approval', inheritedPermissionMode(caller));
25
25
  take('filesystem', caller.permissions.filesystem);
26
26
  take('unattended', caller.permissions.unattended);
27
27
  take('monitorConfig', structuredClone(caller.monitor));
@@ -14,6 +14,7 @@ const LAUNCHERS = ['auto', 'ours-codex', 'codex'];
14
14
  const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
15
15
  /** Codex CLI's accepted `--ask-for-approval` values. */
16
16
  const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
17
+ const BUNDLED_CODEX_ACP_VERSION = '1.1.7';
17
18
  /**
18
19
  * What an unattended role can actually do under Codex's native settings.
19
20
  * `on-request` and `untrusted` stop to ask, and with no console attached that
@@ -55,6 +56,22 @@ function acpAgentMode(role) {
55
56
  return 'agent-full-access';
56
57
  return undefined;
57
58
  }
59
+ function acpModePermissions(mode) {
60
+ if (mode === 'read-only')
61
+ return { approval: 'on-request', sandbox: 'read-only' };
62
+ if (mode === 'agent-full-access')
63
+ return { approval: 'never', sandbox: 'danger-full-access' };
64
+ return { approval: 'on-request', sandbox: 'workspace-write' };
65
+ }
66
+ function fleetModeForApproval(nativeMode) {
67
+ if (nativeMode === 'never')
68
+ return 'allow';
69
+ if (nativeMode === 'on-request')
70
+ return 'auto';
71
+ if (nativeMode === 'untrusted')
72
+ return 'ask';
73
+ throw new Error(`unsupported Codex approval policy '${nativeMode}'`);
74
+ }
58
75
  /** Resolve & validate the per-role approval policy, throwing on an unknown value. */
59
76
  function approvalPolicy(role) {
60
77
  const o = role.harness_options;
@@ -282,6 +299,22 @@ export function makeCodexAdapter(exec = realExec) {
282
299
  return translated;
283
300
  const approval = approvalPolicy(role) ?? 'on-request';
284
301
  const sandbox = sandboxMode(role) ?? 'workspace-write';
302
+ if (role.session === 'acp') {
303
+ const mode = acpAgentMode(role) ?? 'agent';
304
+ const actual = acpModePermissions(mode);
305
+ const exact = actual.approval === approval && actual.sandbox === sandbox;
306
+ return {
307
+ ...translated,
308
+ native: { mode, ...actual },
309
+ exact,
310
+ warnings: exact ? [] : [
311
+ `bundled codex-acp ${BUNDLED_CODEX_ACP_VERSION} mode '${mode}' actually uses `
312
+ + `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
313
+ + `represent approval=${approval} sandbox=${sandbox}`,
314
+ ],
315
+ capabilities: codexCapabilities(actual.approval, actual.sandbox),
316
+ };
317
+ }
285
318
  return {
286
319
  ...translated,
287
320
  native: { approval, sandbox },
@@ -289,13 +322,17 @@ export function makeCodexAdapter(exec = realExec) {
289
322
  };
290
323
  },
291
324
  effectivePermissionMode(role) {
325
+ if (role.session === 'acp') {
326
+ const nativeMode = acpAgentMode(role) ?? 'agent';
327
+ return {
328
+ fleetMode: fleetModeForApproval(acpModePermissions(nativeMode).approval), nativeMode,
329
+ };
330
+ }
292
331
  const nativeMode = approvalPolicy(role) ?? 'untrusted';
293
- const fleetMode = nativeMode === 'never' ? 'allow'
294
- : nativeMode === 'on-request' ? 'auto'
295
- : nativeMode === 'untrusted' ? 'ask' : undefined;
296
- if (!fleetMode)
297
- throw new Error(`unsupported Codex approval policy '${nativeMode}'`);
298
- return { fleetMode, nativeMode };
332
+ return { fleetMode: fleetModeForApproval(nativeMode), nativeMode };
333
+ },
334
+ inheritedPermissionMode(role) {
335
+ return fleetModeForApproval(approvalPolicy(role) ?? 'untrusted');
299
336
  },
300
337
  vocabulary: {
301
338
  bindTool: 'choose_identity',
@@ -111,6 +111,8 @@ export interface HarnessAdapter {
111
111
  fleetMode: FleetPermissionMode;
112
112
  nativeMode: string;
113
113
  };
114
+ /** Configured portable intent to inherit when a live runtime preset is narrower. */
115
+ inheritedPermissionMode?(role: ResolvedRole): FleetPermissionMode;
114
116
  /**
115
117
  * REQUIRED. Every adapter must either translate neutral permissions or
116
118
  * explicitly declare that it cannot. Enforced at registration.
@@ -67,6 +67,8 @@ export declare function effectivePermissionMode(role: ResolvedRole): {
67
67
  fleetMode: import('./config.js').FleetPermissionMode;
68
68
  nativeMode: string;
69
69
  };
70
+ /** Preserve configured intent when a managed child inherits from its caller. */
71
+ export declare function inheritedPermissionMode(role: ResolvedRole): import('./config.js').FleetPermissionMode;
70
72
  /** Resolve one role's permissions through its adapter. Never throws. */
71
73
  export declare function analyzeRolePermissions(role: ResolvedRole): RolePermissionAnalysis;
72
74
  /** Every line a command should show for a role: translation, conflicts, floor. */
@@ -30,6 +30,11 @@ export function effectivePermissionMode(role) {
30
30
  throw new Error(`harness '${role.harness}' cannot report an effective ask|auto|allow permission mode`);
31
31
  return adapter.effectivePermissionMode(role);
32
32
  }
33
+ /** Preserve configured intent when a managed child inherits from its caller. */
34
+ export function inheritedPermissionMode(role) {
35
+ const adapter = getAdapter(role.harness);
36
+ return adapter.inheritedPermissionMode?.(role) ?? effectivePermissionMode(role).fleetMode;
37
+ }
33
38
  /**
34
39
  * Find native settings that contradict the neutral block. Only fires when the
35
40
  * operator wrote BOTH — a role that states its intent once, neutrally or
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, writeFileSync, } from 'node:fs';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { Readable, Writable } from 'node:stream';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
7
  import { normalizeSessionUpdate } from './conversation-normalizer.js';
@@ -18,7 +18,119 @@ export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
18
18
  const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
19
19
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
20
20
  const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
21
+ const MAX_CANONICAL_SYMLINK_DEPTH = 40;
21
22
  const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
23
+ /**
24
+ * Two matching realpath observations narrow the opportunity for a concurrent
25
+ * retarget, but are only an advisory consistency check: they do not lock the
26
+ * path. A mutation after the completed check remains an unavoidable TOCTOU
27
+ * window until ACP offers handle-based access.
28
+ */
29
+ function stableRealpath(path) {
30
+ const first = realpathSync.native(path);
31
+ const second = realpathSync.native(path);
32
+ return first === second ? first : undefined;
33
+ }
34
+ /** Distinguish an absent component from a dangling symlink at that component. */
35
+ function inspectMissingPath(path) {
36
+ try {
37
+ const stat = lstatSync(path);
38
+ // realpath said ENOENT but lstat found a non-link: the path changed while
39
+ // inspected, so there is no coherent canonical answer to trust.
40
+ if (!stat.isSymbolicLink())
41
+ return { kind: 'unsafe' };
42
+ try {
43
+ return { kind: 'symlink', target: resolve(dirname(path), readlinkSync(path)) };
44
+ }
45
+ catch {
46
+ return { kind: 'unsafe' };
47
+ }
48
+ }
49
+ catch (error) {
50
+ return error.code === 'ENOENT'
51
+ ? { kind: 'absent' }
52
+ : { kind: 'unsafe' };
53
+ }
54
+ }
55
+ /**
56
+ * Canonicalize an existing path, or a not-yet-created target through its
57
+ * nearest existing ancestor. Dangling links are followed explicitly because
58
+ * realpath reports their absent target as ENOENT. Only genuine absence is
59
+ * recoverable; loops, permissions, races, and every other error fail closed.
60
+ */
61
+ function canonicalTarget(path, symlinkDepth = 0) {
62
+ let probe = resolve(path);
63
+ const missing = [];
64
+ while (true) {
65
+ try {
66
+ let canonical = stableRealpath(probe);
67
+ if (!canonical)
68
+ return undefined;
69
+ // A component may have appeared while the ancestor search was in
70
+ // progress. Re-walk the suffix so a newly-created symlink is resolved,
71
+ // not treated as a lexical child of the old ancestor.
72
+ let logical = probe;
73
+ for (let i = 0; i < missing.length; i++) {
74
+ logical = resolve(logical, missing[i]);
75
+ try {
76
+ const appeared = stableRealpath(logical);
77
+ if (!appeared)
78
+ return undefined;
79
+ canonical = appeared;
80
+ }
81
+ catch (error) {
82
+ if (error.code !== 'ENOENT')
83
+ return undefined;
84
+ const inspected = inspectMissingPath(logical);
85
+ if (inspected.kind === 'unsafe')
86
+ return undefined;
87
+ if (inspected.kind === 'symlink') {
88
+ if (symlinkDepth >= MAX_CANONICAL_SYMLINK_DEPTH)
89
+ return undefined;
90
+ return canonicalTarget(resolve(inspected.target, ...missing.slice(i + 1)), symlinkDepth + 1);
91
+ }
92
+ return resolve(canonical, ...missing.slice(i));
93
+ }
94
+ }
95
+ return canonical;
96
+ }
97
+ catch (error) {
98
+ if (error.code !== 'ENOENT')
99
+ return undefined;
100
+ const inspected = inspectMissingPath(probe);
101
+ if (inspected.kind === 'unsafe')
102
+ return undefined;
103
+ if (inspected.kind === 'symlink') {
104
+ if (symlinkDepth >= MAX_CANONICAL_SYMLINK_DEPTH)
105
+ return undefined;
106
+ return canonicalTarget(resolve(inspected.target, ...missing), symlinkDepth + 1);
107
+ }
108
+ const parent = dirname(probe);
109
+ if (parent === probe)
110
+ return undefined;
111
+ missing.unshift(basename(probe));
112
+ probe = parent;
113
+ }
114
+ }
115
+ }
116
+ function canonicallyWithin(root, candidates) {
117
+ if (candidates.length === 0)
118
+ return false;
119
+ try {
120
+ const firstRoot = realpathSync.native(root);
121
+ const paths = candidates.map(canonicalTarget);
122
+ const secondRoot = realpathSync.native(root);
123
+ if (firstRoot !== secondRoot || paths.some(path => path === undefined))
124
+ return false;
125
+ return paths.every(candidate => {
126
+ const rel = relative(firstRoot, candidate);
127
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
128
+ });
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
22
134
  /**
23
135
  * Map typed prompt provenance to the conversation ledger's source vocabulary.
24
136
  * Only operator-authored local sources may persist prompt bodies; external
@@ -947,11 +1059,7 @@ export class AcpSession {
947
1059
  if (locations.length === 0)
948
1060
  return false;
949
1061
  const cwd = resolve(this.options.cwd);
950
- return locations.every(location => {
951
- const path = resolve(location.path);
952
- const rel = relative(cwd, path);
953
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
954
- });
1062
+ return canonicallyWithin(cwd, locations.map(location => resolve(location.path)));
955
1063
  }
956
1064
  recordUpdate(update) {
957
1065
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",