@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 +4 -3
- package/dist/agent-ours/service.js +10 -3
- package/dist/application/supervisor-ours-tools.js +2 -10
- package/dist/build-info.json +4 -4
- package/dist/client-profile.d.ts +5 -17
- package/dist/client-profile.js +4 -127
- package/dist/creation.js +2 -2
- package/dist/daemon-recovery.d.ts +1 -8
- package/dist/daemon-recovery.js +8 -113
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +28 -60
- package/dist/monitor.d.ts +1 -34
- package/dist/monitor.js +24 -151
- package/dist/owner-channel/ours-client.js +2 -17
- package/dist/rooms-tasks/close.js +1 -5
- package/dist/rooms-tasks/config.js +3 -1
- package/dist/rooms-tasks/cowork-adapter.d.ts +2 -4
- package/dist/rooms-tasks/cowork-adapter.js +7 -121
- package/dist/rooms-tasks/external-worker.js +0 -1
- package/dist/runner.d.ts +2 -1
- package/dist/runner.js +1 -1
- package/dist/supervisor/launchd.js +2 -1
- package/dist/supervisor/systemd.js +4 -23
- package/package.json +5 -5
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 | `
|
|
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
|
-
|
|
1352
|
-
|
|
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: '
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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');
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.16",
|
|
3
|
+
"buildId": "165e9b4f7cc7",
|
|
4
|
+
"commit": "6f621eee7791b35ceb6ec72ce7260e288f718002",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-26T19:18:02.562Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"cowork.http-management-v1",
|
|
9
9
|
"monitor.interrupt.after_tool"
|
package/dist/client-profile.d.ts
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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;
|
package/dist/client-profile.js
CHANGED
|
@@ -1,130 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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(
|
|
216
|
+
return attachClient({
|
|
217
217
|
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
218
218
|
credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken, env: {},
|
|
219
|
-
}
|
|
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?;
|
package/dist/daemon-recovery.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { readFileSync
|
|
2
|
-
import { join
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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(
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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({
|
|
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
|
|
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
|
|
169
|
-
: `management
|
|
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
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
466
|
+
const target = selected.endpoint;
|
|
499
467
|
detail = `unreachable on ${target} — monitored roles run degraded until it is up ` +
|
|
500
|
-
`(
|
|
468
|
+
`(check the configured gateway and issued credential) [${e?.message ?? e}]`;
|
|
501
469
|
}
|
|
502
470
|
checks.push({ name: checkName, ok, detail });
|
|
503
471
|
}
|