@ours.network/fleet 1.1.5 → 1.2.0-nightly.2

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
@@ -510,6 +510,23 @@ obsolete, presence-sensitive `OURS_AUTOSTART` variable from ACP harness
510
510
  processes. `ours-mcp proxy` is client-only and never starts a daemon; operators and explicit
511
511
  installer/setup flows remain responsible for starting it.
512
512
 
513
+ Fleet selects explicit `OURS_CONFIG` first, otherwise the managed
514
+ `~/.ours-client/profile.json` when present. With a managed profile, ordinary
515
+ `ours-fleet doctor` and `ours-fleet up` need no environment override. Invalid or
516
+ unreadable managed profiles fail rather than select another daemon. If neither
517
+ selection exists, the existing unmanaged behavior remains.
518
+
519
+ The private profile names the daemon endpoint, retained instance UUID and host
520
+ credential path; Fleet does not need the daemon state directory. An explicit
521
+ override selects a new invocation without rewriting already installed service
522
+ definitions. To select another profile explicitly:
523
+
524
+ ```sh
525
+ export OURS_CONFIG=/absolute/private/ours/client.json
526
+ ours-fleet doctor
527
+ ours-fleet up
528
+ ```
529
+
513
530
  ### Rooms and tasks
514
531
 
515
532
  Init installs editable `single`, `pair`, and `team` definitions under
@@ -548,7 +565,26 @@ N, Enter, Escape, Ctrl-C, Ctrl-D, or EOF cancels. In a picker, Escape, Ctrl-C, C
548
565
  or EOF cancels; Enter records the highlighted choice (or continues a non-empty multi-select),
549
566
  N is ignored, and an empty subscription selection remains blocked. Every cancellation
550
567
  before the final Yes performs no host setup or config publication. Non-TTY use stops with
551
- the same zero-mutation guarantee. After the final Yes, host integration runs before locked
568
+ the same zero-mutation guarantee unless `--settings PATH` supplies complete JSON answers.
569
+ Prepared settings run without questions and use this strict shape (catalog model capabilities
570
+ must match exactly):
571
+
572
+ ```json
573
+ {
574
+ "subscriptions": ["codex"],
575
+ "assignmentStrategy": "one-model",
576
+ "models": {
577
+ "development": { "harness": "codex", "session": "acp", "model": "gpt-5.6-sol", "efforts": ["low", "medium", "high", "xhigh", "max", "ultra"] },
578
+ "review": { "harness": "codex", "session": "acp", "model": "gpt-5.6-sol", "efforts": ["low", "medium", "high", "xhigh", "max", "ultra"] },
579
+ "coordination": { "harness": "codex", "session": "acp", "model": "gpt-5.6-sol", "efforts": ["low", "medium", "high", "xhigh", "max", "ultra"] }
580
+ },
581
+ "reasoning": "balanced"
582
+ }
583
+ ```
584
+
585
+ Run `ours-fleet init --settings settings.json -c /path/custom.yaml`. Unreadable,
586
+ malformed, incomplete, extra, or unsupported values fail before host setup and publication;
587
+ Fleet never falls back to questions. After the final Yes or validated settings, host integration runs before locked
552
588
  publication; a hard kill, power loss, or host crash can therefore leave host integration
553
589
  or private stage/recovery evidence to inspect.
554
590
  Existing targets and their tree must be owner-private regular files/directories on the
@@ -1610,3 +1646,54 @@ any interpreter symlink path. Declare these installation-specific paths in
1610
1646
  `isolation.fs.read`; do not expose an entire operator home. The tested policy
1611
1647
  allowed a project write and refused a write to an outside read-only directory.
1612
1648
  The `broker` network mode retains host networking; it is not network isolation.
1649
+
1650
+ ## Container-daemon recovery checks
1651
+
1652
+ After building Fleet with the selected SDK and CLI artifacts installed, run
1653
+ `npm run test:v1-recovery` in an isolated Docker container with external networking
1654
+ disabled. The existing test covers retry and terminal failure cases using a
1655
+ temporary daemon and no authenticated AI harness. By default both daemon and
1656
+ token-update commands use `node_modules/@ours.network/cli/dist/cli.js`;
1657
+ `FLEET_DAEMON_CLI` and `FLEET_TOKEN_CLI` can select explicitly prepared CLI entries.
1658
+ These package checks do not replace native Fleet/harness or platform acceptance.
1659
+
1660
+ ### Build with selected SDK and CLI archives
1661
+
1662
+ Run in the build container with Node 22+, npm and tar available:
1663
+
1664
+ ```sh
1665
+ node scripts/build-selected.mjs --sdk /artifacts/ours.network-sdk-3.7.2.tgz --cli /artifacts/ours.network-cli-2.7.2.tgz --out-dir /artifacts/consumer
1666
+ ```
1667
+
1668
+ The recipe validates package names, installs and builds in disposable staging,
1669
+ then writes one complete portable npm archive. Stdout is a JSON object with its
1670
+ actual `filename`; build/npm logs go to stderr. Normal source manifests, locks
1671
+ and installed dependencies are preserved. The installer must install the same
1672
+ selected SDK and CLI archives alongside this package; its final dependency
1673
+ versions come from those archives. Existing bundling choices are unchanged.
1674
+
1675
+ Focused build/install verification (two real builds, including changed bytes
1676
+ under identical input names and versions, plus a missing-vendor negative check):
1677
+
1678
+ ```sh
1679
+ node scripts/check-build-selected.mjs --sdk /artifacts/ours.network-sdk-3.7.2.tgz --cli /artifacts/ours.network-cli-2.7.2.tgz
1680
+ ```
1681
+
1682
+ ### Remote clients over HTTPS
1683
+
1684
+ A client may select an `https://` origin backed by a TLS reverse proxy. Keep the
1685
+ daemon listener private and configure the proxy separately with a certificate
1686
+ trusted by the client and matching the hostname. Node uses its normal trust
1687
+ store; a private CA may be supplied through `NODE_EXTRA_CA_CERTS` before starting
1688
+ the client. Certificate verification must remain enabled.
1689
+
1690
+ The existing issued client credential works over either transport; changing the
1691
+ URL scheme does not require a new credential. Keep the server's master on the
1692
+ server. Forward `x-ours-api-token` and the `x-ours-*` session headers unchanged,
1693
+ and support streamed request/response bodies and long polling. Client requests
1694
+ refuse redirects, including HTTPS-to-HTTP redirects: configure the final HTTPS
1695
+ origin directly. UUID/capability checks still precede credential-bearing calls.
1696
+
1697
+ This adds client HTTPS support, not an HTTPS daemon listener, certificate
1698
+ provisioning or automatic reverse-proxy configuration. Existing local HTTP and
1699
+ SSH-tunnel profiles continue to work.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.1.5",
3
- "buildId": "a396a4f22c11",
4
- "commit": "9add3db892e29fd3fe0b5d4d44af84617f33d5f7",
5
- "dirty": false,
6
- "builtAt": "2026-09-11T19:32:31.270Z",
2
+ "version": "1.2.0-nightly.2",
3
+ "buildId": "e29dfb038997",
4
+ "commit": "95f30545961ab68e2196fe3c9b3759ab8319f0ca",
5
+ "dirty": true,
6
+ "builtAt": "2026-09-19T07:35:12.185Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { Command } from 'commander';
9
9
  import { VERSION } from './version.js';
10
10
  import { INIT_COMPLETION_GUIDANCE } from './init-guidance.js';
11
11
  import { migrateLegacyStarterPresets, migratePackagedRoleDefaults } from './preset-migration.js';
12
- import { executeInitWizard, isInteractiveTerminal, publishSetup, TerminalPrompter, } from './init-wizard.js';
12
+ import { executeInitAnswers, executeInitWizard, isInteractiveTerminal, publishSetup, readInitSettings, TerminalPrompter, } from './init-wizard.js';
13
13
  import { analyzeInstalls, buildInfo, buildLabel, discoverInstalls, runningLabel, } from './provenance.js';
14
14
  import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir, defaultConfigPath } from './paths.js';
15
15
  import { findRole, loadConfig, ROLE_NAME_RE } from './config.js';
@@ -1269,13 +1269,14 @@ cOpt(program.command('doctor').description('prerequisite report'))
1269
1269
  if (!rep.ok)
1270
1270
  throw new FleetCliExit(1);
1271
1271
  });
1272
- cOpt(program.command('init').description('interactively add missing Fleet defaults while preserving existing configuration'))
1272
+ cOpt(program.command('init').description('add missing Fleet defaults while preserving existing configuration')
1273
+ .option('--settings <file>', 'validated InitAnswers JSON; runs without interactive questions'))
1273
1274
  .action(async (opts) => {
1274
1275
  const configuration = opts.configuration ?? defaultConfigPath();
1275
- if (!isInteractiveTerminal(process.stdin, process.stdout))
1276
+ if (!opts.settings && !isInteractiveTerminal(process.stdin, process.stdout))
1276
1277
  die('ours-fleet init requires an interactive terminal; no host setup ran and no configuration changed');
1277
1278
  try {
1278
- const result = await executeInitWizard(new TerminalPrompter(process.stdin, process.stdout), configuration, {
1279
+ const initDeps = {
1279
1280
  async hostSetup() {
1280
1281
  for (const d of [agentsRoot(), tmpRoot(), logsRoot()])
1281
1282
  mkdirSync(d, { recursive: true });
@@ -1283,10 +1284,13 @@ cOpt(program.command('init').description('interactively add missing Fleet defaul
1283
1284
  console.log(message);
1284
1285
  },
1285
1286
  publish: publishSetup,
1286
- });
1287
+ };
1288
+ const result = opts.settings
1289
+ ? await executeInitAnswers(readInitSettings(opts.settings), configuration, initDeps)
1290
+ : await executeInitWizard(new TerminalPrompter(process.stdin, process.stdout), configuration, initDeps);
1287
1291
  if (!result) {
1288
1292
  console.log('Fleet setup cancelled. No host setup ran and no configuration changed.');
1289
- return;
1293
+ throw new FleetCliExit(1);
1290
1294
  }
1291
1295
  const prior = result.manifestExisted && result.rootExisted ? 'manifest and split configuration'
1292
1296
  : result.manifestExisted ? 'manifest only'
@@ -1297,6 +1301,8 @@ cOpt(program.command('init').description('interactively add missing Fleet defaul
1297
1301
  console.log(INIT_COMPLETION_GUIDANCE);
1298
1302
  }
1299
1303
  catch (error) {
1304
+ if (error instanceof FleetCliExit)
1305
+ throw error;
1300
1306
  die(error);
1301
1307
  }
1302
1308
  });
@@ -0,0 +1,17 @@
1
+ export interface ExplicitClientProfile {
2
+ readonly endpoint: string;
3
+ readonly expectedInstanceId: string;
4
+ readonly credentialPath: string;
5
+ readonly configPath: string;
6
+ }
7
+ export declare class ClientProfileError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ export declare function clientConfigPath(env: NodeJS.ProcessEnv): string;
11
+ /**
12
+ * Read Fleet's selected host-client profile without opening its credential.
13
+ * A legacy daemon config remains legacy only when none of the profile fields is
14
+ * present. Explicit and managed selections fail closed on invalid input.
15
+ */
16
+ export declare function readClientProfile(env: NodeJS.ProcessEnv): ExplicitClientProfile | undefined;
17
+ export declare function clientProfileKey(profile: ExplicitClientProfile): string;
@@ -0,0 +1,115 @@
1
+ import { lstatSync, readFileSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ const PROFILE_FIELDS = ['endpoint', 'expectedInstanceId', 'credentialPath'];
5
+ const LOWERCASE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
6
+ const LEGACY_CONFIG_FIELDS = ['apiToken', 'stateDir', 'port'];
7
+ const LEGACY_ENV_FIELDS = ['OURS_API_TOKEN', 'OURS_STATE_DIR', 'OURS_PORT', 'OURS_DAEMON_ID'];
8
+ export class ClientProfileError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'ClientProfileError';
12
+ }
13
+ }
14
+ export function clientConfigPath(env) {
15
+ if (env.OURS_CONFIG !== undefined)
16
+ return env.OURS_CONFIG;
17
+ const managedPath = join(env.HOME || homedir(), '.ours-client', 'profile.json');
18
+ try {
19
+ lstatSync(managedPath);
20
+ return managedPath;
21
+ }
22
+ catch (error) {
23
+ if (error.code !== 'ENOENT')
24
+ throw invalid(managedPath, 'could not be read');
25
+ return join(homedir(), '.ours', 'config.json');
26
+ }
27
+ }
28
+ function invalid(path, detail) {
29
+ return new ClientProfileError(`ours client profile ${JSON.stringify(path)} ${detail}`);
30
+ }
31
+ /**
32
+ * Read Fleet's selected host-client profile without opening its credential.
33
+ * A legacy daemon config remains legacy only when none of the profile fields is
34
+ * present. Explicit and managed selections fail closed on invalid input.
35
+ */
36
+ export function readClientProfile(env) {
37
+ const configPath = clientConfigPath(env);
38
+ const managed = configPath === join(env.HOME || homedir(), '.ours-client', 'profile.json');
39
+ const named = env.OURS_CONFIG !== undefined || managed;
40
+ let text;
41
+ try {
42
+ text = readFileSync(configPath, 'utf8');
43
+ }
44
+ catch {
45
+ if (named)
46
+ throw invalid(configPath, 'could not be read');
47
+ return undefined;
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(text);
52
+ }
53
+ catch {
54
+ if (named)
55
+ throw invalid(configPath, 'is not valid JSON');
56
+ return undefined;
57
+ }
58
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
59
+ if (named)
60
+ throw invalid(configPath, 'must contain a JSON object');
61
+ return undefined;
62
+ }
63
+ const row = parsed;
64
+ if (!PROFILE_FIELDS.some(field => Object.prototype.hasOwnProperty.call(row, field))) {
65
+ if (managed)
66
+ throw invalid(configPath, 'must contain a complete client profile');
67
+ return undefined;
68
+ }
69
+ let metadata;
70
+ try {
71
+ metadata = statSync(configPath);
72
+ }
73
+ catch {
74
+ throw invalid(configPath, 'could not be inspected');
75
+ }
76
+ if (!metadata.isFile())
77
+ throw invalid(configPath, 'must be a regular file');
78
+ const getuid = process.getuid;
79
+ if (getuid && metadata.uid !== getuid())
80
+ throw invalid(configPath, 'must be owned by this user');
81
+ if ((metadata.mode & 0o077) !== 0)
82
+ throw invalid(configPath, 'must not be accessible by group or other users');
83
+ for (const field of PROFILE_FIELDS)
84
+ if (typeof row[field] !== 'string' || !row[field].trim())
85
+ throw invalid(configPath, `has an invalid or missing ${field}`);
86
+ for (const field of LEGACY_CONFIG_FIELDS)
87
+ if (Object.prototype.hasOwnProperty.call(row, field))
88
+ throw invalid(configPath, `cannot combine ${field} with explicit profile selection`);
89
+ for (const field of LEGACY_ENV_FIELDS)
90
+ if (env[field]?.trim())
91
+ throw invalid(configPath, `cannot combine ${field} with explicit profile selection`);
92
+ const endpoint = row.endpoint.trim();
93
+ let url;
94
+ try {
95
+ url = new URL(endpoint);
96
+ }
97
+ catch {
98
+ throw invalid(configPath, 'has an invalid endpoint');
99
+ }
100
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/'
101
+ || url.search || url.hash)
102
+ throw invalid(configPath, 'endpoint must be an HTTP or HTTPS origin');
103
+ const expectedInstanceId = row.expectedInstanceId.trim();
104
+ if (!LOWERCASE_UUID.test(expectedInstanceId))
105
+ throw invalid(configPath, 'expectedInstanceId must be a lowercase UUID');
106
+ const credentialPath = row.credentialPath.trim();
107
+ if (!isAbsolute(credentialPath))
108
+ throw invalid(configPath, 'credentialPath must be absolute');
109
+ return Object.freeze({
110
+ endpoint: url.origin, expectedInstanceId, credentialPath, configPath,
111
+ });
112
+ }
113
+ export function clientProfileKey(profile) {
114
+ return `${profile.endpoint}#${profile.expectedInstanceId}`;
115
+ }
package/dist/creation.js CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { attachOursClient, } from '@ours.network/sdk/client';
5
5
  import { replaceFileAtomically, withFileLock } from './atomic-file.js';
6
6
  import { stateRoot } from './paths.js';
7
+ import { readClientProfile } from './client-profile.js';
7
8
  import { buildInfo, UNKNOWN_BUILD } from './provenance.js';
8
9
  /**
9
10
  * One creation transaction: role name and ours identity reserved together,
@@ -221,12 +222,17 @@ export async function ensureIdentity(name, profile, provisioner, log = () => { }
221
222
  };
222
223
  }
223
224
  export function daemonIdentityProvisioner(env = process.env, attachClient = attachOursClient, options = {}) {
225
+ const connect = (leaseToken) => {
226
+ const profile = readClientProfile(env);
227
+ return attachClient(profile ? {
228
+ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
229
+ credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken, env: {},
230
+ } : { env, leaseToken, clientPid: process.pid });
231
+ };
224
232
  const provisioner = {
225
233
  async exists(name) {
226
234
  try {
227
- const client = await attachClient({
228
- env, leaseToken: `ours-fleet-identity-preflight-${process.pid}`, clientPid: process.pid,
229
- });
235
+ const client = await connect(`ours-fleet-identity-preflight-${process.pid}`);
230
236
  const identities = await client.identities();
231
237
  if (!Array.isArray(identities))
232
238
  return 'unknown';
@@ -248,10 +254,7 @@ export function daemonIdentityProvisioner(env = process.env, attachClient = atta
248
254
  if (options.createPermanent === false)
249
255
  return provisioner;
250
256
  provisioner.create = async (name, profile) => {
251
- const client = await attachClient({
252
- env, leaseToken: `ours-fleet-identity-create-${process.pid}-${randomUUID()}`,
253
- clientPid: process.pid,
254
- });
257
+ const client = await connect(`ours-fleet-identity-create-${process.pid}-${randomUUID()}`);
255
258
  if (!client.listIdentities || !client.createIdentity || !client.releaseLease)
256
259
  throw new Error('the selected ours SDK client cannot create permanent identities');
257
260
  try {
@@ -1,3 +1,4 @@
1
+ import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
1
2
  import { type FetchLike } from './monitor.js';
2
3
  export declare const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
3
4
  export declare const DAEMON_RECOVERY_INITIAL_BACKOFF_MS = 1000;
@@ -31,6 +32,7 @@ export type DaemonGenerationObservation = {
31
32
  interface GenerationProbeDeps {
32
33
  readText?(path: string): string;
33
34
  canonicalize?(path: string): string;
35
+ attachClient?(options: AttachOursClientOptions): Pick<OursClient, 'version' | 'identities' | 'close'> | Promise<Pick<OursClient, 'version' | 'identities' | 'close'>>;
34
36
  }
35
37
  /**
36
38
  * Corroborate the loopback daemon's unauthenticated `/info`, its credentialed
@@ -1,7 +1,9 @@
1
1
  import { readFileSync, realpathSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
- import { createHash } from 'node:crypto';
4
- import { resolveEndpoint } from './monitor.js';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { attachOursClient, } from '@ours.network/sdk/client';
5
+ import { readDaemonConfig, resolveEndpoint } from './monitor.js';
6
+ import { readClientProfile } from './client-profile.js';
5
7
  import { replaceFileAtomically } from './atomic-file.js';
6
8
  export const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
7
9
  export const DAEMON_RECOVERY_INITIAL_BACKOFF_MS = 1_000;
@@ -50,6 +52,9 @@ function canonical(path, deps) {
50
52
  * stale `ready` file can outlive the process that wrote it.
51
53
  */
52
54
  export async function probeDaemonGeneration(fetch, env, deps = {}) {
55
+ const profile = readClientProfile(env);
56
+ if (profile || env.OURS_DAEMON_ID)
57
+ return probeSelectedDaemon(env, deps, profile);
53
58
  const endpoint = resolveEndpoint(env);
54
59
  let response;
55
60
  try {
@@ -141,6 +146,52 @@ export async function probeDaemonGeneration(fetch, env, deps = {}) {
141
146
  },
142
147
  };
143
148
  }
149
+ /** Container selection uses public SDK reads; daemon paths/PIDs remain opaque metadata. */
150
+ async function probeSelectedDaemon(env, deps, profile) {
151
+ // Defer credential-file access to SDK selection, which checks the configured
152
+ // daemon identity/capability before it reads or sends the credential.
153
+ const endpoint = profile ? undefined : resolveEndpoint(env, false);
154
+ const token = profile ? undefined : env.OURS_API_TOKEN?.trim() || readDaemonConfig(env).apiToken;
155
+ let client;
156
+ try {
157
+ const options = profile ? {
158
+ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
159
+ credentialPath: profile.credentialPath,
160
+ sessionMode: 'external', leaseToken: randomUUID(), env: {},
161
+ } : {
162
+ endpoint: endpoint.origin, expectedInstanceId: env.OURS_DAEMON_ID,
163
+ sessionMode: 'external', leaseToken: randomUUID(),
164
+ ...(token ? { token } : { credentialPath: join(endpoint.stateDir, 'daemon-token') }),
165
+ };
166
+ client = await (deps.attachClient?.(options) ?? attachOursClient(options));
167
+ const info = await client.version({ startup: true });
168
+ if (info.name !== 'ours' || !positiveInteger(info.pid)
169
+ || typeof info.stateDir !== 'string' || !info.stateDir)
170
+ return { state: 'unavailable', reason: 'DAEMON_INFO_INVALID' };
171
+ const progress = startupProgress(info.startup);
172
+ if (!progress)
173
+ return { state: 'unavailable', reason: 'DAEMON_PROGRESS_INVALID' };
174
+ if (progress.phase !== 'ready')
175
+ return { state: 'unavailable', reason: 'DAEMON_PROGRESS_NOT_READY' };
176
+ if (progress.pid !== info.pid)
177
+ return { state: 'unavailable', reason: 'DAEMON_GENERATION_MISMATCH' };
178
+ const identities = await client.identities();
179
+ if (!Array.isArray(identities))
180
+ return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_INVALID' };
181
+ if (!identities.length)
182
+ return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_NOT_READY' };
183
+ return { state: 'ready', generation: {
184
+ bootId: progress.bootId, pid: progress.pid, startedAt: progress.startedAt, stateDir: info.stateDir,
185
+ } };
186
+ }
187
+ catch {
188
+ // Failed selection, auth or transport is unavailable, never owner death.
189
+ return { state: 'unavailable', reason: 'DAEMON_SELECTED_PROBE_UNAVAILABLE' };
190
+ }
191
+ finally {
192
+ await client?.close();
193
+ }
194
+ }
144
195
  export class DaemonGenerationObserver {
145
196
  current;
146
197
  unavailable = false;
package/dist/doctor.js CHANGED
@@ -11,6 +11,7 @@ import { resolveIsolation, harnessRuntimeDir } from './isolation/policy.js';
11
11
  import { hermesConfiguredProvider } from './harness/hermes-startup.js';
12
12
  import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
13
13
  import { authResolutionHint, resolveEndpoint, } from './monitor.js';
14
+ import { readClientProfile } from './client-profile.js';
14
15
  import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
15
16
  import { controlSocketPath } from './session/control.js';
16
17
  import { analyzeInstalls, buildInfo, buildLabel, discoverInstalls, BIN_NAME, } from './provenance.js';
@@ -34,9 +35,21 @@ function cgroupDelegationDetail() {
34
35
  function resolveMonitorProfiles(roles) {
35
36
  const profiles = [];
36
37
  for (const role of roles.filter(r => r.monitor?.mode === 'fleet')) {
37
- const endpoint = resolveEndpoint({ ...process.env, ...(role.env ?? {}) });
38
+ const env = { ...process.env, ...(role.env ?? {}) };
39
+ const client = readClientProfile(env);
40
+ if (client) {
41
+ const existing = profiles.find(p => p.client?.endpoint === client.endpoint
42
+ && p.client.expectedInstanceId === client.expectedInstanceId
43
+ && p.client.credentialPath === client.credentialPath);
44
+ if (existing)
45
+ existing.roles.push(role.name);
46
+ else
47
+ profiles.push({ client, roles: [role.name] });
48
+ continue;
49
+ }
50
+ const endpoint = resolveEndpoint(env);
38
51
  const token = endpoint.headers['x-ours-api-token'];
39
- const existing = profiles.find(p => p.endpoint.origin === endpoint.origin
52
+ const existing = profiles.find(p => p.endpoint?.origin === endpoint.origin
40
53
  && p.endpoint.headers['x-ours-api-token'] === token);
41
54
  if (existing)
42
55
  existing.roles.push(role.name);
@@ -416,12 +429,35 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
416
429
  // shared-mode misconfig (401) surfaces here rather than as a silent deaf monitor.
417
430
  const monitorProfiles = resolveMonitorProfiles(roles);
418
431
  for (const profile of monitorProfiles) {
419
- const { endpoint } = profile;
432
+ const { endpoint, client: selected } = profile;
420
433
  const checkName = monitorProfiles.length === 1
421
434
  ? 'monitor: daemon API'
422
435
  : `monitor: daemon API (${profile.roles.join(', ')})`;
423
436
  let ok = false, detail;
424
437
  try {
438
+ if (selected) {
439
+ const daemon = await attachOursClient({
440
+ endpoint: selected.endpoint,
441
+ expectedInstanceId: selected.expectedInstanceId,
442
+ credentialPath: selected.credentialPath,
443
+ sessionMode: 'external', leaseToken: `ours-fleet-doctor-monitor-${process.pid}`,
444
+ env: {},
445
+ });
446
+ try {
447
+ const [info, identities] = await Promise.all([
448
+ daemon.version(), daemon.identities(),
449
+ ]);
450
+ if (info.name !== 'ours' || !Array.isArray(identities))
451
+ throw new Error('selected daemon metadata is invalid');
452
+ ok = true;
453
+ detail = `reachable at ${selected.endpoint}, authorized — supervisor wake stream available`;
454
+ }
455
+ finally {
456
+ await daemon.close();
457
+ }
458
+ checks.push({ name: checkName, ok, detail });
459
+ continue;
460
+ }
425
461
  const live = await fetchImpl(`${endpoint.origin}/state-dir`, {});
426
462
  if (!live.ok) {
427
463
  detail = `daemon on :${endpoint.port} answered /state-dir with HTTP ${live.status} — not the ours daemon?`;
@@ -440,7 +476,8 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
440
476
  }
441
477
  }
442
478
  catch (e) {
443
- detail = `unreachable on :${endpoint.port} — monitored roles run degraded until it is up ` +
479
+ const target = selected?.endpoint ?? `:${endpoint.port}`;
480
+ detail = `unreachable on ${target} — monitored roles run degraded until it is up ` +
444
481
  `(start it: ours daemon start) [${e?.message ?? e}]`;
445
482
  }
446
483
  checks.push({ name: checkName, ok, detail });
@@ -167,6 +167,10 @@ function codexAgentLaunch(role, prep) {
167
167
  ...(options?.search ? ['--search'] : []),
168
168
  'app-server',
169
169
  ];
170
+ // Fleet delivers wakes through the native session itself. The ours-codex
171
+ // launcher owns a remote TUI and cannot serve this stdio transport.
172
+ if (launcher === 'auto' && role.monitor?.mode === 'fleet')
173
+ return { argv: ['codex', ...flags], env: prep.env };
170
174
  // Preserve the established `auto` launcher contract without resolving PATH
171
175
  // during synchronous launch preparation. The static shell fragment passes
172
176
  // every dynamic value as an argv element and `exec`s the selected process.
@@ -42,18 +42,23 @@ export interface InitPublishResult {
42
42
  manifestExisted: boolean;
43
43
  rootExisted: boolean;
44
44
  }
45
+ export interface InitExecutionDeps {
46
+ hostSetup(): Promise<void>;
47
+ publish(configuration: string, setup: GeneratedSetup): Promise<InitPublishResult>;
48
+ generate?(answers: InitAnswers): GeneratedSetup;
49
+ preflight?(configuration: string): InitPathState;
50
+ }
45
51
  export declare function validateCatalog(value: BrainCatalog, path?: string): BrainCatalog;
52
+ /** Read and validate the complete public InitAnswers JSON shape without side effects. */
53
+ export declare function readInitSettings(path: string): InitAnswers;
46
54
  export declare function askInitQuestions(prompter: InitPrompter, configuration: string): Promise<InitAnswers | undefined>;
47
55
  export declare function formatSetupSummary(answers: InitAnswers, configuration: string): string;
48
56
  /** Build the exact, deterministic default experience without writing to disk. */
49
57
  export declare function generateSetup(answers: InitAnswers): GeneratedSetup;
50
58
  /** Run the mutation boundary only after the complete questionnaire is accepted. */
51
- export declare function executeInitWizard(prompter: InitPrompter, configuration: string, deps: {
52
- hostSetup(): Promise<void>;
53
- publish(configuration: string, setup: GeneratedSetup): Promise<InitPublishResult>;
54
- generate?(answers: InitAnswers): GeneratedSetup;
55
- preflight?(configuration: string): InitPathState;
56
- }): Promise<InitPublishResult | undefined>;
59
+ export declare function executeInitWizard(prompter: InitPrompter, configuration: string, deps: InitExecutionDeps): Promise<InitPublishResult | undefined>;
60
+ /** Execute already-collected answers through the same validation and mutation boundary. */
61
+ export declare function executeInitAnswers(answers: InitAnswers, configuration: string, deps: InitExecutionDeps): Promise<InitPublishResult>;
57
62
  export interface InitPathInspectionDeps {
58
63
  exists?(path: string): boolean;
59
64
  lstat?(path: string): Stats;
@@ -49,6 +49,35 @@ function catalog() {
49
49
  const path = join(packagedPresetRoot(), 'brain-catalog.json');
50
50
  return validateCatalog(JSON.parse(readFileSync(path, 'utf8')), path);
51
51
  }
52
+ const hasExactKeys = (value, keys) => Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');
53
+ /** Read and validate the complete public InitAnswers JSON shape without side effects. */
54
+ export function readInitSettings(path) {
55
+ let value;
56
+ try {
57
+ value = JSON.parse(readFileSync(resolve(path), 'utf8'));
58
+ }
59
+ catch (error) {
60
+ throw new Error(`cannot read valid Fleet init settings ${path}: ${error.message}`, { cause: error });
61
+ }
62
+ if (!value || typeof value !== 'object' || Array.isArray(value)
63
+ || !hasExactKeys(value, ['subscriptions', 'assignmentStrategy', 'models', 'reasoning']))
64
+ throw new Error('Fleet init settings must contain exactly subscriptions, assignmentStrategy, models, and reasoning');
65
+ const candidate = value;
66
+ const models = candidate.models;
67
+ if (!models || typeof models !== 'object' || Array.isArray(models)
68
+ || !hasExactKeys(models, WORK_KINDS))
69
+ throw new Error('Fleet init settings models must contain exactly development, review, and coordination');
70
+ for (const work of WORK_KINDS) {
71
+ const model = models[work];
72
+ if (!model || typeof model !== 'object' || Array.isArray(model)
73
+ || !hasExactKeys(model, ['harness', 'session', 'model', 'efforts']))
74
+ throw new Error(`Fleet init settings ${work} model must contain exactly harness, session, model, and efforts`);
75
+ }
76
+ const answers = value;
77
+ // Generation is the single semantic/catalog validator for wizard and file input.
78
+ generateSetup(answers);
79
+ return answers;
80
+ }
52
81
  const subscriptionFor = (model) => model.harness === 'codex' ? 'codex' : 'claude';
53
82
  export async function askInitQuestions(prompter, configuration) {
54
83
  const configPath = resolve(configuration);
@@ -216,6 +245,10 @@ export async function executeInitWizard(prompter, configuration, deps) {
216
245
  const answers = await askInitQuestions(prompter, configuration);
217
246
  if (!answers)
218
247
  return undefined;
248
+ return executeInitAnswers(answers, configuration, deps);
249
+ }
250
+ /** Execute already-collected answers through the same validation and mutation boundary. */
251
+ export async function executeInitAnswers(answers, configuration, deps) {
219
252
  // Resolve every catalog/preset dependency before crossing the first mutation
220
253
  // boundary. Production owns this new map; the optional seam forces failures
221
254
  // deterministically in tests.