@ours.network/fleet 1.2.0-nightly.44 → 1.2.0-nightly.46

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 CHANGED
@@ -286,10 +286,33 @@ ours-fleet loops reload <Role>
286
286
  ours-fleet loops run-now|disable|enable <Role> <Loop>
287
287
  ours-fleet rm <Name>
288
288
  ours-fleet doctor [--harness H]
289
+ ours-fleet managed-cli setup [--enable <Agent>]... | status [--json] native harness policy for the task workflow
289
290
  ours-fleet version [--json] build identity, capabilities, installs on PATH
290
291
  ours-fleet init
291
292
  ```
292
293
 
294
+ ### Fleet task commands from a harness command sandbox
295
+
296
+ A Codex or Claude command sandbox refuses the supervisor socket, so a managed
297
+ `ours-fleet` call fails with `connect EPERM` before it parses anything. Fleet
298
+ prepares the native harness policy for the packaged Coordinator task workflow;
299
+ you do not write Codex rules or Claude sandbox settings by hand:
300
+
301
+ ```sh
302
+ ours-fleet managed-cli setup --enable FleetCoordinator # adds managed_cli: [task-workflow] to that Agent
303
+ ours-fleet managed-cli status # static setup, session policy, observed commands
304
+ ```
305
+
306
+ The opt-in is an explicit Agent key and changes no permission. Setup starts no
307
+ session or model, is repeated at every agent start, and `ours-install` runs it
308
+ for you. It prepares `task create|start|finish|block|unblock|review|list|show`,
309
+ `room show|members` and template/plan/help inspection for one pinned
310
+ configuration, and nothing else; `task start`/`task finish` are real authority.
311
+ Qualified end to end on Linux with Codex (native and ACP) and Claude ACP; macOS
312
+ is prepared the same way but not yet qualified. Scope, boundaries, unsupported
313
+ combinations and the evidence are in
314
+ [docs/validation/managed-cli-permissions.md](docs/validation/managed-cli-permissions.md).
315
+
293
316
  ### Which build am I running?
294
317
 
295
318
  `--version` prints a semver, and a semver does not identify an artifact: version
@@ -1833,6 +1856,58 @@ pipe with a 32KiB limit and refuses a terminal. Legacy v1 payloads, which contai
1833
1856
  long-lived scoped credentials, are accepted only through file/stdin, never as
1834
1857
  command arguments. Use only one payload transport.
1835
1858
 
1859
+ When this host already has a registration, Fleet inspects the one-use grant without
1860
+ consuming it. The same workspace reruns without a deletion prompt or retirement.
1861
+ An account-origin change for that workspace requires `--migrate-app-origin`; a
1862
+ changed server identity is refused. An older App without inspection support falls
1863
+ back to asking before redemption. An unavailable inspection service preserves the
1864
+ grant and setup. Fleet verifies the redeemed destination against the inspection.
1865
+
1866
+ For another workspace, setup describes the existing workspace, tunnel hostname and
1867
+ account origin and asks `[y/N]`. No answer or `no` preserves the setup and grant; a
1868
+ noninteractive caller must pass `--replace-registration`, otherwise it exits with
1869
+ an error. A legacy binding without a server identity must rerun its own workspace
1870
+ first. The original Human root signs retirement. Fleet waits for the account's
1871
+ durable cleanup receipt before stopping the old service and revoking its linked
1872
+ devices. If a migrated database has no old registration, the signed command confirms
1873
+ absence; the service has no old provider resources to delete. Installation identities,
1874
+ profile, agents, history, stable host ID and local pairing are preserved.
1875
+
1876
+ Replacement recovery is saved in an owned private `workspace/replacement.json`
1877
+ after root, host, service and server-contact preconditions pass. The App must
1878
+ explicitly support deadline-enforced retirement; an older App without that
1879
+ capability preserves the existing setup. `setup-tunnel --resume` retries its nonce
1880
+ and saved operation deadline (24 hours less a five-minute clock margin).
1881
+ If the enrollment challenge expires, obtain a fresh App command for the same target
1882
+ and pass `--replace-registration`; completed retirement is retained. The old server
1883
+ identity is pinned: only its own invitation can reconnect it.
1884
+
1885
+ `setup-tunnel --abandon-replacement` removes the private recovery payload only when
1886
+ a fresh private receipt confirms no deletion and the operation was never dispatched
1887
+ or was terminally rejected. A lost transport response remains uncertain. Expired
1888
+ operations require a fresh receipt proving the old binding still exists after the
1889
+ deadline plus five minutes and an explicit server guarantee that the deadline is enforced;
1890
+ older recovery records without that guarantee must be resumed. Abandoning preserves
1891
+ the existing binding, connector and installation data. A terminally rejected operation
1892
+ requires abandonment and a new App command, rather than reusing its nonce.
1893
+ After that deadline, a fresh command for the same target can also resume with a
1894
+ new operation nonce after checking the enforced expiry. This confirms signed
1895
+ absence if an old successful receipt was lost and later pruned.
1896
+
1897
+ If the App accepted the successor proof before Fleet wrote its local binding,
1898
+ a fresh command for that target retains the original private connector from the
1899
+ recovery record. If that successor cannot be recovered, remove it in the App,
1900
+ then use `setup-tunnel --discard-retired-replacement` and a fresh App command.
1901
+ Discard is allowed only after retirement and local cleanup, while the old local
1902
+ binding remains. It removes only the recovery record. This host has no working
1903
+ registration until the new setup completes; the old service was stopped and its
1904
+ linked devices revoked.
1905
+
1906
+ Renewal commands contain no existing live connector credential. Fleet keeps the
1907
+ current private connector byte for byte, and accepts renewal only for its recorded
1908
+ workspace, server, origin and tunnel hostname. Restore a missing or unsafe connector,
1909
+ or remove and re-create that workspace in the App before setting it up again.
1910
+
1836
1911
  After service and signed tunnel-target confirmation, setup prints both a QR and
1837
1912
  ordinary private single-use connection code. The App checks readiness automatically
1838
1913
  and immediately opens the code field. If setup or code output fails after binding,
@@ -1,3 +1,4 @@
1
+ import { retireNotificationTarget } from '../notifications/target-cleanup.js';
1
2
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
2
3
  import { basename, dirname, join } from 'node:path';
3
4
  import { loadConfig, ROLE_NAME_RE } from '../config.js';
@@ -87,6 +88,7 @@ export class RoleRemovalService {
87
88
  await this.options.ops.backend.uninstall(preview.role);
88
89
  if (existsSync(sourceState))
89
90
  renameSync(sourceState, join(recoveryPath, 'orphan-state'));
91
+ await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(preview.role)}`).catch(() => { });
90
92
  this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
91
93
  }
92
94
  return { ...preview, removed: true, recoveryPath };
@@ -15,6 +15,8 @@ export interface BriefingOpts {
15
15
  identityGuarantee?: 'verified' | 'created' | 'unverified';
16
16
  /** Temporary spawn whose newly-created identity should share the session lifecycle. */
17
17
  temporaryIdentity?: boolean;
18
+ /** Fleet-prepared invocation guidance (src/managed-cli.ts); empty when none applies. */
19
+ managedCli?: string[];
18
20
  }
19
21
  /** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
20
22
  export declare function generateBriefing(role: ResolvedRole, v: BriefingVocab, opts: BriefingOpts): string;
package/dist/briefing.js CHANGED
@@ -197,9 +197,16 @@ export function generateBriefing(role, v, opts) {
197
197
  L.push('For omitted settings, the supervisor inherits your canonical Brain and Role selections,');
198
198
  L.push('working directory, neutral permissions, coordinator, and fleet monitor policy. Every');
199
199
  L.push('explicit option wins; identity/session-local and secret material never inherit.');
200
- L.push('All public CLI surfaces are available; hidden worker entry points remain internal.');
200
+ L.push('All public CLI surfaces are routed this way; hidden worker entry points remain internal.');
201
+ if (role.permissions && role.permissions.filesystem !== 'unrestricted' && !opts.managedCli?.length) {
202
+ L.push('Routing is not reachability: every call first connects to the supervisor socket, and a');
203
+ L.push('harness command sandbox can refuse that connection (`connect EPERM`), even for `--help`.');
204
+ L.push('No Fleet command is prepared to run outside your sandbox. If one is refused, report it');
205
+ L.push('as a blocker; the owner enables a workflow with `ours-fleet managed-cli setup --enable`.');
206
+ }
201
207
  L.push('Lifecycle notice delivery failures stay in service diagnostics and never rerun effects.');
202
208
  L.push('This proxy is attribution and convenience, not a security boundary for unisolated roles.');
209
+ L.push(...(opts.managedCli ?? []));
203
210
  }
204
211
  if (role.coordinator) {
205
212
  L.push(`7. ANNOUNCE yourself: call **${v.sendTool}** to contact "${role.coordinator}" with text:`);
@@ -1,10 +1,11 @@
1
1
  {
2
- "version": "1.2.0-nightly.44",
3
- "buildId": "0814e82b7dde",
4
- "commit": "94ad261abac133a3c17081c1a531a8a363178470",
2
+ "version": "1.2.0-nightly.46",
3
+ "buildId": "a75b12e57308",
4
+ "commit": "f608ced10209f4b9ecf9dc9b573d8cab46136a68",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-04T08:52:19.101Z",
6
+ "builtAt": "2026-10-04T23:11:11.430Z",
7
7
  "capabilities": [
8
+ "managed-cli.setup-v1",
8
9
  "workspace.enroll.preserve-profile-v1",
9
10
  "cowork.http-management-v1",
10
11
  "monitor.interrupt.after_tool"
@@ -16,7 +16,9 @@
16
16
  export declare const CAP_MONITOR_INTERRUPT_AFTER_TOOL = "monitor.interrupt.after_tool";
17
17
  export declare const CAP_HTTP_COWORK = "cowork.http-management-v1";
18
18
  export declare const CAP_WORKSPACE_PRESERVE_PROFILE = "workspace.enroll.preserve-profile-v1";
19
- export declare const CAPABILITIES: readonly ["workspace.enroll.preserve-profile-v1", "cowork.http-management-v1", "monitor.interrupt.after_tool"];
19
+ /** `ours-fleet managed-cli setup|status` and the `managed_cli` Agent key (#233). */
20
+ export declare const CAP_MANAGED_CLI_SETUP = "managed-cli.setup-v1";
21
+ export declare const CAPABILITIES: readonly ["managed-cli.setup-v1", "workspace.enroll.preserve-profile-v1", "cowork.http-management-v1", "monitor.interrupt.after_tool"];
20
22
  export type Capability = (typeof CAPABILITIES)[number];
21
23
  /** Does this build declare `token`? */
22
24
  export declare const hasCapability: (token: string) => boolean;
@@ -16,7 +16,10 @@
16
16
  export const CAP_MONITOR_INTERRUPT_AFTER_TOOL = 'monitor.interrupt.after_tool';
17
17
  export const CAP_HTTP_COWORK = 'cowork.http-management-v1';
18
18
  export const CAP_WORKSPACE_PRESERVE_PROFILE = 'workspace.enroll.preserve-profile-v1';
19
+ /** `ours-fleet managed-cli setup|status` and the `managed_cli` Agent key (#233). */
20
+ export const CAP_MANAGED_CLI_SETUP = 'managed-cli.setup-v1';
19
21
  export const CAPABILITIES = [
22
+ CAP_MANAGED_CLI_SETUP,
20
23
  CAP_WORKSPACE_PRESERVE_PROFILE,
21
24
  CAP_HTTP_COWORK,
22
25
  CAP_MONITOR_INTERRUPT_AFTER_TOOL,
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { beginWorkspaceReplacement, abandonWorkspaceReplacement, discardRetiredWorkspaceReplacement, clearWorkspaceReplacement, confirmWorkspaceReplacement, finishWorkspaceReplacement, readWorkspaceBinding, readWorkspaceReplacement, requiresWorkspaceReplacement, refreshWorkspaceReplacement } from './workspace-replacement.js';
2
3
  import { completeTunnelSetup } from './tunnel-setup-completion.js';
3
- import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup } from './tunnel-setup-input.js';
4
+ import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup, inspectTunnelSetup, verifyTunnelSetupTarget } from './tunnel-setup-input.js';
4
5
  import { ensureMinimalSetup } from './minimal-setup.js';
5
- import { confirmWorkspaceTarget } from './workspace-enrollment.js';
6
+ import { confirmWorkspaceTarget, checkWorkspaceConnector } from './workspace-enrollment.js';
6
7
  import { clearPendingTunnelSetup, readPendingTunnelSetup, requiredPortAfterExpiry, savePendingTunnelSetup, setupTunnelPreflight } from './setup-tunnel-preflight.js';
7
8
  import { availableWebPort } from './web/available-port.js';
8
9
  import QRCode from 'qrcode';
@@ -56,6 +57,8 @@ import './harness/claude-code.js'; // registers the claude-code adapter
56
57
  import './harness/codex.js'; // registers the codex adapter
57
58
  import './harness/hermes.js';
58
59
  import { registerLayoutCommands } from './rooms-tasks/layout-cli.js';
60
+ import { MANAGED_CONFIGURATION_FLAG, assertPinnedConfiguration, enableManagedCli, managedCliReport, parseManagedEntry, } from './managed-cli.js';
61
+ import { MANAGED_CLI_WORKFLOW_IDS } from './config.js';
59
62
  import { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './rooms-tasks/cli.js';
60
63
  import { RoleCreationService } from './application/role-creation-service.js';
61
64
  import { RoleRemovalService } from './application/role-removal-service.js';
@@ -99,6 +102,22 @@ const program = new Command()
99
102
  .exitOverride()
100
103
  .version(VERSION);
101
104
  const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'manifest (default: ~/fleet.yaml; documents under ~/fleet/)');
105
+ /**
106
+ * Set only for the pinned managed entry form (see src/managed-cli.ts). Checked
107
+ * against the PARSED option, so every spelling of a later `-c` is covered.
108
+ */
109
+ let pinnedConfiguration;
110
+ program.hook('preAction', (_program, action) => {
111
+ if (pinnedConfiguration === undefined)
112
+ return;
113
+ try {
114
+ assertPinnedConfiguration(pinnedConfiguration, action.opts().configuration);
115
+ }
116
+ catch (error) {
117
+ console.error(`error: ${error.message}`);
118
+ throw new FleetCliExit(1, 'validation', 'not_started');
119
+ }
120
+ });
102
121
  const collect = (value, previous) => [...previous, value];
103
122
  program.command('docs')
104
123
  .alias('man')
@@ -1299,6 +1318,78 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
1299
1318
  die(e);
1300
1319
  }
1301
1320
  });
1321
+ function renderManagedCliReport(report) {
1322
+ const lines = [
1323
+ `Managed CLI setup for ${report.configuration}`,
1324
+ ` node ${report.node}`, ` cli ${report.cli}`,
1325
+ ` platform ${report.platform.os}${report.platform.declared ? '' : ' (not a declared platform)'}`,
1326
+ ];
1327
+ for (const role of report.roles) {
1328
+ if (role.setup.state === 'not-declared')
1329
+ continue;
1330
+ lines.push('', `${role.role} (${role.harness}/${role.session}) — ${role.workflows.join(', ')}`);
1331
+ lines.push(` setup (static): ${role.setup.state}${role.setup.action ? ` [${role.setup.action}]` : ''} — ${role.setup.detail}`);
1332
+ if (role.setup.artifact)
1333
+ lines.push(` ${role.setup.artifact}`);
1334
+ lines.push(` running session: ${role.session_policy}`);
1335
+ lines.push(` observed (audit ledger): ${role.observed.summary}`);
1336
+ if (role.commandPrefix)
1337
+ lines.push(` invoke as: ${role.commandPrefix} <command>`);
1338
+ for (const line of role.scope)
1339
+ lines.push(` scope: ${line}`);
1340
+ for (const line of role.warnings)
1341
+ lines.push(` note: ${line}`);
1342
+ }
1343
+ if (!report.roles.some(role => role.setup.state !== 'not-declared'))
1344
+ lines.push('', 'No agent declares managed_cli. Nothing was generated and no permission changed.', 'Enable it for one agent with: ours-fleet managed-cli setup --enable <Agent>');
1345
+ for (const item of report.removed)
1346
+ lines.push('', `${item.action}: ${item.path} — ${item.detail ?? ''}`);
1347
+ lines.push('', ...report.disclosure.map(line => `* ${line}`));
1348
+ return lines.join('\n');
1349
+ }
1350
+ const managedCliCommand = program.command('managed-cli')
1351
+ .description('prepare and inspect native harness policy for Fleet commands run from a command sandbox');
1352
+ for (const [name, write, description] of [
1353
+ ['setup', true, 'generate the policy for agents declaring managed_cli; starts no session and no model'],
1354
+ ['status', false, 'compare generated policy and observed supervisor access without changing anything'],
1355
+ ]) {
1356
+ const command = cOpt(managedCliCommand.command(name).description(description)).option('--json', 'JSON output');
1357
+ if (write)
1358
+ command.option('--enable <agent>', 'first add managed_cli: [task-workflow] to this Agent file (repeatable); its permissions are not changed', (value, all) => [...all, value], [])
1359
+ .option('--workflow <id>', `workflow --enable adds (${MANAGED_CLI_WORKFLOW_IDS.join(', ')})`, 'task-workflow');
1360
+ command.action((opts) => {
1361
+ try {
1362
+ const enabled = [];
1363
+ if (opts.enable?.length) {
1364
+ if (!MANAGED_CLI_WORKFLOW_IDS.includes(opts.workflow ?? ''))
1365
+ throw new Error(`unknown workflow '${opts.workflow}'; supported: ${MANAGED_CLI_WORKFLOW_IDS.join(', ')}`);
1366
+ // Validate every name before the first file is touched.
1367
+ const cfg = loadConfig(opts.configuration);
1368
+ for (const agent of opts.enable)
1369
+ findRole(cfg, agent);
1370
+ for (const agent of opts.enable)
1371
+ enabled.push({ agent, ...enableManagedCli(opts.configuration, agent, opts.workflow) });
1372
+ }
1373
+ const report = managedCliReport(loadConfig(opts.configuration), opts.configuration, { write });
1374
+ if (opts.json) {
1375
+ console.log(JSON.stringify({ ...report, enabled }, null, 2));
1376
+ }
1377
+ else {
1378
+ for (const item of enabled)
1379
+ console.log(`${item.changed ? 'Enabled' : 'Already enabled'} ${opts.workflow} for ${item.agent} in ${item.file}`);
1380
+ console.log(renderManagedCliReport(report));
1381
+ }
1382
+ const failing = report.roles.filter(role => ['unsupported', 'conflict', 'stale', 'missing'].includes(role.setup.state));
1383
+ if (failing.length)
1384
+ throw new FleetCliExit(1);
1385
+ }
1386
+ catch (error) {
1387
+ if (error instanceof FleetCliExit)
1388
+ throw error;
1389
+ die(error);
1390
+ }
1391
+ });
1392
+ }
1302
1393
  cOpt(program.command('doctor').description('prerequisite report'))
1303
1394
  .option('--harness <id>', 'check one harness explicitly')
1304
1395
  .option('--yaml-mode <mode>', 'non-plain YAML policy: compat|strict', 'compat')
@@ -1519,9 +1610,23 @@ webCommand.command('uninstall').description('stop and uninstall the owner web se
1519
1610
  });
1520
1611
  async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1521
1612
  try {
1613
+ if (opts.discardRetiredReplacement) {
1614
+ if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration || opts.abandonReplacement)
1615
+ throw Error('--discard-retired-replacement takes no payload or other setup options');
1616
+ discardRetiredWorkspaceReplacement();
1617
+ process.stdout.write('Retired replacement recovery discarded. This host has no working registration: the old service was stopped and linked devices revoked. After removing the failed successor in the App, use a fresh command to complete a new registration. Installation data and the old local binding are preserved.\n');
1618
+ return;
1619
+ }
1620
+ if (opts.abandonReplacement) {
1621
+ if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration)
1622
+ throw Error('--abandon-replacement takes no payload or other setup options');
1623
+ await abandonWorkspaceReplacement();
1624
+ process.stdout.write('Unsent or rejected replacement abandoned; existing local setup preserved. Obtain a fresh App command for another setup.\n');
1625
+ return;
1626
+ }
1522
1627
  if (setupTunnel && opts.check && opts.resume)
1523
1628
  throw Error('Use either --check or --resume');
1524
- if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin))
1629
+ if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin || opts.replaceRegistration))
1525
1630
  throw Error('--check and --resume take no payload or migration flag');
1526
1631
  if (setupTunnel && opts.check) {
1527
1632
  await setupTunnelPreflight();
@@ -1531,10 +1636,22 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1531
1636
  }
1532
1637
  let pending;
1533
1638
  if (setupTunnel && opts.resume) {
1534
- pending = readPendingTunnelSetup();
1535
- if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
1536
- throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
1537
- await setupTunnelPreflight();
1639
+ const replacement = readWorkspaceReplacement();
1640
+ if (replacement) {
1641
+ if (opts.configuration && resolvePath(opts.configuration) !== replacement.configuration)
1642
+ throw Error('Resume replacement with its original configuration');
1643
+ await setupTunnelPreflight();
1644
+ const result = await finishWorkspaceReplacement(replacement);
1645
+ pending = { appOrigin: replacement.payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: replacement.payload.challenge, configuration: replacement.configuration };
1646
+ savePendingTunnelSetup(pending);
1647
+ clearWorkspaceReplacement();
1648
+ }
1649
+ else {
1650
+ pending = readPendingTunnelSetup();
1651
+ if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
1652
+ throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
1653
+ await setupTunnelPreflight();
1654
+ }
1538
1655
  }
1539
1656
  else {
1540
1657
  if (argument === undefined && !opts.file && !opts.stdin)
@@ -1544,18 +1661,49 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1544
1661
  if (opts.stdin && process.stdin.isTTY)
1545
1662
  throw Error('--stdin requires a pipe; use an owned private --file instead');
1546
1663
  const input = setupTunnel ? (argument !== undefined ? readTunnelSetupArgument(argument) : opts.stdin ? await readTunnelSetupStdin() : readTunnelSetupFile(opts.file)) : undefined;
1664
+ const previous = setupTunnel ? readWorkspaceBinding() : undefined;
1665
+ const replacement = setupTunnel ? readWorkspaceReplacement() : undefined;
1666
+ if (replacement && !opts.replaceRegistration)
1667
+ throw Error('An unfinished replacement exists. Run setup-tunnel --resume, use a fresh command for the same target with --replace-registration, or --abandon-replacement when retirement was never sent or definitively rejected. If retirement and cleanup completed but the successor cannot be recovered, remove the successor in the App and use --discard-retired-replacement');
1668
+ if (replacement && resolvePath(opts.configuration ?? defaultConfigPath()) !== replacement.configuration)
1669
+ throw Error('Replacement requires its original configuration');
1670
+ const target = previous && !replacement ? await inspectTunnelSetup(input) : undefined;
1671
+ const different = previous && !replacement && (!target || requiresWorkspaceReplacement(previous, target, opts.migrateAppOrigin));
1672
+ if (different && !(await confirmWorkspaceReplacement(previous, opts.replaceRegistration))) {
1673
+ if (!process.stdin.isTTY)
1674
+ throw Error('Replacement confirmation required; existing setup and grant preserved');
1675
+ return;
1676
+ }
1547
1677
  if (setupTunnel) {
1548
1678
  await setupTunnelPreflight();
1549
1679
  await checkWorkspaceConfiguration(opts.configuration ?? defaultConfigPath());
1550
1680
  }
1551
- const payload = setupTunnel ? await redeemTunnelSetup(input) : readWorkspacePayload(opts.file);
1681
+ let payload = setupTunnel ? await redeemTunnelSetup(input) : readWorkspacePayload(opts.file);
1682
+ verifyTunnelSetupTarget(target, payload);
1683
+ const replace = previous && !replacement && requiresWorkspaceReplacement(previous, { workspaceId: payload.challenge.workspaceId, serverCid: payload.serverCid, appOrigin: payload.appOrigin }, opts.migrateAppOrigin);
1684
+ if (replacement) {
1685
+ refreshWorkspaceReplacement(replacement, payload, opts.configuration ?? defaultConfigPath());
1686
+ payload = replacement.payload;
1687
+ }
1688
+ checkWorkspaceConnector(payload);
1552
1689
  if (setupTunnel)
1553
1690
  await ensureMinimalSetup(opts.configuration ?? defaultConfigPath());
1554
- const result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
1691
+ let result;
1692
+ if (replacement) {
1693
+ result = await finishWorkspaceReplacement(replacement);
1694
+ }
1695
+ else if (replace) {
1696
+ const record = await beginWorkspaceReplacement(previous, payload, opts.configuration ?? defaultConfigPath());
1697
+ result = await finishWorkspaceReplacement(record);
1698
+ }
1699
+ else
1700
+ result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
1555
1701
  pending = { appOrigin: payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: payload.challenge, configuration: resolvePath(opts.configuration ?? defaultConfigPath()) };
1556
- // The proof is one-time: keep what the remaining steps need so they can be finished without a new command.
1557
- if (setupTunnel)
1702
+ // Keep both the replacement capability and the proof until the next recovery record is durable.
1703
+ if (setupTunnel) {
1558
1704
  savePendingTunnelSetup(pending);
1705
+ clearWorkspaceReplacement();
1706
+ }
1559
1707
  }
1560
1708
  try {
1561
1709
  await finishWorkspaceService(pending, opts.resume ? pending.configuration : opts.configuration, setupTunnel);
@@ -1649,7 +1797,10 @@ cOpt(program.command('setup-tunnel').argument('[payload]', 'minimal one-use v2 A
1649
1797
  .option('--file <path>', 'owned private App grant or legacy payload file (chmod 600)')
1650
1798
  .option('--stdin', 'read the private App grant or legacy payload from a pipe')
1651
1799
  .option('--check', 'only verify host prerequisites; changes nothing and needs no payload')
1652
- .option('--resume', 'finish a setup that stopped after this host was bound; needs no new payload')
1800
+ .option('--resume', 'finish an interrupted replacement or setup; needs no new payload')
1801
+ .option('--discard-retired-replacement', 'forget a failed successor after retirement and local cleanup; remove it in the App first')
1802
+ .option('--abandon-replacement', 'discard an unsent or definitively rejected replacement after checking its private receipt')
1803
+ .option('--replace-registration', 'confirm retirement of the existing account registration when the new one differs')
1653
1804
  .option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
1654
1805
  .action(async (payload, opts) => workspaceEnrollmentCommand(opts, true, payload));
1655
1806
  program.command('link-device').description('create a single-use workspace device connection code (expires in five minutes)')
@@ -1797,7 +1948,15 @@ async function parseFleetCli() {
1797
1948
  async function runFleetCli() {
1798
1949
  const stateDir = process.env[FLEET_PROXY_STATE_DIR_ENV];
1799
1950
  const caller = process.env[FLEET_PROXY_CALLER_ENV];
1951
+ const entry = parseManagedEntry(process.argv.slice(2));
1800
1952
  if (!stateDir || !caller) {
1953
+ // The pinned form exists for managed sessions; outside one there is no
1954
+ // supervisor to audit the call, so it is refused rather than run unaudited.
1955
+ if (entry.kind !== 'none') {
1956
+ console.error(`error: ${entry.kind === 'error' ? entry.message
1957
+ : `${MANAGED_CONFIGURATION_FLAG} is only valid inside a Fleet-managed agent session, where the supervisor audits the call`}`);
1958
+ throw new FleetCliExit(1, 'validation', 'not_started');
1959
+ }
1801
1960
  await parseFleetCli();
1802
1961
  return;
1803
1962
  }
@@ -1832,6 +1991,15 @@ async function runFleetCli() {
1832
1991
  const originalExit = process.exit;
1833
1992
  process.exit = ((code) => { throw new FleetCliExit(code ?? 0); });
1834
1993
  try {
1994
+ if (entry.kind === 'error') {
1995
+ console.error(`error: ${entry.message}`);
1996
+ throw new FleetCliExit(1, 'validation', 'not_started');
1997
+ }
1998
+ if (entry.kind === 'pinned') {
1999
+ // The supervisor audited the argv as typed; Commander parses the ordinary form.
2000
+ pinnedConfiguration = entry.configuration;
2001
+ process.argv = [process.argv[0], process.argv[1], ...entry.argv];
2002
+ }
1835
2003
  await parseFleetCli();
1836
2004
  }
1837
2005
  catch (error) {
package/dist/config.d.ts CHANGED
@@ -36,6 +36,12 @@ export type FilesystemMode = 'read-only' | 'workspace' | 'unrestricted';
36
36
  export type UnattendedMode = 'deny' | 'wait';
37
37
  /** Monitor wake policy: preserve legacy booleans and add one explicit safe boundary. */
38
38
  export type MonitorInterrupt = boolean | 'after_tool';
39
+ /**
40
+ * Workflows Fleet prepares native harness execution policy for. Declared per
41
+ * Agent; never inferred from a role's name or persona, and never inherited.
42
+ */
43
+ export declare const MANAGED_CLI_WORKFLOW_IDS: readonly ["task-workflow"];
44
+ export type ManagedCliWorkflowId = (typeof MANAGED_CLI_WORKFLOW_IDS)[number];
39
45
  export interface CommonPermissions {
40
46
  approval: ApprovalMode;
41
47
  filesystem: FilesystemMode;
@@ -146,6 +152,8 @@ export interface RoleConfig {
146
152
  owner_channel?: OwnerChannelConfigInput;
147
153
  worklog?: WorklogPolicyInput;
148
154
  auth_proxy?: Partial<AuthProxyConfig>;
155
+ /** Fleet-prepared CLI workflows this role may run outside its command sandbox. */
156
+ managed_cli?: ManagedCliWorkflowId[];
149
157
  }
150
158
  export type AgentSelection<T extends Record<string, unknown> = Record<string, unknown>> = {
151
159
  ref: string;
@@ -167,6 +175,8 @@ export interface AgentDefinition {
167
175
  owner_channel?: OwnerChannelConfigInput;
168
176
  worklog?: WorklogPolicyInput;
169
177
  auth_proxy?: Partial<AuthProxyConfig>;
178
+ /** Fleet-prepared CLI workflows this Agent may run outside its command sandbox. */
179
+ managed_cli?: ManagedCliWorkflowId[];
170
180
  /** Scheduled turns scoped only to temporary launches; persistent Agents reject this field. */
171
181
  loops?: AgentLoopsConfig;
172
182
  }
package/dist/config.js CHANGED
@@ -24,6 +24,11 @@ export const NOTIFY_EVENT_TYPES = [
24
24
  'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
25
25
  'pending_message', 'contact_restored', 'inbound_error', 'state_import_failed',
26
26
  ];
27
+ /**
28
+ * Workflows Fleet prepares native harness execution policy for. Declared per
29
+ * Agent; never inferred from a role's name or persona, and never inherited.
30
+ */
31
+ export const MANAGED_CLI_WORKFLOW_IDS = ['task-workflow'];
27
32
  /** Default wake sources when a role does not list its own. */
28
33
  export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
29
34
  const MONITOR_KEYS = [
@@ -139,7 +144,7 @@ export const ROLE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
139
144
  const ROLE_KEYS = [
140
145
  'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
141
146
  'briefing_file', 'model', 'effort', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
142
- 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
147
+ 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'managed_cli',
143
148
  ];
144
149
  export const ROLE_PRESET_KEYS = ['mission', 'persona', 'bio', 'briefing_file'];
145
150
  export const BRAIN_PRESET_KEYS = [
@@ -148,7 +153,7 @@ export const BRAIN_PRESET_KEYS = [
148
153
  ];
149
154
  export const AGENT_KEYS = [
150
155
  'role', 'brain', 'permissions', 'identity', 'cwd', 'coordinator', 'env', 'oversee',
151
- 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'loops',
156
+ 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'loops', 'managed_cli',
152
157
  ];
153
158
  const AGENT_INSTANCE_KEYS = ['template', 'overrides'];
154
159
  const TEMPLATE_FORBIDDEN_KEYS = ['identity'];
@@ -236,6 +241,12 @@ function validateAgentScalars(value, file, id) {
236
241
  if (current !== undefined && (typeof current !== 'string' || !current.trim()))
237
242
  schemaError(file, `/agents/${id}/${key}`, 'must be a non-blank string');
238
243
  }
244
+ if (value.managed_cli !== undefined) {
245
+ const ids = value.managed_cli;
246
+ if (!Array.isArray(ids) || ids.some(entry => !MANAGED_CLI_WORKFLOW_IDS.includes(entry))
247
+ || new Set(ids).size !== ids.length)
248
+ schemaError(file, `/agents/${id}/managed_cli`, `must be a list of distinct workflow IDs; supported: ${MANAGED_CLI_WORKFLOW_IDS.join(', ')}`);
249
+ }
239
250
  if (value.coordinator !== undefined
240
251
  && (typeof value.coordinator !== 'string' || !ROLE_NAME_RE.test(value.coordinator)))
241
252
  schemaError(file, `/agents/${id}/coordinator`, 'must be a valid Agent ID');