@ours.network/fleet 1.2.0-nightly.33 → 1.2.0-nightly.35

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,15 @@
1
+ import { type OfferedModels } from '../subscriptions/cli.js';
2
+ export interface CodexModelDiscoveryDeps {
3
+ /** The Codex executable ACP sessions will run, and its version. */
4
+ runtime(): Promise<{
5
+ executable: string;
6
+ version: string;
7
+ }>;
8
+ ask(executable: string, env: NodeJS.ProcessEnv): Promise<OfferedModels>;
9
+ now(): number;
10
+ }
11
+ /**
12
+ * The models offered to the active Codex account by the runtime that sessions will actually use.
13
+ * An answer is reused only for the same executable, version, profile and sign-in.
14
+ */
15
+ export declare function createCodexModelDiscovery(overrides?: Partial<CodexModelDiscoveryDeps>): () => Promise<OfferedModels>;
@@ -0,0 +1,56 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { resolveBundledAcpAgent } from '../harness/acp-agent.js';
4
+ import { probeCodexRuntime } from '../harness/codex-runtime.js';
5
+ import { codexOfferedModels, profileEnv } from '../subscriptions/cli.js';
6
+ import { profileHome, readSubscriptionState } from '../subscriptions/store.js';
7
+ const KNOWN_FOR_MS = 5 * 60_000;
8
+ const UNKNOWN_FOR_MS = 10_000;
9
+ /** When the profile's sign-in last changed; a new sign-in must not reuse another account's answer. */
10
+ function signInStamp(home) {
11
+ try {
12
+ return statSync(join(home, 'auth.json')).mtimeMs;
13
+ }
14
+ catch {
15
+ return 0;
16
+ }
17
+ }
18
+ /**
19
+ * The models offered to the active Codex account by the runtime that sessions will actually use.
20
+ * An answer is reused only for the same executable, version, profile and sign-in.
21
+ */
22
+ export function createCodexModelDiscovery(overrides = {}) {
23
+ const deps = {
24
+ runtime: () => probeCodexRuntime(resolveBundledAcpAgent('@agentclientprotocol/codex-acp', 'codex-acp', 'codex-acp'), process.env),
25
+ ask: codexOfferedModels,
26
+ now: Date.now,
27
+ ...overrides,
28
+ };
29
+ let cached;
30
+ let pending;
31
+ return async () => {
32
+ let key, executable, env;
33
+ try {
34
+ const profileId = readSubscriptionState().providers.codex.activeProfileId;
35
+ const profile = profileHome('codex', profileId);
36
+ const runtime = await deps.runtime();
37
+ executable = runtime.executable;
38
+ env = profileEnv('CODEX_HOME', profileId, profile);
39
+ key = [runtime.executable, runtime.version, profileId, signInStamp(profile)].join('\n');
40
+ }
41
+ catch (error) {
42
+ return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) };
43
+ }
44
+ if (cached && cached.key === key && cached.until > deps.now())
45
+ return cached.value;
46
+ if (pending?.key === key)
47
+ return pending.answer;
48
+ const answer = deps.ask(executable, env).then(value => {
49
+ cached = { key, until: deps.now() + (value.state === 'known' ? KNOWN_FOR_MS : UNKNOWN_FOR_MS), value };
50
+ return value;
51
+ }).finally(() => { if (pending?.key === key)
52
+ pending = undefined; });
53
+ pending = { key, answer };
54
+ return answer;
55
+ };
56
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.33",
3
- "buildId": "c27262dcfdeb",
4
- "commit": "57b2fa27b72ae4f53fbad81e966139474b7a6e03",
2
+ "version": "1.2.0-nightly.35",
3
+ "buildId": "8e9a6c78b17e",
4
+ "commit": "88af91aca3e52e9a82830df15d9ff63618393d02",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-02T10:40:46.562Z",
6
+ "builtAt": "2026-10-02T15:09:21.240Z",
7
7
  "capabilities": [
8
8
  "workspace.enroll.preserve-profile-v1",
9
9
  "cowork.http-management-v1",
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureMinimalSetup } from './minimal-setup.js';
3
- import { configureWorkspacePort } from './workspace-enrollment.js';
3
+ import { confirmWorkspaceTarget } from './workspace-enrollment.js';
4
4
  import { clearPendingTunnelSetup, readPendingTunnelSetup, requiredPortAfterExpiry, savePendingTunnelSetup, setupTunnelPreflight } from './setup-tunnel-preflight.js';
5
5
  import { availableWebPort } from './web/available-port.js';
6
6
  import QRCode from 'qrcode';
7
- import { readWorkspacePayload, enrollWorkspace } from './workspace-enrollment.js';
7
+ import { readWorkspacePayload, enrollWorkspace, checkWorkspaceConfiguration } from './workspace-enrollment.js';
8
8
  import { waitForRoleDaemon } from './startup-readiness.js';
9
9
  import { SupervisorOursTools } from './application/supervisor-ours-tools.js';
10
10
  import { runTempSupervisor, TEMP_RECYCLE_EXIT } from './temp-supervisor-recovery.js';
@@ -1541,6 +1541,7 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false) {
1541
1541
  throw Error('--check and --resume take no payload file or migration flag');
1542
1542
  if (setupTunnel && opts.check) {
1543
1543
  await setupTunnelPreflight();
1544
+ await checkWorkspaceConfiguration(opts.configuration ?? defaultConfigPath());
1544
1545
  process.stdout.write('Tunnel setup prerequisites are available.\n');
1545
1546
  return;
1546
1547
  }
@@ -1557,6 +1558,7 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false) {
1557
1558
  const payload = readWorkspacePayload(opts.file);
1558
1559
  if (setupTunnel) {
1559
1560
  await setupTunnelPreflight();
1561
+ await checkWorkspaceConfiguration(opts.configuration ?? defaultConfigPath());
1560
1562
  await ensureMinimalSetup(opts.configuration ?? defaultConfigPath());
1561
1563
  }
1562
1564
  const result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
@@ -1629,7 +1631,7 @@ async function finishWorkspaceService(pending, configuration, record) {
1629
1631
  pending = { ...pending, requestedPorts: [...(pending.requestedPorts ?? []), bound.port] };
1630
1632
  savePendingTunnelSetup(pending);
1631
1633
  }
1632
- await configureWorkspacePort(pending, pending.hostWorkspaceId, pending.rootCid, bound.port);
1634
+ await confirmWorkspaceTarget(pending, pending.hostWorkspaceId, pending.rootCid, bound.port, error => { process.stderr.write(`The enrollment contact could not be removed from Messenger (${error instanceof Error ? error.message : String(error)}); it can be removed there by hand.\n`); });
1633
1635
  }
1634
1636
  else if (bound.port !== requiredPort)
1635
1637
  throw Error(`The setup window has expired and the tunnel points at port ${requiredPort}, but this host could only bind port ${bound.port}. Free port ${requiredPort} on this host`);
package/dist/config.d.ts CHANGED
@@ -311,6 +311,8 @@ export declare function loadConfig(configPath?: string, options?: {
311
311
  temporary?: boolean;
312
312
  };
313
313
  skipWatchdogs?: boolean;
314
+ /** The one Owner invitation file that tunnel setup writes itself and that may therefore still be absent. */
315
+ deferredOwnerInviteFile?: string;
314
316
  }): FleetConfig;
315
317
  /** Full read-only Agent resolution used by launch dry-runs and room plan validation. */
316
318
  export declare function resolveAgentDefinitionDryRun(configPath: string, id: string, definition: AgentDefinition): ResolvedRole;
package/dist/config.js CHANGED
@@ -764,7 +764,7 @@ export function loadConfig(configPath, options = {}) {
764
764
  if (doc.rooms !== undefined) {
765
765
  if (rooms)
766
766
  throw new ConfigError(`rooms: defined in multiple files; last: ${file}`);
767
- const validated = validateRoomsConfig(deepSub(doc.rooms, vars), vars, file);
767
+ const validated = validateRoomsConfig(deepSub(doc.rooms, vars), vars, file, options.deferredOwnerInviteFile);
768
768
  ownerInviteFingerprint = validated._invite?.fingerprint;
769
769
  ownerInvite = validated._invite?.value;
770
770
  const { _invite: _, ...clean } = validated;
@@ -4,8 +4,18 @@ export declare const VERIFIED_CODEX_ACP_VERSIONS: Set<string>;
4
4
  export declare function executableOnPath(command: string, env: NodeJS.ProcessEnv, cwd?: string): string;
5
5
  /** Match the npm Codex wrapper's platform-package/vendor selection. */
6
6
  export declare function codexExecutable(entry: string): string;
7
+ /**
8
+ * The host's own Codex, when ACP sessions should run it instead of the copy bundled with the adapter.
9
+ * The person signs in to, updates and checks the host Codex, and a newer Codex knows models the bundled
10
+ * one does not. It is used only when it is at least as new as the bundled copy; an explicit CODEX_PATH
11
+ * (including an empty one, which selects the bundled copy) always decides instead.
12
+ */
13
+ export declare function hostCodex(adapterManifest: string | undefined, env: NodeJS.ProcessEnv, cwd?: string, version?: (path: string, env: NodeJS.ProcessEnv) => string | undefined): {
14
+ path: string;
15
+ version: string;
16
+ } | undefined;
7
17
  export interface CodexRuntime {
8
- source: 'CODEX_PATH' | 'bundled';
18
+ source: 'CODEX_PATH' | 'host' | 'bundled';
9
19
  entry: string;
10
20
  executable: string;
11
21
  version: string;
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
- import { accessSync, constants, existsSync, realpathSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from 'node:fs';
3
4
  import { dirname, join, isAbsolute, resolve, delimiter } from 'node:path';
4
5
  import { realExec } from '../exec.js';
5
6
  export const VERIFIED_CODEX_ACP_VERSIONS = new Set(['1.1.7', '1.10.0']);
@@ -46,18 +47,44 @@ export function codexExecutable(entry) {
46
47
  }
47
48
  throw new Error(`Codex platform binary missing under ${vendor}; reinstall Fleet with optional dependencies`);
48
49
  }
50
+ const versionOf = (text) => text.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
51
+ function installedCodexVersion(path, env) {
52
+ const result = spawnSync(path, ['--version'], { env, encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] });
53
+ return result.status === 0 ? versionOf(result.stdout ?? '') : undefined;
54
+ }
55
+ /**
56
+ * The host's own Codex, when ACP sessions should run it instead of the copy bundled with the adapter.
57
+ * The person signs in to, updates and checks the host Codex, and a newer Codex knows models the bundled
58
+ * one does not. It is used only when it is at least as new as the bundled copy; an explicit CODEX_PATH
59
+ * (including an empty one, which selects the bundled copy) always decides instead.
60
+ */
61
+ export function hostCodex(adapterManifest, env, cwd = process.cwd(), version = installedCodexVersion) {
62
+ if (env.CODEX_PATH !== undefined || !adapterManifest)
63
+ return undefined;
64
+ let path, bundled;
65
+ try {
66
+ path = executableOnPath('codex', env, cwd);
67
+ bundled = JSON.parse(readFileSync(createRequire(adapterManifest).resolve('@openai/codex/package.json'), 'utf8')).version;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ const found = version(path, env);
73
+ return found && typeof bundled === 'string' && codexVersionAtLeast(found, bundled) ? { path, version: found } : undefined;
74
+ }
49
75
  export async function probeCodexRuntime(adapter, env, exec = realExec, cwd = process.cwd()) {
50
- const configured = env.CODEX_PATH;
76
+ const host = hostCodex(adapter.manifestPath, env, cwd);
77
+ const configured = env.CODEX_PATH || host?.path;
51
78
  if (!configured && !adapter.manifestPath)
52
79
  throw new Error('ACP runtime is unknown for a PATH/custom adapter; set CODEX_PATH to an absolute executable or use the bundled adapter');
53
80
  const entry = configured ? executableOnPath(configured, env, cwd)
54
81
  : createRequire(adapter.manifestPath).resolve('@openai/codex/bin/codex.js');
55
82
  const executable = codexExecutable(entry);
56
83
  const result = await exec(!configured ? process.execPath : entry, !configured ? [entry, '--version'] : ['--version'], { env, timeout: 5_000 });
57
- const version = result.stdout.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
84
+ const version = versionOf(result.stdout);
58
85
  if (result.code !== 0 || !version)
59
86
  throw new Error(`Cannot read Codex version from ${entry}; check CODEX_PATH and executable permissions`);
60
- return { source: configured ? 'CODEX_PATH' : 'bundled', entry, executable, version };
87
+ return { source: host ? 'host' : configured ? 'CODEX_PATH' : 'bundled', entry, executable, version };
61
88
  }
62
89
  export function codexVersionAtLeast(version, minimum) {
63
90
  const actual = version.split('.').map(Number);
@@ -6,7 +6,7 @@ import { realExec } from '../exec.js';
6
6
  import { registerAdapter } from './registry.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
8
  import { resolveBundledAcpAgent, } from './acp-agent.js';
9
- import { VERIFIED_CODEX_ACP_VERSIONS, probeCodexRuntime, codexVersionAtLeast } from './codex-runtime.js';
9
+ import { VERIFIED_CODEX_ACP_VERSIONS, probeCodexRuntime, codexVersionAtLeast, hostCodex } from './codex-runtime.js';
10
10
  import { CodexAgentSessionAdapter } from './codex-session.js';
11
11
  const OPTION_KEYS = [
12
12
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
@@ -266,7 +266,9 @@ function codexAcpEnvironment(role, dirs) {
266
266
  [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
267
267
  [CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
268
268
  [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
269
- [CODEX_PROXY_REAL_PATH_ENV]: role.env?.CODEX_PATH ?? process.env.CODEX_PATH ?? '',
269
+ // Same choice as probeCodexRuntime: an explicit CODEX_PATH, else a host Codex at least as new as the bundled one.
270
+ [CODEX_PROXY_REAL_PATH_ENV]: role.env?.CODEX_PATH ?? process.env.CODEX_PATH
271
+ ?? hostCodex(resolution.manifestPath, { ...process.env, ...role.env }, dirs.runCwd)?.path ?? '',
270
272
  };
271
273
  }
272
274
  function encodeTomlValue(value) {
@@ -238,7 +238,8 @@ export function generateSetup(answers) {
238
238
  `role: { ref: ${role} }`,
239
239
  `brain: { ref: ${ROLE_WORK[role]} }`,
240
240
  'coordinator: FleetCoordinator',
241
- 'permissions: { approval: ask, filesystem: workspace, unattended: deny }',
241
+ // Temporary default while the product is being tested: prepared agents work without asking.
242
+ 'permissions: { approval: allow, filesystem: unrestricted, unattended: deny }',
242
243
  'monitor: { mode: fleet, interrupt: after_tool }',
243
244
  '',
244
245
  ].join('\n'));
@@ -3,13 +3,14 @@ import { basename, join } from 'node:path';
3
3
  import { withFileLock } from './atomic-file.js';
4
4
  import { loadConfig, splitRootFor } from './config.js';
5
5
  import { preflightInitPaths } from './init-wizard.js';
6
+ import { workspaceOwnerInvite } from './paths.js';
6
7
  /** Prepare only the web configuration; App onboarding chooses agents and brains later. */
7
8
  export async function ensureMinimalSetup(configuration) {
8
9
  const initial = preflightInitPaths(configuration);
9
10
  await withFileLock(join(initial.parent, `.${basename(initial.splitRoot)}.init.lock`), async () => {
10
11
  const selected = preflightInitPaths(configuration);
11
12
  if (selected.manifestExisted) {
12
- loadConfig(selected.configPath, { yamlMode: 'strict' });
13
+ loadConfig(selected.configPath, { yamlMode: 'strict', deferredOwnerInviteFile: workspaceOwnerInvite() });
13
14
  return;
14
15
  }
15
16
  if (selected.rootExisted)
package/dist/paths.d.ts CHANGED
@@ -11,6 +11,8 @@ export declare const home: () => string;
11
11
  */
12
12
  export declare function deriveXdgRuntimeDir(env?: NodeJS.ProcessEnv, uid?: number | undefined, exists?: (p: string) => boolean): string | undefined;
13
13
  export declare const stateRoot: () => string;
14
+ /** The Owner invitation file that tunnel setup writes itself after enrollment. */
15
+ export declare const workspaceOwnerInvite: () => string;
14
16
  export declare const agentsRoot: () => string;
15
17
  export declare const tmpRoot: () => string;
16
18
  export declare const logsRoot: () => string;
package/dist/paths.js CHANGED
@@ -21,6 +21,8 @@ export function deriveXdgRuntimeDir(env = process.env, uid = process.getuid?.(),
21
21
  return env.XDG_RUNTIME_DIR;
22
22
  }
23
23
  export const stateRoot = () => join(home(), '.ours-fleet');
24
+ /** The Owner invitation file that tunnel setup writes itself after enrollment. */
25
+ export const workspaceOwnerInvite = () => join(stateRoot(), 'workspace', 'owner.invite');
24
26
  export const agentsRoot = () => join(stateRoot(), 'agents');
25
27
  export const tmpRoot = () => join(stateRoot(), 'tmp');
26
28
  export const logsRoot = () => join(stateRoot(), 'logs');
@@ -4,7 +4,7 @@ export declare class RoomsTasksConfigError extends Error {
4
4
  constructor(path: string, message: string);
5
5
  }
6
6
  export declare function fingerprint(invite: string): string;
7
- export declare function validateRoomsConfig(raw: unknown, vars: Record<string, string>, path: string): RoomsConfig & {
7
+ export declare function validateRoomsConfig(raw: unknown, vars: Record<string, string>, path: string, deferredInviteFile?: string): RoomsConfig & {
8
8
  _invite?: {
9
9
  value: string;
10
10
  fingerprint: string;
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, TEMPLATE_ROOM_KEYS as TRK, } from './types.js';
4
5
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -18,7 +19,7 @@ function rejectUnknown(obj, allowed, path, section) {
18
19
  export function fingerprint(invite) {
19
20
  return createHash('sha256').update(invite.trim()).digest('hex');
20
21
  }
21
- function resolveInvite(owner, vars, path) {
22
+ function resolveInvite(owner, vars, path, deferredInviteFile) {
22
23
  const inline = owner.public_invite;
23
24
  const file = owner.public_invite_file;
24
25
  if (inline && file)
@@ -28,6 +29,10 @@ function resolveInvite(owner, vars, path) {
28
29
  let raw;
29
30
  if (file) {
30
31
  const resolved = file.replace(/\$\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
32
+ // Tunnel setup writes this one file itself after enrollment; until then it may be absent.
33
+ // Only the literal absolute path counts: a relative spelling that happens to reach it does not.
34
+ if (!existsSync(resolved) && deferredInviteFile !== undefined && isAbsolute(deferredInviteFile) && resolved === deferredInviteFile)
35
+ return undefined;
31
36
  if (!existsSync(resolved))
32
37
  throw new RoomsTasksConfigError(path, `rooms.owner.public_invite_file: not found: ${resolved}`);
33
38
  raw = readFileSync(resolved, 'utf8').trim();
@@ -39,7 +44,7 @@ function resolveInvite(owner, vars, path) {
39
44
  throw new RoomsTasksConfigError(path, 'rooms.owner: invite is empty');
40
45
  return { value: raw, fingerprint: fingerprint(raw) };
41
46
  }
42
- export function validateRoomsConfig(raw, vars, path) {
47
+ export function validateRoomsConfig(raw, vars, path, deferredInviteFile) {
43
48
  if (!isPlainObject(raw))
44
49
  throw new RoomsTasksConfigError(path, 'rooms: must be a mapping');
45
50
  // Migration-only input: provider selection was exposed before rooms shipped,
@@ -68,7 +73,7 @@ export function validateRoomsConfig(raw, vars, path) {
68
73
  if (!CID_RE.test(expectedCid))
69
74
  throw new RoomsTasksConfigError(path, 'rooms.owner.expected_cid: must be exactly 64 hexadecimal characters');
70
75
  const ownerRole = raw.owner.role ?? 'Owner';
71
- const invite = resolveInvite(raw.owner, vars, path);
76
+ const invite = resolveInvite(raw.owner, vars, path, deferredInviteFile);
72
77
  const owner = {
73
78
  provider: ownerProvider,
74
79
  expected_cid: expectedCid.toLowerCase(),
@@ -43,14 +43,27 @@ export declare class CodexAppServer {
43
43
  private readonly listeners;
44
44
  private closed;
45
45
  private constructor();
46
- static start(bin: string, env: NodeJS.ProcessEnv): Promise<CodexAppServer>;
46
+ static start(bin: string, env: NodeJS.ProcessEnv, initializeTimeoutMs?: number): Promise<CodexAppServer>;
47
47
  call<T = unknown>(method: string, params: unknown, timeoutMs?: number): Promise<T>;
48
48
  onNotification(fn: (method: string, params: unknown) => void): () => void;
49
49
  close(): void;
50
50
  private onData;
51
51
  }
52
52
  /** One-shot use of an app-server for a profile. */
53
- export declare function withCodex<T>(bin: string, env: NodeJS.ProcessEnv, fn: (s: CodexAppServer) => Promise<T>): Promise<T>;
53
+ export declare function withCodex<T>(bin: string, env: NodeJS.ProcessEnv, fn: (s: CodexAppServer) => Promise<T>, initializeTimeoutMs?: number): Promise<T>;
54
+ /** What a Codex runtime offers the account it is signed in to. An empty list is an answer; unknown is not. */
55
+ export type OfferedModels = {
56
+ state: 'known';
57
+ models: string[];
58
+ } | {
59
+ state: 'unknown';
60
+ reason: string;
61
+ };
62
+ /**
63
+ * Ask this Codex executable, with this profile's environment, which models it offers (`model/list`,
64
+ * the call the ACP adapter itself uses). Nothing is inferred when it does not answer.
65
+ */
66
+ export declare function codexOfferedModels(bin: string, env: NodeJS.ProcessEnv, budgetMs?: number): Promise<OfferedModels>;
54
67
  export interface UsageWindow {
55
68
  id: string;
56
69
  label: string;
@@ -81,11 +81,18 @@ export class CodexAppServer {
81
81
  this.pending.clear();
82
82
  });
83
83
  }
84
- static async start(bin, env) {
84
+ static async start(bin, env, initializeTimeoutMs = 20_000) {
85
85
  const child = spawn(bin, ['app-server'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
86
86
  await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
87
87
  const server = new CodexAppServer(child);
88
- await server.call('initialize', { clientInfo: { name: 'ours-fleet', title: 'Ours Fleet', version: '1' } });
88
+ // A server that does not answer is not handed out, so nobody else can close it: end it here.
89
+ try {
90
+ await server.call('initialize', { clientInfo: { name: 'ours-fleet', title: 'Ours Fleet', version: '1' } }, initializeTimeoutMs);
91
+ }
92
+ catch (error) {
93
+ server.close();
94
+ throw error;
95
+ }
89
96
  child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
90
97
  return server;
91
98
  }
@@ -140,8 +147,8 @@ export class CodexAppServer {
140
147
  }
141
148
  }
142
149
  /** One-shot use of an app-server for a profile. */
143
- export async function withCodex(bin, env, fn) {
144
- const server = await CodexAppServer.start(bin, env);
150
+ export async function withCodex(bin, env, fn, initializeTimeoutMs) {
151
+ const server = await CodexAppServer.start(bin, env, initializeTimeoutMs);
145
152
  try {
146
153
  return await fn(server);
147
154
  }
@@ -149,6 +156,36 @@ export async function withCodex(bin, env, fn) {
149
156
  server.close();
150
157
  }
151
158
  }
159
+ /**
160
+ * Ask this Codex executable, with this profile's environment, which models it offers (`model/list`,
161
+ * the call the ACP adapter itself uses). Nothing is inferred when it does not answer.
162
+ */
163
+ export async function codexOfferedModels(bin, env, budgetMs = 30_000) {
164
+ // One budget for the whole question: onboarding waits for this answer.
165
+ const deadline = Date.now() + budgetMs;
166
+ const left = () => Math.max(1, deadline - Date.now());
167
+ try {
168
+ return await withCodex(bin, env, async (server) => {
169
+ const models = [];
170
+ let cursor = null;
171
+ while (Date.now() < deadline) {
172
+ const answer = await server.call('model/list', { cursor, limit: null }, left());
173
+ if (!Array.isArray(answer?.data))
174
+ return { state: 'unknown', reason: 'Codex did not return a model list' };
175
+ for (const model of answer.data)
176
+ if (model && typeof model.id === 'string' && model.id && model.hidden !== true)
177
+ models.push(model.id);
178
+ cursor = typeof answer.nextCursor === 'string' && answer.nextCursor ? answer.nextCursor : null;
179
+ if (!cursor)
180
+ return { state: 'known', models: [...new Set(models)] };
181
+ }
182
+ return { state: 'unknown', reason: 'Codex did not finish listing its models in time' };
183
+ }, left());
184
+ }
185
+ catch (error) {
186
+ return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) };
187
+ }
188
+ }
152
189
  /** Flatten `account/rateLimits/read` into display windows. */
153
190
  export function codexUsageWindows(result) {
154
191
  const snapshots = result.rateLimitsByLimitId && Object.keys(result.rateLimitsByLimitId).length
@@ -52,6 +52,8 @@ export declare class WebAuth {
52
52
  }): void;
53
53
  /** Mint a replacement for an operator-triggered reauthentication ceremony. */
54
54
  mintBootstrap(): string;
55
+ /** The configured account origin, when this request comes from it and workspace devices are enabled. */
56
+ accountOrigin(request: Pick<FastifyRequest, 'headers'>): string | undefined;
55
57
  validateBoundary(request: FastifyRequest, requireOrigin: boolean, allowPageNavigation?: boolean): void;
56
58
  exchange(request: FastifyRequest): AuthResult;
57
59
  login(request: FastifyRequest, password: string): AuthResult;
package/dist/web/auth.js CHANGED
@@ -54,6 +54,10 @@ export class WebAuth {
54
54
  this.bootstrapUsed = false;
55
55
  return this._bootstrapSecret;
56
56
  }
57
+ /** The configured account origin, when this request comes from it and workspace devices are enabled. */
58
+ accountOrigin(request) {
59
+ return this.workspaceDevices && request.headers.origin === this.appOrigin ? this.appOrigin : undefined;
60
+ }
57
61
  validateBoundary(request, requireOrigin, allowPageNavigation = false) {
58
62
  const host = request.headers.host;
59
63
  const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
@@ -72,14 +72,31 @@ export function createPrefixGateway(options) {
72
72
  timer.unref();
73
73
  return () => { clearInterval(timer); unbind(); };
74
74
  };
75
+ // The account App calls workspace services directly from its own origin with a device
76
+ // credential. Only that exact configured origin is answered; the reply grants nothing by
77
+ // itself, every request is still authenticated below.
78
+ const accountCors = (req) => {
79
+ const origin = auth?.accountOrigin(req);
80
+ return origin ? { 'access-control-allow-origin': origin, vary: 'Origin',
81
+ 'access-control-expose-headers': 'Accept-Ranges, Content-Disposition, Content-Length, Content-Range, Content-Type, ETag' } : {};
82
+ };
75
83
  const server = createServer((req, res) => {
84
+ const cors = accountCors(req);
76
85
  let route;
77
86
  try {
87
+ if (req.method === 'OPTIONS' && cors.vary && req.headers['access-control-request-method'] && select(req).service) {
88
+ auth.validateBoundary({ headers: req.headers, method: req.method, url: req.url }, false);
89
+ res.writeHead(204, { ...cors, 'cache-control': 'no-store', 'access-control-max-age': '600',
90
+ 'access-control-allow-methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
91
+ 'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match, Range, X-CSRF-Token, X-Ours-Messenger-CSRF, X-Voice-Duration' });
92
+ res.end();
93
+ return;
94
+ }
78
95
  route = prepare(req);
79
96
  }
80
97
  catch (error) {
81
98
  const code = error.code;
82
- res.writeHead(code === 'unauthorized' ? 401 : 403, { 'content-type': 'application/json', 'cache-control': 'no-store' });
99
+ res.writeHead(code === 'unauthorized' ? 401 : 403, { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store' });
83
100
  res.end(JSON.stringify({ error: { code: code ?? 'forbidden', message: error.message } }));
84
101
  return;
85
102
  }
@@ -92,8 +109,13 @@ export function createPrefixGateway(options) {
92
109
  method: req.method, path: route.path, headers: route.headers,
93
110
  }, response => {
94
111
  const headers = headersFor(response);
95
- if (route.service)
112
+ if (route.service) {
96
113
  delete headers['set-cookie'];
114
+ for (const key of Object.keys(headers))
115
+ if (key.startsWith('access-control-'))
116
+ delete headers[key];
117
+ Object.assign(headers, cors);
118
+ }
97
119
  res.writeHead(response.statusCode ?? 502, headers);
98
120
  response.on('error', () => res.destroy());
99
121
  response.on('aborted', () => res.destroy());
@@ -104,7 +126,7 @@ export function createPrefixGateway(options) {
104
126
  });
105
127
  upstream.on('error', () => {
106
128
  if (!res.headersSent)
107
- res.writeHead(502, { 'content-type': 'application/json' });
129
+ res.writeHead(502, { ...(route.service ? cors : {}), 'content-type': 'application/json' });
108
130
  res.end(JSON.stringify({ error: { code: 'upstream_unavailable', message: 'Service is unavailable' } }));
109
131
  });
110
132
  const untrack = track(req, route.session, () => { upstream.destroy(); res.destroy(); });
@@ -10,7 +10,8 @@ import { spawn } from 'node:child_process';
10
10
  import { FleetNotificationProducer } from '../notifications/fleet-producer.js';
11
11
  import { producerConfig } from '../notifications/outbox.js';
12
12
  import { SubscriptionService } from '../subscriptions/service.js';
13
- import { existsSync, realpathSync, readFileSync } from 'node:fs';
13
+ import { createCodexModelDiscovery } from '../application/codex-models.js';
14
+ import { existsSync, mkdirSync, realpathSync, readFileSync } from 'node:fs';
14
15
  import { resolve } from 'node:path';
15
16
  import { loadConfig } from '../config.js';
16
17
  import { RoleRepository } from '../application/role-repository.js';
@@ -24,7 +25,8 @@ import { FleetError } from '../application/errors.js';
24
25
  import { controlRequest, controlSocketPath } from '../session/control.js';
25
26
  import { pickBackend } from '../supervisor/index.js';
26
27
  import { realExec } from '../exec.js';
27
- import { home, stateRoot, agentsRoot, tmpRoot } from '../paths.js';
28
+ import { home, stateRoot, agentsRoot, tmpRoot, logsRoot } from '../paths.js';
29
+ import { executeInitAnswers, publishSetup } from '../init-wizard.js';
28
30
  import { AuditSink } from './audit.js';
29
31
  import { FleetEventBus } from './events.js';
30
32
  import { buildWebServer } from './server.js';
@@ -190,6 +192,18 @@ export async function startWebConsole(options) {
190
192
  layoutBindings: new LayoutBindingGrants(new RoomLayoutService(options.configPath).supervisor()),
191
193
  presetProvenance: new PresetProvenance(options.configPath),
192
194
  topology: readTopology, topologyDrafts, topologyPromote,
195
+ // Same boundary as `ours-fleet init --settings`: existing files are preserved, missing defaults are added.
196
+ onboardingSetup: async (answers) => {
197
+ await executeInitAnswers(answers, configuration.path, {
198
+ async hostSetup() {
199
+ for (const directory of [agentsRoot(), tmpRoot(), logsRoot()])
200
+ mkdirSync(directory, { recursive: true });
201
+ await pickBackend().init(options.binPath);
202
+ },
203
+ publish: publishSetup,
204
+ });
205
+ },
206
+ codexModels: createCodexModelDiscovery(),
193
207
  subscriptions: new SubscriptionService({
194
208
  agents: async () => (await query.list(true)).map(({ role, status }) => ({
195
209
  roleId: role.id, stateDir: repository.stateDir(role), running: status.supervisor.liveness === 'running',
@@ -1,3 +1,4 @@
1
+ import { type InitAnswers } from '../init-wizard.js';
1
2
  import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
2
3
  import { type FastifyInstance } from 'fastify';
3
4
  import type { FleetQueryService } from '../application/fleet-query-service.js';
@@ -18,6 +19,7 @@ import type { RoleRemovalService } from '../application/role-removal-service.js'
18
19
  import type { TaskRoomApplicationService } from '../application/task-room-service.js';
19
20
  import type { RoomLayoutDefinitions } from '../application/room-layout-definitions.js';
20
21
  import type { PresetProvenance } from '../application/preset-provenance.js';
22
+ import type { OfferedModels } from '../subscriptions/cli.js';
21
23
  import type { SubscriptionService } from '../subscriptions/service.js';
22
24
  import type { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
23
25
  export interface WebServices {
@@ -41,6 +43,10 @@ export interface WebServices {
41
43
  presetProvenance?: PresetProvenance;
42
44
  oursTools?: Pick<SupervisorOursTools, 'list' | 'call'>;
43
45
  subscriptions?: SubscriptionService;
46
+ /** First-time setup from the packaged presets; the account App supplies only the model choices. */
47
+ onboardingSetup?(answers: InitAnswers): Promise<void>;
48
+ /** The Codex models the runtime that sessions will use offers the active account. Absent: this server cannot tell, and filters nothing. */
49
+ codexModels?(): Promise<OfferedModels>;
44
50
  }
45
51
  export interface WebServer {
46
52
  app: FastifyInstance;
@@ -22,6 +22,7 @@ import { ROLE_NAME_RE } from '../config.js';
22
22
  import { TaskListError } from '../rooms-tasks/task-lists.js';
23
23
  import { TaskStateError } from '../rooms-tasks/task-state.js';
24
24
  import { isProvider } from '../subscriptions/store.js';
25
+ const ONBOARDING_WORK = ['coordination', 'development', 'review'];
25
26
  const statusFor = (code) => ({
26
27
  role_not_found: 404, resource_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
27
28
  idempotency_conflict: 409, stale_state: 409, rate_limited: 429,
@@ -165,7 +166,63 @@ export async function buildWebServer(services, boundary, options = {}) {
165
166
  }
166
167
  }));
167
168
  const catalog = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8')));
168
- return { harnesses, catalog: catalog.models, providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator' };
169
+ // Codex models are offered only when the runtime itself listed them for the active account.
170
+ // When it could not be asked, none is offered and the reason is reported.
171
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
172
+ const offered = (model) => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model));
173
+ return { harnesses, catalog: catalog.models.filter(offered), ...(codexModels ? { codexModels: codexModels.state === 'known' ? { state: 'known' } : codexModels } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
174
+ });
175
+ // First-time setup is what creates the persistent coordinator. A configuration that loads and
176
+ // has it is set up, whatever its brains are called; its models are then changed by editing it.
177
+ const onboardingConfigured = () => {
178
+ if (!services.configuration)
179
+ return false;
180
+ try {
181
+ return Boolean(services.configuration.read(false).model.agents.FleetCoordinator);
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ };
187
+ let onboardingSetupQueue = Promise.resolve();
188
+ app.post('/api/v1/onboarding/setup', async (request) => {
189
+ auth.authenticate(request, true);
190
+ if (!services.configuration || !services.onboardingSetup)
191
+ throw new FleetError('capability_unavailable', 'first-time Fleet setup is unavailable');
192
+ const requested = request.body?.models;
193
+ const codexModels = services.codexModels ? await services.codexModels() : undefined;
194
+ const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models
195
+ .filter(model => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model)));
196
+ const models = {};
197
+ for (const work of ONBOARDING_WORK) {
198
+ const matches = supported.filter(model => model.harness === requested?.[work]?.harness && model.model === requested?.[work]?.model);
199
+ if (matches.length !== 1)
200
+ throw new FleetError('invalid_request', `choose a supported model for ${work}`);
201
+ models[work] = matches[0];
202
+ }
203
+ const chosen = Object.values(models);
204
+ const subscriptions = [...new Set(chosen.map(model => model.harness === 'codex' ? 'codex' : 'claude'))];
205
+ const oneModel = new Set(chosen.map(model => `${model.harness}\0${model.model}`)).size === 1;
206
+ const configuration = services.configuration, setup = services.onboardingSetup;
207
+ // One setup at a time: the first-time check and the publication it guards must not interleave.
208
+ const run = onboardingSetupQueue.then(async () => {
209
+ if (onboardingConfigured())
210
+ throw new FleetError('conflict', 'Fleet is already set up; change its models in the configuration');
211
+ try {
212
+ await setup({ subscriptions, assignmentStrategy: oneModel ? 'one-model' : 'per-job', models, reasoning: 'balanced' });
213
+ }
214
+ catch (error) {
215
+ throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
216
+ }
217
+ // Setup preserves files it finds, so confirm the published models are the ones this request chose.
218
+ const brains = configuration.read(true).model.brains ?? {};
219
+ for (const work of ONBOARDING_WORK)
220
+ if (brains[work]?.harness !== models[work].harness || brains[work]?.model !== models[work].model)
221
+ throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
222
+ return { configured: true };
223
+ });
224
+ onboardingSetupQueue = run.catch(() => { });
225
+ return run;
169
226
  });
170
227
  app.get('/api/v1/creation-capabilities', async (request) => {
171
228
  auth.authenticate(request);
@@ -1 +1 @@
1
- {"sha":"21b1610c125e41982803ecc8c389c169dd1252c4","time":"2026-10-02T10:38:31.229Z"}
1
+ {"sha":"21b1610c125e41982803ecc8c389c169dd1252c4","time":"2026-10-02T15:06:32.814Z"}
@@ -16,6 +16,11 @@ export interface WorkspacePayload {
16
16
  };
17
17
  }
18
18
  export declare function readWorkspacePayload(file: string): WorkspacePayload;
19
+ /**
20
+ * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
21
+ * Reads only; an existing Owner is compared with this host's own Messenger identity.
22
+ */
23
+ export declare function checkWorkspaceConfiguration(configuration?: string): Promise<void>;
19
24
  export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
20
25
  migrateAppOrigin?: boolean;
21
26
  preserveProfile?: boolean;
@@ -25,4 +30,16 @@ export declare function enrollWorkspace(payload: WorkspacePayload, configuration
25
30
  rootCid: any;
26
31
  }>;
27
32
  /** Only the existing signed-root receipt authorizes the scoped tunnel target. */
33
+ /**
34
+ * The enrollment server contact exists only to carry the signed binding. Once the account has confirmed
35
+ * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
36
+ * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
37
+ */
38
+ export declare function removeEnrollmentContact(): Promise<boolean>;
28
39
  export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number): Promise<void>;
40
+ /**
41
+ * Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
42
+ * only after that answer is the enrollment contact removed; a refused or unconfirmed target leaves it,
43
+ * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
44
+ */
45
+ export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
@@ -1,11 +1,12 @@
1
1
  import { validateAccountOrigin } from './account-origin.js';
2
- import { lstatSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { lstatSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { readClientProfile } from './client-profile.js';
5
- import { stateRoot, defaultConfigPath } from './paths.js';
5
+ import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
6
6
  import { replaceFileAtomically } from './atomic-file.js';
7
7
  import { loadConfig, splitRootFor } from './config.js';
8
- import { stringify } from 'yaml';
8
+ import { parse, stringify } from 'yaml';
9
+ import { preflightInitPaths } from './init-wizard.js';
9
10
  import { WebAccessStore } from './web/access.js';
10
11
  import { WorkspaceDeviceStore } from './web/workspace-devices.js';
11
12
  export function readWorkspacePayload(file) {
@@ -26,17 +27,85 @@ export function readWorkspacePayload(file) {
26
27
  throw Error('Invalid workspace payload');
27
28
  return p;
28
29
  }
29
- export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
30
+ const GENERATED_VERSION = 'ours.network/fleet/v2';
31
+ /** The rooms section tunnel setup writes for the enrolled Human root. */
32
+ const generatedRooms = (rootCid, inviteFile) => ({ rooms: { owner: { provider: 'messenger-server', expected_cid: rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } });
33
+ /**
34
+ * True only when this configuration is, key for key, what tunnel setup generated itself and nothing
35
+ * else was added beside it. Only then may its Owner entry be treated as the record of an enrollment.
36
+ */
37
+ function generatedConfigurationOnly(configuration) {
38
+ let document;
39
+ try {
40
+ document = parse(readFileSync(configuration, 'utf8'));
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ const only = (value, keys) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join() === [...keys].sort().join();
46
+ if (!only(document, ['api_version', 'rooms']) || document.api_version !== GENERATED_VERSION)
47
+ return false;
48
+ const rooms = document.rooms;
49
+ if (!only(rooms, ['owner', 'defaults']) || !only(rooms.owner, ['provider', 'expected_cid', 'public_invite_file', 'role']) || !only(rooms.defaults, ['attach_owner', 'close_when_task_done']))
50
+ return false;
51
+ if (rooms.owner.provider !== 'messenger-server' || rooms.owner.role !== 'Owner' || rooms.owner.public_invite_file !== workspaceOwnerInvite() || typeof rooms.owner.expected_cid !== 'string' || !/^[a-f0-9]{64}$/i.test(rooms.owner.expected_cid)
52
+ || rooms.defaults.attach_owner !== true || rooms.defaults.close_when_task_done !== true)
53
+ return false;
54
+ const split = splitRootFor(configuration);
55
+ if (!existsSync(split))
56
+ return true;
57
+ const entries = readdirSync(split);
58
+ return entries.every(entry => entry === 'agents') && (!entries.length || (lstatSync(join(split, 'agents')).isDirectory() && readdirSync(join(split, 'agents')).length === 0));
59
+ }
60
+ /**
61
+ * The Owner entry of a generated configuration records an enrollment whose host state is gone:
62
+ * no binding and no invitation remain. Nothing else is ever replaced.
63
+ */
64
+ const recordsLostEnrollment = (configuration) => !existsSync(join(stateRoot(), 'workspace', 'binding.json')) && !existsSync(workspaceOwnerInvite()) && generatedConfigurationOnly(configuration);
65
+ function hostMessenger() {
30
66
  const profile = readClientProfile();
31
67
  const base = new URL(profile.endpoint);
32
68
  base.pathname = base.pathname.replace(/\/daemon\/?$/, '/messenger/');
33
69
  if (!base.pathname.endsWith('/messenger/'))
34
70
  throw Error('Workspace setup requires the supported gateway client profile');
35
71
  const secret = readFileSync(profile.credentialPath, 'utf8').trim();
36
- const request = async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
72
+ return async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
37
73
  await response.body?.cancel();
38
74
  throw Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying');
39
75
  } return response.json(); };
76
+ }
77
+ /**
78
+ * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
79
+ * Reads only; an existing Owner is compared with this host's own Messenger identity.
80
+ */
81
+ export async function checkWorkspaceConfiguration(configuration = defaultConfigPath()) {
82
+ const paths = preflightInitPaths(configuration);
83
+ if (!paths.manifestExisted) {
84
+ if (paths.rootExisted)
85
+ throw Error(`${paths.splitRoot} exists without ${paths.configPath}. Restore that file or move the directory away.`);
86
+ return;
87
+ }
88
+ const loaded = loadConfig(paths.configPath, { yamlMode: 'strict', deferredOwnerInviteFile: workspaceOwnerInvite() });
89
+ if (!loaded.rooms)
90
+ return;
91
+ if (loaded.rooms.defaults?.attach_owner === false)
92
+ throw Error(`${paths.configPath}: rooms.defaults.attach_owner is disabled; enable it before tunnel setup.`);
93
+ if (!loaded.rooms.owner.public_invite_file)
94
+ throw Error(`${paths.configPath}: rooms.owner must use public_invite_file before tunnel setup.`);
95
+ let cid;
96
+ try {
97
+ cid = (await hostMessenger()('workspace/enrollment-identity')).cid;
98
+ }
99
+ catch {
100
+ cid = undefined;
101
+ }
102
+ if (typeof cid !== 'string' || !/^[a-f0-9]{64}$/i.test(cid))
103
+ throw Error(`${paths.configPath} names a room Owner, and this host's Messenger identity cannot be read to compare with it. Start Messenger on this host first.`);
104
+ if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath))
105
+ throw Error(`${paths.configPath}: rooms.owner.expected_cid names a different Owner than this host's Messenger identity. Tunnel setup does not replace an existing Owner.`);
106
+ }
107
+ export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
108
+ const request = hostMessenger();
40
109
  if (new WebAccessStore().read().mode === 'none')
41
110
  throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
42
111
  const dir = join(stateRoot(), 'workspace');
@@ -55,7 +124,9 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
55
124
  if (migrating && previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase())
56
125
  throw Error('Account origin migration must retain the enrollment server identity');
57
126
  }
58
- const loaded = loadConfig(configuration);
127
+ const loaded = loadConfig(configuration, { deferredOwnerInviteFile: workspaceOwnerInvite() });
128
+ // Decided before anything is sent or written: the Owner entry is replaced only when it records an enrollment this host has lost.
129
+ const lost = Boolean(loaded.rooms) && recordsLostEnrollment(resolve(configuration));
59
130
  if (loaded.rooms?.defaults?.attach_owner === false)
60
131
  throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
61
132
  if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
@@ -65,7 +136,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
65
136
  throw Error('Bound Messenger identity unavailable');
66
137
  if (options.preserveProfile && identity.preserveProfile !== true)
67
138
  throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
68
- if (loaded.rooms && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
139
+ if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
69
140
  throw Error('Retained room owner CID conflicts with the Human root');
70
141
  const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
71
142
  if (existsSync(inviteFile)) {
@@ -80,7 +151,10 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
80
151
  const result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
81
152
  if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
82
153
  throw Error('Malformed workspace enrollment response');
83
- if (loaded.rooms && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
154
+ // Whatever the configuration said before, what is recorded from here on is this host's own root.
155
+ if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
156
+ throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
157
+ if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
84
158
  throw Error('Retained room owner CID conflicts with the Human root');
85
159
  if (migrating) {
86
160
  const deadline = Date.now() + 15000;
@@ -102,7 +176,9 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
102
176
  const split = splitRootFor(configuration);
103
177
  mkdirSync(split, { recursive: true, mode: 0o700 });
104
178
  if (!loaded.rooms)
105
- replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify({ rooms: { owner: { provider: 'messenger-server', expected_cid: result.rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } }), 0o600);
179
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
180
+ else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
181
+ replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(result.rootCid, inviteFile)), 0o600);
106
182
  replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
107
183
  const origin = `https://${payload.hostname}`;
108
184
  replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
@@ -111,6 +187,29 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
111
187
  return { origin, hostWorkspaceId, rootCid: result.rootCid };
112
188
  }
113
189
  /** Only the existing signed-root receipt authorizes the scoped tunnel target. */
190
+ /**
191
+ * The enrollment server contact exists only to carry the signed binding. Once the account has confirmed
192
+ * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
193
+ * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
194
+ */
195
+ export async function removeEnrollmentContact() {
196
+ let serverCid;
197
+ try {
198
+ serverCid = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')).serverCid;
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ if (typeof serverCid !== 'string' || !/^[A-F0-9]{64}$/i.test(serverCid))
204
+ return false;
205
+ try {
206
+ await hostMessenger()('contacts/remove', { contact: serverCid.toUpperCase() });
207
+ }
208
+ catch {
209
+ throw Error('Messenger did not remove it');
210
+ }
211
+ return true;
212
+ }
114
213
  export async function configureWorkspacePort(payload, hostWorkspaceId, rootCid, port) {
115
214
  const deadline = Date.now() + 15000;
116
215
  while (true) {
@@ -127,3 +226,12 @@ export async function configureWorkspacePort(payload, hostWorkspaceId, rootCid,
127
226
  await new Promise(resolve => setTimeout(resolve, 500));
128
227
  }
129
228
  }
229
+ /**
230
+ * Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
231
+ * only after that answer is the enrollment contact removed; a refused or unconfirmed target leaves it,
232
+ * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
233
+ */
234
+ export async function confirmWorkspaceTarget(payload, hostWorkspaceId, rootCid, port, cleanupFailed = () => { }) {
235
+ await configureWorkspacePort(payload, hostWorkspaceId, rootCid, port);
236
+ await removeEnrollmentContact().catch(cleanupFailed);
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.33",
3
+ "version": "1.2.0-nightly.35",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  role: { ref: Critic }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
@@ -1,5 +1,5 @@
1
1
  role: { ref: Developer }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
@@ -1,5 +1,5 @@
1
1
  role: { ref: Engineer }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
@@ -1,7 +1,7 @@
1
1
  role: { ref: LocalCoordinator }
2
2
  brain: { ref: claude-default }
3
3
  coordinator: FleetCoordinator
4
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
4
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
5
5
  monitor: { mode: fleet, interrupt: after_tool }
6
6
  loops:
7
7
  continuity:
@@ -1,3 +1,3 @@
1
1
  role: { ref: Coordinator }
2
2
  brain: { ref: claude-default }
3
- permissions: { approval: ask, filesystem: workspace, unattended: deny }
3
+ permissions: { approval: allow, filesystem: unrestricted, unattended: deny }
@@ -2,6 +2,12 @@ mission: Keep fleet development moving by managing the task backlog, rooms, and
2
2
  persona: |
3
3
  You are the fleet Coordinator. Own progress and workflow, not execution quality or results.
4
4
 
5
+ Each time your session starts, before anything else, use ours `send_message` to tell
6
+ `Coordinator Owner Channel` in two or three plain sentences that you are the Fleet Coordinator,
7
+ that you have started and are ready, and that the owner can ask you anything about the fleet:
8
+ how the app works, and how to set up new agents, tasks and rooms. Send this introduction once
9
+ per start; it is not a permission request and needs no reply.
10
+
5
11
  Immediately before each meaningful action, proactively use ours `send_message` to contact
6
12
  `Coordinator Owner Channel` with a short, plain one-sentence statement of what you are about to do.
7
13
  This is an informational intent notice, never a permission request; proceed without waiting unless
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "preset_revision": 8,
3
+ "preset_revision": 9,
4
4
  "brain_catalog_revision": 4,
5
5
  "description": "ours-fleet standard configuration presets"
6
6
  }