@ours.network/fleet 1.2.0-nightly.14 → 1.2.0-nightly.16

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
@@ -79,7 +79,7 @@ The state dir contract:
79
79
  |---|---|---|
80
80
  | Node ≥ 22 | runs `ours-fleet` and its maintained adapters | nodejs.org, `apt`, or `brew` |
81
81
  | a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
82
- | `ours` CLI + shared daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/cli@2.8.1-nightly.7 @ours.network/daemon@3.8.1-nightly.1 && ours-daemon start` |
82
+ | `ours` CLI + shared daemon | identity + agent-to-agent messaging | `ours-install client --config /absolute/private/profile.json --integrations fleet --fleet-settings /absolute/fleet-settings.json` |
83
83
 
84
84
  Linux only: `ours-fleet init` enables *linger* so roles run without a login session
85
85
  and survive reboots. macOS: launchd agents start **at login** (no linger
@@ -1348,8 +1348,9 @@ The command override is exact: Fleet does not append `--profile`, `--search`, or
1348
1348
 
1349
1349
  One-off/permanent spawn selects a Brain that owns model, reasoning, native permission,
1350
1350
  sandbox, profile, launcher, search, monitor consent, native config, and additional roots.
1351
- Use `env.OURS_PORT`/`env.OURS_CONFIG` for a
1352
- role-specific ours daemon, or configure the host default in `~/.ours/config.json`.
1351
+ All roles use the shared gateway profile `~/.ours-client/profile.json`.
1352
+ `OURS_CONFIG` selects an alternate whole profile. Legacy port/token/state-dir
1353
+ overrides are rejected. See the [gateway setup guide](https://github.com/adapt-toolkit/ours-network/blob/prerelease/packages/installer/GATEWAY_SETUP.md).
1353
1354
 
1354
1355
  ## Agent isolation
1355
1356
 
@@ -267,8 +267,10 @@ export async function preparePermanentAssignment(role) {
267
267
  endpoint: profile.endpoint,
268
268
  expectedInstanceId: profile.expectedInstanceId,
269
269
  credentialPath: profile.credentialPath,
270
- sessionMode: 'local',
271
- requiredCapabilities: ['local-pid-v1', 'root-first-identities-v1'],
270
+ sessionMode: 'external',
271
+ leaseToken: randomUUID(),
272
+ requiredCapabilities: ['external-sessions-v1', 'root-first-identities-v1'],
273
+ env: {},
272
274
  });
273
275
  try {
274
276
  const rows = await client.listIdentities(), row = rows.find((r) => r.name === role.identity);
@@ -295,7 +297,12 @@ export async function preparePermanentAssignment(role) {
295
297
  return 'verified';
296
298
  }
297
299
  finally {
298
- await client.close();
300
+ try {
301
+ await client.releaseLease();
302
+ }
303
+ finally {
304
+ await client.close();
305
+ }
299
306
  }
300
307
  }
301
308
  export function readRoomReadiness(roomCid, name) {
@@ -71,16 +71,8 @@ export class SupervisorOursTools {
71
71
  verifySelection();
72
72
  return;
73
73
  }
74
- const actual = await client.callTool({ name: 'current_identity', arguments: {} });
75
- const blocks = actual.content;
76
- const lines = blocks?.length === 1 && blocks[0].type === 'text' ? blocks[0].text?.split('\n') : undefined;
77
- const prefix = `Bound to "${identity.name}" (${identity.cid})`;
78
- const suffix = lines?.[0].startsWith(prefix) ? lines[0].slice(prefix.length) : undefined;
79
- const roleSuffix = typeof suffix === 'string' && /^ — role "[^"\r\n]+" under root "[^"\r\n]+"\.$/.test(suffix);
80
- const temporaryLine = 'TEMPORARY identity owned by the Fleet supervisor for this logical agent instance. Bridge or harness disconnect retains it; terminal supervisor release deletes local state with best-effort peer notices.';
81
- if (actual.isError || !(roleSuffix || (selected.temporary && suffix === '.'))
82
- || (selected.temporary ? lines?.[1] !== temporaryLine : lines?.[1]?.startsWith('TEMPORARY')))
83
- throw new FleetError('capability_unavailable', 'connected supervisor identity does not match legacy ownership proof');
74
+ // The supervisor owns the binding. The authenticated bridge and pinned local
75
+ // assignment are authoritative; current_identity is display text, not a protocol.
84
76
  const fresh = legacySupervisorIdentity(role, identityName, selected.temporary, descriptor.generation);
85
77
  if (!('proof' in identity) || fresh.proof !== identity.proof)
86
78
  throw new FleetError('capability_unavailable', 'legacy supervisor identity changed');
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.14",
3
- "buildId": "b88d0d158cc7",
4
- "commit": "717f6d8a09e6173b9b8a017fc5efb08d71927524",
2
+ "version": "1.2.0-nightly.16",
3
+ "buildId": "165e9b4f7cc7",
4
+ "commit": "6f621eee7791b35ceb6ec72ce7260e288f718002",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-24T14:15:57.206Z",
6
+ "builtAt": "2026-09-26T19:18:02.562Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
@@ -1,18 +1,6 @@
1
- export interface ExplicitClientProfile {
2
- readonly endpoint: string;
3
- readonly expectedInstanceId: string;
4
- readonly credentialPath: string;
5
- readonly configPath: string;
6
- readonly serverUrl?: string;
7
- }
8
- export declare class ClientProfileError extends Error {
9
- constructor(message: string);
10
- }
11
- export declare function clientConfigPath(env: NodeJS.ProcessEnv): string;
12
- /**
13
- * Read Fleet's selected host-client profile without opening its credential.
14
- * A legacy daemon config remains legacy only when none of the profile fields is
15
- * present. Explicit and managed selections fail closed on invalid input.
16
- */
17
- export declare function readClientProfile(env: NodeJS.ProcessEnv): ExplicitClientProfile | undefined;
1
+ import { clientProfilePath, readGatewayClientProfile } from '@ours.network/sdk/client';
2
+ export { ClientProfileError } from '@ours.network/sdk/client';
3
+ export type ExplicitClientProfile = ReturnType<typeof readGatewayClientProfile>;
4
+ export declare const clientConfigPath: typeof clientProfilePath;
5
+ export declare const readClientProfile: typeof readGatewayClientProfile;
18
6
  export declare function clientProfileKey(profile: ExplicitClientProfile): string;
@@ -1,130 +1,7 @@
1
- import { normalizeDaemonEndpoint } from '@ours.network/sdk/client';
2
- import { lstatSync, readFileSync, statSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { isAbsolute, join } from 'node:path';
5
- const PROFILE_FIELDS = ['endpoint', 'expectedInstanceId', 'credentialPath'];
6
- const LOWERCASE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7
- const LEGACY_CONFIG_FIELDS = ['apiToken', 'stateDir', 'port'];
8
- const LEGACY_ENV_FIELDS = ['OURS_API_TOKEN', 'OURS_STATE_DIR', 'OURS_PORT', 'OURS_DAEMON_ID'];
9
- export class ClientProfileError extends Error {
10
- constructor(message) {
11
- super(message);
12
- this.name = 'ClientProfileError';
13
- }
14
- }
15
- export function clientConfigPath(env) {
16
- if (env.OURS_CONFIG !== undefined)
17
- return env.OURS_CONFIG;
18
- const managedPath = join(env.HOME || homedir(), '.ours-client', 'profile.json');
19
- try {
20
- lstatSync(managedPath);
21
- return managedPath;
22
- }
23
- catch (error) {
24
- if (error.code !== 'ENOENT')
25
- throw invalid(managedPath, 'could not be read');
26
- return join(homedir(), '.ours', 'config.json');
27
- }
28
- }
29
- function invalid(path, detail) {
30
- return new ClientProfileError(`ours client profile ${JSON.stringify(path)} ${detail}`);
31
- }
32
- /**
33
- * Read Fleet's selected host-client profile without opening its credential.
34
- * A legacy daemon config remains legacy only when none of the profile fields is
35
- * present. Explicit and managed selections fail closed on invalid input.
36
- */
37
- export function readClientProfile(env) {
38
- const configPath = clientConfigPath(env);
39
- const managed = configPath === join(env.HOME || homedir(), '.ours-client', 'profile.json');
40
- const named = env.OURS_CONFIG !== undefined || managed;
41
- let text;
42
- try {
43
- text = readFileSync(configPath, 'utf8');
44
- }
45
- catch {
46
- if (named)
47
- throw invalid(configPath, 'could not be read');
48
- return undefined;
49
- }
50
- let parsed;
51
- try {
52
- parsed = JSON.parse(text);
53
- }
54
- catch {
55
- if (named)
56
- throw invalid(configPath, 'is not valid JSON');
57
- return undefined;
58
- }
59
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
60
- if (named)
61
- throw invalid(configPath, 'must contain a JSON object');
62
- return undefined;
63
- }
64
- const row = parsed;
65
- if (!PROFILE_FIELDS.some(field => Object.prototype.hasOwnProperty.call(row, field))) {
66
- if (managed)
67
- throw invalid(configPath, 'must contain a complete client profile');
68
- return undefined;
69
- }
70
- let metadata;
71
- try {
72
- metadata = statSync(configPath);
73
- }
74
- catch {
75
- throw invalid(configPath, 'could not be inspected');
76
- }
77
- if (!metadata.isFile())
78
- throw invalid(configPath, 'must be a regular file');
79
- const getuid = process.getuid;
80
- if (getuid && metadata.uid !== getuid())
81
- throw invalid(configPath, 'must be owned by this user');
82
- if ((metadata.mode & 0o077) !== 0)
83
- throw invalid(configPath, 'must not be accessible by group or other users');
84
- for (const field of PROFILE_FIELDS)
85
- if (typeof row[field] !== 'string' || !row[field].trim())
86
- throw invalid(configPath, `has an invalid or missing ${field}`);
87
- for (const field of LEGACY_CONFIG_FIELDS)
88
- if (Object.prototype.hasOwnProperty.call(row, field))
89
- throw invalid(configPath, `cannot combine ${field} with explicit profile selection`);
90
- for (const field of LEGACY_ENV_FIELDS)
91
- if (env[field]?.trim())
92
- throw invalid(configPath, `cannot combine ${field} with explicit profile selection`);
93
- const endpoint = row.endpoint.trim();
94
- let url;
95
- try {
96
- url = new URL(endpoint);
97
- }
98
- catch {
99
- throw invalid(configPath, 'has an invalid endpoint');
100
- }
101
- if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password
102
- || url.search || url.hash)
103
- throw invalid(configPath, 'endpoint must be an HTTP or HTTPS base URL');
104
- const expectedInstanceId = row.expectedInstanceId.trim();
105
- if (!LOWERCASE_UUID.test(expectedInstanceId))
106
- throw invalid(configPath, 'expectedInstanceId must be a lowercase UUID');
107
- const credentialPath = row.credentialPath.trim();
108
- if (!isAbsolute(credentialPath))
109
- throw invalid(configPath, 'credentialPath must be absolute');
110
- let serverUrl;
111
- if (Object.hasOwn(row, 'serverUrl')) {
112
- if (typeof row.serverUrl !== 'string' || /[\s\\?#]/.test(row.serverUrl))
113
- throw invalid(configPath, 'serverUrl must be a safe HTTP or HTTPS base URL');
114
- try {
115
- serverUrl = normalizeDaemonEndpoint(row.serverUrl);
116
- }
117
- catch {
118
- throw invalid(configPath, 'serverUrl must be a safe HTTP or HTTPS base URL');
119
- }
120
- if (normalizeDaemonEndpoint(endpoint) !== serverUrl + '/daemon')
121
- throw invalid(configPath, 'endpoint must select serverUrl/daemon');
122
- }
123
- return Object.freeze({
124
- ...(serverUrl === undefined ? {} : { serverUrl }),
125
- endpoint: normalizeDaemonEndpoint(endpoint), expectedInstanceId, credentialPath, configPath,
126
- });
127
- }
1
+ import { clientProfilePath, readGatewayClientProfile } from '@ours.network/sdk/client';
2
+ export { ClientProfileError } from '@ours.network/sdk/client';
3
+ export const clientConfigPath = clientProfilePath;
4
+ export const readClientProfile = readGatewayClientProfile;
128
5
  export function clientProfileKey(profile) {
129
6
  return `${profile.endpoint}#${profile.expectedInstanceId}`;
130
7
  }
package/dist/creation.js CHANGED
@@ -213,10 +213,10 @@ export async function ensureIdentity(name, profile, provisioner, log = () => { }
213
213
  export function daemonIdentityProvisioner(env = process.env, attachClient = attachOursClient, options = {}) {
214
214
  const connect = (leaseToken) => {
215
215
  const profile = readClientProfile(env);
216
- return attachClient(profile ? {
216
+ return attachClient({
217
217
  endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
218
218
  credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken, env: {},
219
- } : { env, leaseToken, clientPid: process.pid });
219
+ });
220
220
  };
221
221
  const provisioner = {
222
222
  async exists(name) {
@@ -34,14 +34,7 @@ interface GenerationProbeDeps {
34
34
  canonicalize?(path: string): string;
35
35
  attachClient?(options: AttachOursClientOptions): Pick<OursClient, 'version' | 'identities' | 'close'> | Promise<Pick<OursClient, 'version' | 'identities' | 'close'>>;
36
36
  }
37
- /**
38
- * Corroborate the loopback daemon's unauthenticated `/info`, its credentialed
39
- * identity-index readiness route, and its local boot-generation record. The
40
- * index enforces auth when the daemon visibility requires it; open visibility
41
- * deliberately does not. No source is sufficient
42
- * alone: `/info` has no boot id, the identity index has no generation, and a
43
- * stale `ready` file can outlive the process that wrote it.
44
- */
37
+ /** Read generation and readiness through the selected gateway. Server paths are opaque. */
45
38
  export declare function probeDaemonGeneration(fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: GenerationProbeDeps): Promise<DaemonGenerationProbe>;
46
39
  export declare class DaemonGenerationObserver {
47
40
  private current?;
@@ -1,8 +1,7 @@
1
- import { readFileSync, realpathSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { attachOursClient, } from '@ours.network/sdk/client';
5
- import { readDaemonConfig, resolveEndpoint } from './monitor.js';
6
5
  import { readClientProfile } from './client-profile.js';
7
6
  import { replaceFileAtomically } from './atomic-file.js';
8
7
  export const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
@@ -39,129 +38,25 @@ function startupProgress(value) {
39
38
  return undefined;
40
39
  return row;
41
40
  }
42
- function canonical(path, deps) {
43
- const absolute = resolve(path);
44
- return deps.canonicalize?.(absolute) ?? realpathSync.native(absolute);
45
- }
46
- /**
47
- * Corroborate the loopback daemon's unauthenticated `/info`, its credentialed
48
- * identity-index readiness route, and its local boot-generation record. The
49
- * index enforces auth when the daemon visibility requires it; open visibility
50
- * deliberately does not. No source is sufficient
51
- * alone: `/info` has no boot id, the identity index has no generation, and a
52
- * stale `ready` file can outlive the process that wrote it.
53
- */
41
+ /** Read generation and readiness through the selected gateway. Server paths are opaque. */
54
42
  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);
58
- const endpoint = resolveEndpoint(env);
59
- let response;
60
- try {
61
- response = await fetch(`${endpoint.origin}/info`, { headers: endpoint.headers });
62
- }
63
- catch {
64
- return { state: 'unavailable', reason: 'DAEMON_INFO_UNREACHABLE' };
65
- }
66
- if (!response.ok)
67
- return {
68
- state: 'unavailable',
69
- reason: response.status === 401 ? 'DAEMON_INFO_UNAUTHORIZED' : 'DAEMON_INFO_HTTP_ERROR',
70
- };
71
- let info;
72
- try {
73
- const value = await response.json();
74
- if (!value || typeof value !== 'object' || Array.isArray(value))
75
- throw new Error('shape');
76
- info = value;
77
- }
78
- catch {
79
- return { state: 'unavailable', reason: 'DAEMON_INFO_INVALID' };
80
- }
81
- if (info.name !== 'ours' || !positiveInteger(info.pid)
82
- || typeof info.stateDir !== 'string' || info.stateDir.length === 0)
83
- return { state: 'unavailable', reason: 'DAEMON_INFO_INVALID' };
84
- let reportedStateDir;
85
- let expectedStateDir;
86
- try {
87
- reportedStateDir = canonical(info.stateDir, deps);
88
- expectedStateDir = canonical(endpoint.stateDir, deps);
89
- }
90
- catch {
91
- return { state: 'unavailable', reason: 'DAEMON_STATE_DIR_UNAVAILABLE' };
92
- }
93
- if (reportedStateDir !== expectedStateDir)
94
- return { state: 'unavailable', reason: 'DAEMON_STATE_DIR_MISMATCH' };
95
- // `/identities` is the daemon's credential-capable readiness route. A
96
- // valid-but-empty index is a documented intermediate startup state, never
97
- // proof of readiness.
98
- let identitiesResponse;
99
- try {
100
- identitiesResponse = await fetch(`${endpoint.origin}/identities`, { headers: endpoint.headers });
101
- }
102
- catch {
103
- return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_UNREACHABLE' };
104
- }
105
- if (!identitiesResponse.ok)
106
- return {
107
- state: 'unavailable',
108
- reason: identitiesResponse.status === 401
109
- ? 'DAEMON_IDENTITIES_UNAUTHORIZED' : 'DAEMON_IDENTITIES_HTTP_ERROR',
110
- };
111
- try {
112
- const body = await identitiesResponse.json();
113
- if (!body || typeof body !== 'object' || Array.isArray(body)
114
- || !Array.isArray(body.identities))
115
- return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_INVALID' };
116
- if (body.identities.length === 0)
117
- return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_NOT_READY' };
118
- }
119
- catch {
120
- return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_INVALID' };
121
- }
122
- let progress;
123
43
  try {
124
- const text = deps.readText?.(join(reportedStateDir, 'startup-progress.json'))
125
- ?? readFileSync(join(reportedStateDir, 'startup-progress.json'), 'utf8');
126
- progress = startupProgress(JSON.parse(text));
44
+ return await probeSelectedDaemon(deps, readClientProfile(env));
127
45
  }
128
46
  catch {
129
- return { state: 'unavailable', reason: 'DAEMON_PROGRESS_UNAVAILABLE' };
47
+ return { state: 'unavailable', reason: 'CLIENT_GATEWAY_PROFILE_INVALID' };
130
48
  }
131
- if (!progress)
132
- return { state: 'unavailable', reason: 'DAEMON_PROGRESS_INVALID' };
133
- if (progress.phase !== 'ready')
134
- return { state: 'unavailable', reason: 'DAEMON_PROGRESS_NOT_READY' };
135
- if (progress.pid !== info.pid)
136
- return { state: 'unavailable', reason: 'DAEMON_GENERATION_MISMATCH' };
137
- return {
138
- state: 'ready',
139
- generation: {
140
- // Keep bootId opaque. Its current writer uses `${pid}-${startedAt}`, but
141
- // equality—not its formatting—is the generation contract Fleet needs.
142
- bootId: progress.bootId,
143
- pid: progress.pid,
144
- startedAt: progress.startedAt,
145
- stateDir: reportedStateDir,
146
- },
147
- };
148
49
  }
149
50
  /** Container selection uses public SDK reads; daemon paths/PIDs remain opaque metadata. */
150
- async function probeSelectedDaemon(env, deps, profile) {
51
+ async function probeSelectedDaemon(deps, profile) {
151
52
  // Defer credential-file access to SDK selection, which checks the configured
152
53
  // 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
54
  let client;
156
55
  try {
157
- const options = profile ? {
56
+ const options = {
158
57
  endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
159
58
  credentialPath: profile.credentialPath,
160
59
  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
60
  };
166
61
  client = await (deps.attachClient?.(options) ?? attachOursClient(options));
167
62
  const info = await client.version({ startup: true });
@@ -189,7 +84,7 @@ async function probeSelectedDaemon(env, deps, profile) {
189
84
  return { state: 'unavailable', reason: 'DAEMON_SELECTED_PROBE_UNAVAILABLE' };
190
85
  }
191
86
  finally {
192
- await client?.close();
87
+ await client?.close().catch(() => undefined);
193
88
  }
194
89
  }
195
90
  export class DaemonGenerationObserver {
package/dist/doctor.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type Exec } from './exec.js';
3
3
  import type { YamlMode } from './config-yaml.js';
4
4
  import { type FetchLike } from './monitor.js';
5
5
  import type { PrereqReport } from './harness/types.js';
6
- type DoctorDaemonClient = Pick<OursClient, 'version' | 'close'>;
6
+ type DoctorDaemonClient = Pick<OursClient, 'version' | 'close'> & Partial<Pick<OursClient, 'identities'>>;
7
7
  type AttachDoctorDaemon = (options: AttachOursClientOptions) => Promise<DoctorDaemonClient>;
8
8
  /** Host-level + per-harness prerequisite report with actionable messages. */
9
9
  export declare function doctor(opts?: {
package/dist/doctor.js CHANGED
@@ -1,5 +1,4 @@
1
- import { join } from 'node:path';
2
- import { userInfo, homedir } from 'node:os';
1
+ import { userInfo } from 'node:os';
3
2
  import { existsSync, readFileSync } from 'node:fs';
4
3
  import { attachOursClient, } from '@ours.network/sdk/client';
5
4
  import { realExec } from './exec.js';
@@ -11,7 +10,6 @@ import { agentDir, deriveXdgRuntimeDir } from './paths.js';
11
10
  import { resolveIsolation, harnessRuntimeDir } from './isolation/policy.js';
12
11
  import { hermesConfiguredProvider } from './harness/hermes-startup.js';
13
12
  import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
14
- import { authResolutionHint, resolveEndpoint, } from './monitor.js';
15
13
  import { readClientProfile } from './client-profile.js';
16
14
  import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
17
15
  import { controlSocketPath } from './session/control.js';
@@ -37,25 +35,21 @@ function resolveMonitorProfiles(roles) {
37
35
  const profiles = [];
38
36
  for (const role of roles.filter(r => r.monitor?.mode === 'fleet')) {
39
37
  const env = { ...process.env, ...(role.env ?? {}) };
40
- const client = readClientProfile(env);
41
- if (client) {
42
- const existing = profiles.find(p => p.client?.endpoint === client.endpoint
43
- && p.client.expectedInstanceId === client.expectedInstanceId
44
- && p.client.credentialPath === client.credentialPath);
45
- if (existing)
46
- existing.roles.push(role.name);
47
- else
48
- profiles.push({ client, roles: [role.name] });
38
+ let client;
39
+ try {
40
+ client = readClientProfile(env);
41
+ }
42
+ catch (error) {
43
+ profiles.push({ roles: [role.name], error: error instanceof Error ? error.message : 'Invalid gateway profile' });
49
44
  continue;
50
45
  }
51
- const endpoint = resolveEndpoint(env);
52
- const token = endpoint.headers['x-ours-api-token'];
53
- const existing = profiles.find(p => p.endpoint?.origin === endpoint.origin
54
- && p.endpoint.headers['x-ours-api-token'] === token);
46
+ const existing = profiles.find(p => p.client?.endpoint === client.endpoint
47
+ && p.client.expectedInstanceId === client.expectedInstanceId
48
+ && p.client.credentialPath === client.credentialPath);
55
49
  if (existing)
56
50
  existing.roles.push(role.name);
57
51
  else
58
- profiles.push({ endpoint, roles: [role.name] });
52
+ profiles.push({ client, roles: [role.name] });
59
53
  }
60
54
  return profiles;
61
55
  }
@@ -156,7 +150,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
156
150
  // ── Rooms/tasks checks ────────────────────────────────────────
157
151
  if (loaded.ok && loaded.rooms) {
158
152
  const rooms = loaded.rooms;
159
- // Cowork socket reachability
153
+ // Cowork gateway reachability
160
154
  const coworkConfig = rooms.cowork?.config;
161
155
  try {
162
156
  const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
@@ -165,14 +159,14 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
165
159
  checks.push({
166
160
  name: 'cowork', ok: reachable,
167
161
  detail: reachable
168
- ? 'management socket reachable'
169
- : `management socket unreachable${coworkConfig ? ` (config: ${coworkConfig})` : ''}`,
162
+ ? 'gateway management reachable'
163
+ : `gateway management unreachable${coworkConfig ? ` (config: ${coworkConfig})` : ''}`,
170
164
  });
171
165
  }
172
166
  catch (e) {
173
167
  checks.push({
174
168
  name: 'cowork', ok: false,
175
- detail: `management socket error${coworkConfig ? ` (config: ${coworkConfig})` : ''}: ${e?.message ?? e}`,
169
+ detail: `gateway management error${coworkConfig ? ` (config: ${coworkConfig})` : ''}: ${e?.message ?? e}`,
176
170
  });
177
171
  }
178
172
  // Owner CID shape
@@ -219,17 +213,6 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
219
213
  : `template '${defaultTemplate}' not found`,
220
214
  });
221
215
  }
222
- // Shared-daemon selection coherence
223
- if (rooms.cowork?.config) {
224
- const { existsSync: exists } = await import('node:fs');
225
- const configOk = exists(rooms.cowork.config);
226
- checks.push({
227
- name: 'rooms: cowork config', ok: configOk,
228
- detail: configOk
229
- ? `cowork config at ${rooms.cowork.config}`
230
- : `cowork config not found: ${rooms.cowork.config}`,
231
- });
232
- }
233
216
  // Hard room-management capability check
234
217
  try {
235
218
  const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
@@ -273,20 +256,17 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
273
256
  }
274
257
  catch { /* state dir may not exist yet */ }
275
258
  }
276
- let managedSelection = false;
259
+ let managedSelection = true;
277
260
  try {
278
261
  // Mark explicit selection before validation so a damaged managed profile does
279
262
  // not produce advice to start an unrelated local daemon.
280
- managedSelection = process.env.OURS_CONFIG !== undefined
281
- || existsSync(join(process.env.HOME || homedir(), '.ours-client', 'profile.json'));
263
+ managedSelection = true;
282
264
  const selected = readClientProfile(process.env);
283
265
  managedSelection = selected !== undefined;
284
- const client = await attachDaemon(selected ? {
266
+ const client = await attachDaemon({
285
267
  endpoint: selected.endpoint, expectedInstanceId: selected.expectedInstanceId,
286
268
  credentialPath: selected.credentialPath, env: {}, sessionMode: 'external',
287
269
  leaseToken: `ours-fleet-doctor-${process.pid}`,
288
- } : {
289
- env: process.env, leaseToken: `ours-fleet-doctor-${process.pid}`, clientPid: process.pid,
290
270
  });
291
271
  let daemonVersion;
292
272
  try {
@@ -448,14 +428,18 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
448
428
  // shared-mode misconfig (401) surfaces here rather than as a silent deaf monitor.
449
429
  const monitorProfiles = resolveMonitorProfiles(roles);
450
430
  for (const profile of monitorProfiles) {
451
- const { endpoint, client: selected } = profile;
431
+ const selected = profile.client;
452
432
  const checkName = monitorProfiles.length === 1
453
433
  ? 'monitor: daemon API'
454
434
  : `monitor: daemon API (${profile.roles.join(', ')})`;
455
- let ok = false, detail;
435
+ let ok = false, detail = 'gateway profile unavailable';
436
+ if (profile.error) {
437
+ checks.push({ name: checkName, ok: false, detail: profile.error });
438
+ continue;
439
+ }
456
440
  try {
457
441
  if (selected) {
458
- const daemon = await attachOursClient({
442
+ const daemon = await attachDaemon({
459
443
  endpoint: selected.endpoint,
460
444
  expectedInstanceId: selected.expectedInstanceId,
461
445
  credentialPath: selected.credentialPath,
@@ -464,7 +448,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
464
448
  });
465
449
  try {
466
450
  const [info, identities] = await Promise.all([
467
- daemon.version(), daemon.identities(),
451
+ daemon.version(), daemon.identities?.(),
468
452
  ]);
469
453
  if (info.name !== 'ours' || !Array.isArray(identities))
470
454
  throw new Error('selected daemon metadata is invalid');
@@ -477,27 +461,11 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
477
461
  checks.push({ name: checkName, ok, detail });
478
462
  continue;
479
463
  }
480
- const live = await fetchImpl(`${endpoint.origin}/state-dir`, {});
481
- if (!live.ok) {
482
- detail = `daemon on :${endpoint.port} answered /state-dir with HTTP ${live.status} — not the ours daemon?`;
483
- }
484
- else {
485
- const auth = await fetchImpl(`${endpoint.origin}/identities`, { headers: endpoint.headers });
486
- if (auth.status === 401)
487
- detail = `reachable on :${endpoint.port} but the API token was rejected (401) — ` +
488
- authResolutionHint(endpoint);
489
- else if (!auth.ok)
490
- detail = `reachable on :${endpoint.port} but /identities returned HTTP ${auth.status}`;
491
- else {
492
- ok = true;
493
- detail = `reachable on :${endpoint.port}, authorized — supervisor wake stream available`;
494
- }
495
- }
496
464
  }
497
465
  catch (e) {
498
- const target = selected?.endpoint ?? `:${endpoint.port}`;
466
+ const target = selected.endpoint;
499
467
  detail = `unreachable on ${target} — monitored roles run degraded until it is up ` +
500
- `(start it: ours-daemon start) [${e?.message ?? e}]`;
468
+ `(check the configured gateway and issued credential) [${e?.message ?? e}]`;
501
469
  }
502
470
  checks.push({ name: checkName, ok, detail });
503
471
  }