@ours.network/fleet 1.2.0-nightly.15 → 1.2.0-nightly.17
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/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) {
|
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.17",
|
|
3
|
+
"buildId": "165e9b4f7cc7",
|
|
4
|
+
"commit": "e720586890943d424840f0f9b96d0ac5051d8a6c",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-26T21:55:24.104Z",
|
|
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
|
}
|
package/dist/monitor.d.ts
CHANGED
|
@@ -88,33 +88,6 @@ export interface MonitorDeps {
|
|
|
88
88
|
* turns they trigger keep dying.
|
|
89
89
|
*/
|
|
90
90
|
export type StatusCause = 'connectivity' | 'delivery' | 'modal' | 'offline' | 'turns-failing' | 'safe-boundary' | 'auth';
|
|
91
|
-
/** Best-effort daemon config (issue #17): the fields the MCP client reads. */
|
|
92
|
-
interface DaemonConfig {
|
|
93
|
-
apiToken?: string;
|
|
94
|
-
port?: number;
|
|
95
|
-
stateDir?: string;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Read the daemon config the way the MCP client does — best-effort. Any missing,
|
|
99
|
-
* malformed, or unreadable config yields `{}` so token resolution falls through
|
|
100
|
-
* (issue #17). Only the well-typed fields we consume are surfaced.
|
|
101
|
-
*/
|
|
102
|
-
export declare function readDaemonConfig(env: NodeJS.ProcessEnv): DaemonConfig;
|
|
103
|
-
/**
|
|
104
|
-
* Resolve the daemon API token exactly like the MCP client (issue #17), a 3-step
|
|
105
|
-
* chain: `OURS_API_TOKEN` (trimmed) → config `apiToken` (trimmed) → the 0600 owner
|
|
106
|
-
* token at `<stateDir>/daemon-token`. Never generates a token; a failed read of
|
|
107
|
-
* any source (missing/unreadable) silently falls through to the next.
|
|
108
|
-
*/
|
|
109
|
-
export declare function resolveApiToken(env: NodeJS.ProcessEnv, file?: DaemonConfig): string | undefined;
|
|
110
|
-
export interface DaemonEndpoint {
|
|
111
|
-
origin: string;
|
|
112
|
-
port: number;
|
|
113
|
-
configPath: string;
|
|
114
|
-
stateDir: string;
|
|
115
|
-
url(name: string): string;
|
|
116
|
-
headers: Record<string, string>;
|
|
117
|
-
}
|
|
118
91
|
export type IdentityPresence = {
|
|
119
92
|
state: 'present';
|
|
120
93
|
temporary: boolean;
|
|
@@ -125,16 +98,12 @@ export type IdentityPresence = {
|
|
|
125
98
|
state: 'unknown';
|
|
126
99
|
detail: string;
|
|
127
100
|
};
|
|
128
|
-
/** Resolve the daemon endpoint + auth header from env → config → defaults. */
|
|
129
|
-
export declare function resolveEndpoint(env: NodeJS.ProcessEnv, includeToken?: boolean): DaemonEndpoint;
|
|
130
101
|
/**
|
|
131
102
|
* Ask the daemon's authoritative identity index. The notifications endpoint is
|
|
132
103
|
* intentionally unsuitable for lifecycle: it serves an empty 200 page for a
|
|
133
104
|
* valid but missing identity, which made a closed temp identity look healthy.
|
|
134
105
|
*/
|
|
135
106
|
export declare function probeIdentityPresence(name: string, fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: IdentityProbeDeps): Promise<IdentityPresence>;
|
|
136
|
-
/** Actionable, secret-free description of every token source for this profile. */
|
|
137
|
-
export declare function authResolutionHint(ep: DaemonEndpoint): string;
|
|
138
107
|
/** Keep only the events whose type the role asked to wake on. */
|
|
139
108
|
export declare function filterEvents(events: NotifyEvent[], wakeSources: string[]): NotifyEvent[];
|
|
140
109
|
/**
|
|
@@ -196,8 +165,7 @@ export declare class Monitor {
|
|
|
196
165
|
private readonly identity;
|
|
197
166
|
private readonly cfg;
|
|
198
167
|
private readonly deps;
|
|
199
|
-
private readonly
|
|
200
|
-
private readonly profile?;
|
|
168
|
+
private readonly profile;
|
|
201
169
|
private readonly profileKey;
|
|
202
170
|
private readonly monitorLeaseToken;
|
|
203
171
|
private profileClientPromise?;
|
|
@@ -263,4 +231,3 @@ export declare class Monitor {
|
|
|
263
231
|
private writeStatus;
|
|
264
232
|
}
|
|
265
233
|
export declare function createMonitor(o: MonitorOpts): Monitor;
|
|
266
|
-
export {};
|
package/dist/monitor.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { attachOursClient } from '@ours.network/sdk/client';
|
|
6
5
|
import { clientProfileKey, readClientProfile, } from './client-profile.js';
|
|
@@ -42,124 +41,28 @@ const TURN_OBSERVE_INTERVAL_MS = 1_500;
|
|
|
42
41
|
const DEFAULT_TURN_FAIL_THRESHOLD = 3; // fallback when the resolved config omits it
|
|
43
42
|
class AuthError extends Error {
|
|
44
43
|
}
|
|
45
|
-
/** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
|
|
46
|
-
const daemonConfigPath = (env) => env.OURS_CONFIG ?? join(homedir(), '.ours', 'config.json');
|
|
47
|
-
/** Preserve the daemon's legacy env integer semantics: parseInt, invalid → absent. */
|
|
48
|
-
function envInt(env, name) {
|
|
49
|
-
const raw = env[name];
|
|
50
|
-
if (raw === undefined)
|
|
51
|
-
return undefined;
|
|
52
|
-
const n = parseInt(raw, 10);
|
|
53
|
-
return Number.isNaN(n) ? undefined : n;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Read the daemon config the way the MCP client does — best-effort. Any missing,
|
|
57
|
-
* malformed, or unreadable config yields `{}` so token resolution falls through
|
|
58
|
-
* (issue #17). Only the well-typed fields we consume are surfaced.
|
|
59
|
-
*/
|
|
60
|
-
export function readDaemonConfig(env) {
|
|
61
|
-
try {
|
|
62
|
-
const p = JSON.parse(readFileSync(daemonConfigPath(env), 'utf8'));
|
|
63
|
-
const o = {};
|
|
64
|
-
if (typeof p.apiToken === 'string' && p.apiToken.trim())
|
|
65
|
-
o.apiToken = p.apiToken.trim();
|
|
66
|
-
if (typeof p.port === 'number' && Number.isFinite(p.port))
|
|
67
|
-
o.port = p.port;
|
|
68
|
-
if (typeof p.stateDir === 'string')
|
|
69
|
-
o.stateDir = p.stateDir;
|
|
70
|
-
return o;
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return {};
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Resolve the daemon API token exactly like the MCP client (issue #17), a 3-step
|
|
78
|
-
* chain: `OURS_API_TOKEN` (trimmed) → config `apiToken` (trimmed) → the 0600 owner
|
|
79
|
-
* token at `<stateDir>/daemon-token`. Never generates a token; a failed read of
|
|
80
|
-
* any source (missing/unreadable) silently falls through to the next.
|
|
81
|
-
*/
|
|
82
|
-
export function resolveApiToken(env, file = readDaemonConfig(env)) {
|
|
83
|
-
const e = env.OURS_API_TOKEN?.trim();
|
|
84
|
-
if (e)
|
|
85
|
-
return e;
|
|
86
|
-
if (file.apiToken)
|
|
87
|
-
return file.apiToken;
|
|
88
|
-
const sd = env.OURS_STATE_DIR ?? file.stateDir ?? join(homedir(), '.ours');
|
|
89
|
-
try {
|
|
90
|
-
const t = readFileSync(join(sd, 'daemon-token'), 'utf8').trim();
|
|
91
|
-
if (t)
|
|
92
|
-
return t;
|
|
93
|
-
}
|
|
94
|
-
catch { /* missing/unreadable (e.g. cross-user 0600) → fall through */ }
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
/** Resolve the daemon endpoint + auth header from env → config → defaults. */
|
|
98
|
-
export function resolveEndpoint(env, includeToken = true) {
|
|
99
|
-
const file = readDaemonConfig(env);
|
|
100
|
-
const port = envInt(env, 'OURS_PORT') ?? file.port ?? DEFAULT_PORT;
|
|
101
|
-
const configPath = daemonConfigPath(env);
|
|
102
|
-
const stateDir = env.OURS_STATE_DIR ?? file.stateDir ?? join(homedir(), '.ours');
|
|
103
|
-
const token = includeToken ? resolveApiToken(env, file) : undefined;
|
|
104
|
-
const origin = `http://127.0.0.1:${port}`;
|
|
105
|
-
return {
|
|
106
|
-
origin,
|
|
107
|
-
port,
|
|
108
|
-
configPath,
|
|
109
|
-
stateDir,
|
|
110
|
-
url: (name) => `${origin}/identities/${encodeURIComponent(name)}/notifications`,
|
|
111
|
-
headers: token ? { 'x-ours-api-token': token } : {},
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
44
|
/**
|
|
115
45
|
* Ask the daemon's authoritative identity index. The notifications endpoint is
|
|
116
46
|
* intentionally unsuitable for lifecycle: it serves an empty 200 page for a
|
|
117
47
|
* valid but missing identity, which made a closed temp identity look healthy.
|
|
118
48
|
*/
|
|
119
49
|
export async function probeIdentityPresence(name, fetch, env, deps = {}) {
|
|
120
|
-
|
|
121
|
-
if (profile) {
|
|
122
|
-
let client;
|
|
123
|
-
try {
|
|
124
|
-
client = await (deps.attachClient?.({
|
|
125
|
-
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
126
|
-
credentialPath: profile.credentialPath,
|
|
127
|
-
sessionMode: 'external', leaseToken: randomUUID(), env: {},
|
|
128
|
-
}) ?? attachOursClient({
|
|
129
|
-
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
130
|
-
credentialPath: profile.credentialPath,
|
|
131
|
-
sessionMode: 'external', leaseToken: randomUUID(), env: {},
|
|
132
|
-
}));
|
|
133
|
-
return classifyIdentityPresence(name, await client.identities());
|
|
134
|
-
}
|
|
135
|
-
catch {
|
|
136
|
-
return { state: 'unknown', detail: 'selected identity index is unavailable' };
|
|
137
|
-
}
|
|
138
|
-
finally {
|
|
139
|
-
await client?.close().catch(() => undefined);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
const ep = resolveEndpoint(env);
|
|
143
|
-
let response;
|
|
50
|
+
let client;
|
|
144
51
|
try {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (!response.ok)
|
|
151
|
-
return {
|
|
152
|
-
state: 'unknown',
|
|
153
|
-
detail: response.status === 401
|
|
154
|
-
? `daemon rejected the API token (401) — ${authResolutionHint(ep)}`
|
|
155
|
-
: `identity index returned HTTP ${response.status}`,
|
|
52
|
+
const profile = readClientProfile(env);
|
|
53
|
+
const options = {
|
|
54
|
+
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
55
|
+
credentialPath: profile.credentialPath,
|
|
56
|
+
sessionMode: 'external', leaseToken: randomUUID(), env: {},
|
|
156
57
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return classifyIdentityPresence(name, body.identities);
|
|
58
|
+
client = await (deps.attachClient?.(options) ?? attachOursClient(options));
|
|
59
|
+
return classifyIdentityPresence(name, await client.identities());
|
|
160
60
|
}
|
|
161
|
-
catch
|
|
162
|
-
return { state: 'unknown', detail:
|
|
61
|
+
catch {
|
|
62
|
+
return { state: 'unknown', detail: 'gateway identity index unavailable; check the shared client profile and issued credential' };
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await client?.close().catch(() => undefined);
|
|
163
66
|
}
|
|
164
67
|
}
|
|
165
68
|
function classifyIdentityPresence(name, identities) {
|
|
@@ -186,12 +89,6 @@ function classifyIdentityPresence(name, identities) {
|
|
|
186
89
|
stale: found.stale === true,
|
|
187
90
|
};
|
|
188
91
|
}
|
|
189
|
-
/** Actionable, secret-free description of every token source for this profile. */
|
|
190
|
-
export function authResolutionHint(ep) {
|
|
191
|
-
const tokenPath = join(ep.stateDir, 'daemon-token');
|
|
192
|
-
return `set OURS_API_TOKEN, set apiToken in ${JSON.stringify(ep.configPath)}, or ensure ` +
|
|
193
|
-
`${JSON.stringify(tokenPath)} is readable by the fleet supervisor`;
|
|
194
|
-
}
|
|
195
92
|
/** Keep only the events whose type the role asked to wake on. */
|
|
196
93
|
export function filterEvents(events, wakeSources) {
|
|
197
94
|
const set = new Set(wakeSources);
|
|
@@ -414,7 +311,6 @@ export class Monitor {
|
|
|
414
311
|
identity;
|
|
415
312
|
cfg;
|
|
416
313
|
deps;
|
|
417
|
-
ep;
|
|
418
314
|
profile;
|
|
419
315
|
profileKey;
|
|
420
316
|
monitorLeaseToken = `ours-fleet-monitor-${process.pid}-${randomUUID()}`;
|
|
@@ -441,8 +337,7 @@ export class Monitor {
|
|
|
441
337
|
this.cfg = o.cfg;
|
|
442
338
|
this.deps = o.deps;
|
|
443
339
|
this.profile = readClientProfile(o.deps.env);
|
|
444
|
-
this.
|
|
445
|
-
this.profileKey = this.profile ? clientProfileKey(this.profile) : this.ep.origin;
|
|
340
|
+
this.profileKey = clientProfileKey(this.profile);
|
|
446
341
|
this.statusPath = join(o.agentDir, '.monitor-status');
|
|
447
342
|
this.cursorPath = join(o.agentDir, '.notify-cursor');
|
|
448
343
|
this.statePath = join(o.agentDir, '.monitor-state.json');
|
|
@@ -470,14 +365,8 @@ export class Monitor {
|
|
|
470
365
|
}
|
|
471
366
|
catch (e) {
|
|
472
367
|
if (e instanceof AuthError) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
this.degrade('auth', e.message);
|
|
476
|
-
}
|
|
477
|
-
else {
|
|
478
|
-
this.fatal = true;
|
|
479
|
-
this.degrade('auth', e.message, 'failed');
|
|
480
|
-
}
|
|
368
|
+
this.cursor = null;
|
|
369
|
+
this.degrade('auth', e.message);
|
|
481
370
|
}
|
|
482
371
|
else {
|
|
483
372
|
this.cursor = null;
|
|
@@ -510,11 +399,6 @@ export class Monitor {
|
|
|
510
399
|
catch (e) {
|
|
511
400
|
if (this.stopped)
|
|
512
401
|
return;
|
|
513
|
-
if (e instanceof AuthError && !this.profile) {
|
|
514
|
-
this.fatal = true;
|
|
515
|
-
this.degrade('auth', e.message, 'failed');
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
402
|
backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
|
|
519
403
|
if (e instanceof AuthError)
|
|
520
404
|
this.degrade('auth', e.message);
|
|
@@ -630,23 +514,19 @@ export class Monitor {
|
|
|
630
514
|
this.currentAbort = ctrl;
|
|
631
515
|
let timedOut = false;
|
|
632
516
|
const timer = this.deps.timers.set(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
|
|
633
|
-
let resp;
|
|
634
517
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
return { cursor: page.cursor, events: page.events };
|
|
643
|
-
}
|
|
644
|
-
resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
|
|
518
|
+
const client = await this.profileClient();
|
|
519
|
+
const page = await client.readNotificationPage(this.identity, {
|
|
520
|
+
since: since === 'tip' ? 'tip' : Number.parseInt(since, 10),
|
|
521
|
+
signal: ctrl.signal,
|
|
522
|
+
requestTimeoutMs: timeoutMs,
|
|
523
|
+
});
|
|
524
|
+
return { cursor: page.cursor, events: page.events };
|
|
645
525
|
}
|
|
646
526
|
catch (error) {
|
|
647
527
|
if (timedOut && timeoutKind === 'stall')
|
|
648
528
|
throw new Error(`notification stream stalled for ${Math.round(timeoutMs / 1000)}s`);
|
|
649
|
-
if (
|
|
529
|
+
if (/(?:HTTP\s*401|unauthori[sz]ed|API token)/i.test(msg(error)))
|
|
650
530
|
throw new AuthError(`daemon rejected the API token (401) — verify the credential file `
|
|
651
531
|
+ `${JSON.stringify(this.profile.credentialPath)} selected by `
|
|
652
532
|
+ `${JSON.stringify(this.profile.configPath)}`);
|
|
@@ -656,15 +536,8 @@ export class Monitor {
|
|
|
656
536
|
this.deps.timers.clear(timer);
|
|
657
537
|
this.currentAbort = null;
|
|
658
538
|
}
|
|
659
|
-
if (resp.status === 401)
|
|
660
|
-
throw new AuthError(`daemon rejected the API token (401) — ${authResolutionHint(this.ep)}`);
|
|
661
|
-
if (!resp.ok)
|
|
662
|
-
throw new Error(`daemon returned HTTP ${resp.status}`);
|
|
663
|
-
return resp.json();
|
|
664
539
|
}
|
|
665
540
|
profileClient() {
|
|
666
|
-
if (!this.profile)
|
|
667
|
-
throw new Error('explicit client profile is not selected');
|
|
668
541
|
if (!this.profileClientPromise) {
|
|
669
542
|
const { endpoint, expectedInstanceId, credentialPath } = this.profile;
|
|
670
543
|
const options = {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { extname
|
|
4
|
-
import { readDaemonConfig, resolveEndpoint } from '../monitor.js';
|
|
3
|
+
import { extname } from 'node:path';
|
|
5
4
|
import { readClientProfile } from '../client-profile.js';
|
|
6
5
|
import { OursError, attachOursClient, } from '@ours.network/sdk/client';
|
|
7
6
|
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
@@ -131,24 +130,10 @@ export class OursSdkClient {
|
|
|
131
130
|
return;
|
|
132
131
|
const environment = { ...process.env, ...this.env };
|
|
133
132
|
const profile = readClientProfile(environment);
|
|
134
|
-
|
|
135
|
-
// daemon capability before reading the client's protected token delivery file.
|
|
136
|
-
const endpoint = !profile && environment.OURS_DAEMON_ID
|
|
137
|
-
? resolveEndpoint(environment, false) : undefined;
|
|
138
|
-
const token = endpoint ? environment.OURS_API_TOKEN?.trim() || readDaemonConfig(environment).apiToken : undefined;
|
|
139
|
-
const options = profile ? {
|
|
133
|
+
const options = {
|
|
140
134
|
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
141
135
|
credentialPath: profile.credentialPath,
|
|
142
136
|
sessionMode: 'external', leaseToken: this.leaseToken, env: {},
|
|
143
|
-
} : endpoint ? {
|
|
144
|
-
endpoint: endpoint.origin, expectedInstanceId: environment.OURS_DAEMON_ID,
|
|
145
|
-
sessionMode: 'external', leaseToken: this.leaseToken,
|
|
146
|
-
...(token ? { token } : { credentialPath: join(endpoint.stateDir, 'daemon-token') }),
|
|
147
|
-
} : {
|
|
148
|
-
env: environment,
|
|
149
|
-
leaseToken: this.leaseToken,
|
|
150
|
-
clientPid: process.pid,
|
|
151
|
-
fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
|
|
152
137
|
};
|
|
153
138
|
this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
|
|
154
139
|
this.terminalReleasePending = true;
|
|
@@ -77,15 +77,11 @@ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {})
|
|
|
77
77
|
async function withIdentityClient(work) {
|
|
78
78
|
const profile = readClientProfile(process.env);
|
|
79
79
|
const leaseToken = `ours-fleet-room-close-${process.pid}-${randomUUID()}`;
|
|
80
|
-
const client = await attachOursClient(
|
|
80
|
+
const client = await attachOursClient({
|
|
81
81
|
endpoint: profile.endpoint,
|
|
82
82
|
expectedInstanceId: profile.expectedInstanceId,
|
|
83
83
|
credentialPath: profile.credentialPath,
|
|
84
84
|
sessionMode: 'external', env: {}, leaseToken,
|
|
85
|
-
} : {
|
|
86
|
-
env: process.env,
|
|
87
|
-
leaseToken,
|
|
88
|
-
clientPid: process.pid,
|
|
89
85
|
});
|
|
90
86
|
try {
|
|
91
87
|
return await work(client);
|
|
@@ -54,7 +54,9 @@ export function validateRoomsConfig(raw, vars, path) {
|
|
|
54
54
|
if (!isPlainObject(raw.cowork))
|
|
55
55
|
throw new RoomsTasksConfigError(path, 'rooms.cowork: must be a mapping');
|
|
56
56
|
rejectUnknown(raw.cowork, RCK, path, 'rooms.cowork');
|
|
57
|
-
|
|
57
|
+
if (raw.cowork.config !== undefined)
|
|
58
|
+
throw new RoomsTasksConfigError(path, 'rooms.cowork.config is no longer supported; remove it and configure ~/.ours-client/profile.json for all services');
|
|
59
|
+
cowork = {};
|
|
58
60
|
}
|
|
59
61
|
if (!isPlainObject(raw.owner))
|
|
60
62
|
throw new RoomsTasksConfigError(path, 'rooms.owner: required and must be a mapping');
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Typed client for the ours-cowork v1
|
|
2
|
+
* Typed client for the ours-cowork v1 gateway management protocol.
|
|
3
3
|
*
|
|
4
4
|
* Cowork owns room state. Fleet only invokes the versioned JSON RPC exposed
|
|
5
|
-
*
|
|
5
|
+
* through the shared HTTP gateway and projects the response into orchestration data.
|
|
6
6
|
*/
|
|
7
7
|
import type { Socket } from 'node:net';
|
|
8
8
|
import type { RoomHistoryEvidence } from './types.js';
|
|
@@ -108,6 +108,4 @@ export interface CoworkAdapterOptions {
|
|
|
108
108
|
home?: string;
|
|
109
109
|
connect?: (path: string) => Socket;
|
|
110
110
|
}
|
|
111
|
-
/** Resolve the same config/state inputs as ours-cowork and return its Unix socket. */
|
|
112
|
-
export declare function resolveCoworkSocketPath(options?: CoworkAdapterOptions): string;
|
|
113
111
|
export declare function createCoworkAdapter(options?: CoworkAdapterOptions): CoworkAdapter;
|
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Typed client for the ours-cowork v1
|
|
2
|
+
* Typed client for the ours-cowork v1 gateway management protocol.
|
|
3
3
|
*
|
|
4
4
|
* Cowork owns room state. Fleet only invokes the versioned JSON RPC exposed
|
|
5
|
-
*
|
|
5
|
+
* through the shared HTTP gateway and projects the response into orchestration data.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
-
import { homedir } from 'node:os';
|
|
9
|
-
import { join, resolve } from 'node:path';
|
|
10
|
-
import { createConnection } from 'node:net';
|
|
11
7
|
import { readClientProfile } from '../client-profile.js';
|
|
12
8
|
import { coworkHttpCall } from './cowork-http.js';
|
|
13
|
-
const RPC_VERSION = 1;
|
|
14
9
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
15
|
-
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
16
10
|
export class CoworkUnavailableError extends Error {
|
|
17
|
-
constructor(message = 'Cowork management
|
|
11
|
+
constructor(message = 'Cowork gateway management is not reachable', options) {
|
|
18
12
|
super(message, options);
|
|
19
13
|
this.name = 'CoworkUnavailableError';
|
|
20
14
|
}
|
|
@@ -171,121 +165,13 @@ function projectHistoryRecord(value) {
|
|
|
171
165
|
}
|
|
172
166
|
return undefined;
|
|
173
167
|
}
|
|
174
|
-
/** Resolve the same config/state inputs as ours-cowork and return its Unix socket. */
|
|
175
|
-
export function resolveCoworkSocketPath(options = {}) {
|
|
176
|
-
if (options.socketPath)
|
|
177
|
-
return resolve(options.socketPath);
|
|
178
|
-
const env = options.env ?? process.env;
|
|
179
|
-
const home = options.home ?? homedir();
|
|
180
|
-
const configuredPath = options.configPath ?? env.OURS_COWORK_CONFIG;
|
|
181
|
-
const configPath = resolve(configuredPath ?? join(home, '.ours-cowork', 'config.json'));
|
|
182
|
-
let stateDir = resolve(home, '.ours-cowork');
|
|
183
|
-
if (existsSync(configPath)) {
|
|
184
|
-
let parsed;
|
|
185
|
-
try {
|
|
186
|
-
parsed = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
187
|
-
}
|
|
188
|
-
catch (error) {
|
|
189
|
-
throw new CoworkProtocolError('config', `malformed config at ${configPath}`, 'invalid_config', { cause: error });
|
|
190
|
-
}
|
|
191
|
-
const config = object(parsed, 'config', 'config');
|
|
192
|
-
if (config.version !== 1 || typeof config.stateDir !== 'string' || !config.stateDir)
|
|
193
|
-
throw new CoworkProtocolError('config', `invalid Cowork v1 config at ${configPath}`, 'invalid_config');
|
|
194
|
-
stateDir = resolve(config.stateDir);
|
|
195
|
-
}
|
|
196
|
-
else if (configuredPath !== undefined) {
|
|
197
|
-
throw new CoworkProtocolError('config', `configured Cowork config does not exist: ${configPath}`, 'invalid_config');
|
|
198
|
-
}
|
|
199
|
-
if (env.OURS_COWORK_STATE_DIR)
|
|
200
|
-
stateDir = resolve(env.OURS_COWORK_STATE_DIR);
|
|
201
|
-
return join(stateDir, 'management.sock');
|
|
202
|
-
}
|
|
203
|
-
function rpcCall(socketPath, method, params, timeoutMs, connect) {
|
|
204
|
-
const id = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
205
|
-
const request = `${JSON.stringify({ version: RPC_VERSION, id, method, params })}\n`;
|
|
206
|
-
return new Promise((resolveCall, rejectCall) => {
|
|
207
|
-
const socket = connect(socketPath);
|
|
208
|
-
let bytes = '';
|
|
209
|
-
let size = 0;
|
|
210
|
-
let settled = false;
|
|
211
|
-
let connected = false;
|
|
212
|
-
const finishError = (error) => {
|
|
213
|
-
if (settled)
|
|
214
|
-
return;
|
|
215
|
-
settled = true;
|
|
216
|
-
clearTimeout(timer);
|
|
217
|
-
socket.destroy();
|
|
218
|
-
rejectCall(error);
|
|
219
|
-
};
|
|
220
|
-
const timer = setTimeout(() => finishError(new CoworkUnavailableError(connected ? 'Cowork did not answer the management socket' : 'Cowork management socket is not reachable')), timeoutMs);
|
|
221
|
-
socket.setEncoding('utf8');
|
|
222
|
-
socket.once('connect', () => {
|
|
223
|
-
connected = true;
|
|
224
|
-
socket.write(request);
|
|
225
|
-
});
|
|
226
|
-
socket.on('data', (chunk) => {
|
|
227
|
-
if (settled)
|
|
228
|
-
return;
|
|
229
|
-
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
230
|
-
size += Buffer.byteLength(text);
|
|
231
|
-
if (size > MAX_RESPONSE_BYTES) {
|
|
232
|
-
finishError(new CoworkProtocolError(method, 'response exceeded 4 MiB'));
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
bytes += text;
|
|
236
|
-
const newline = bytes.indexOf('\n');
|
|
237
|
-
if (newline < 0)
|
|
238
|
-
return;
|
|
239
|
-
if (bytes.slice(newline + 1).trim() !== '') {
|
|
240
|
-
finishError(new CoworkProtocolError(method, 'daemon returned more than one response'));
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
let response;
|
|
244
|
-
try {
|
|
245
|
-
response = object(JSON.parse(bytes.slice(0, newline)), method, 'RPC response');
|
|
246
|
-
}
|
|
247
|
-
catch (error) {
|
|
248
|
-
finishError(error instanceof CoworkProtocolError
|
|
249
|
-
? error : new CoworkProtocolError(method, 'daemon returned malformed JSON', 'protocol_error', { cause: error }));
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
if (response.version !== RPC_VERSION || response.id !== id
|
|
253
|
-
|| (Object.hasOwn(response, 'error') === Object.hasOwn(response, 'result'))) {
|
|
254
|
-
finishError(new CoworkProtocolError(method, 'daemon returned an invalid RPC response'));
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
settled = true;
|
|
258
|
-
clearTimeout(timer);
|
|
259
|
-
socket.destroy();
|
|
260
|
-
if (Object.hasOwn(response, 'error')) {
|
|
261
|
-
const error = object(response.error, method, 'RPC error');
|
|
262
|
-
rejectCall(new CoworkProtocolError(method, typeof error.message === 'string' ? error.message : 'unknown RPC error', typeof error.code === 'string' ? error.code : 'rpc_error'));
|
|
263
|
-
}
|
|
264
|
-
else {
|
|
265
|
-
resolveCall(response.result);
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
socket.once('end', () => {
|
|
269
|
-
if (!settled)
|
|
270
|
-
finishError(new CoworkProtocolError(method, 'daemon closed without a complete response'));
|
|
271
|
-
});
|
|
272
|
-
socket.once('error', (error) => {
|
|
273
|
-
if (!settled)
|
|
274
|
-
finishError(new CoworkUnavailableError(error.code === 'EACCES' || error.code === 'EPERM'
|
|
275
|
-
? 'Cowork management socket access denied' : 'Cowork management socket is not reachable', { cause: error }));
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
168
|
export function createCoworkAdapter(options = {}) {
|
|
280
169
|
const env = options.env ?? process.env;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const profile =
|
|
284
|
-
const remote = profile?.serverUrl ? profile : undefined;
|
|
285
|
-
const socketPath = remote ? undefined : resolveCoworkSocketPath(options);
|
|
170
|
+
if (options.socketPath !== undefined || options.configPath !== undefined)
|
|
171
|
+
throw new CoworkProtocolError('config', 'Local Cowork selectors are unsupported; configure the shared gateway client profile', 'invalid_config');
|
|
172
|
+
const profile = readClientProfile({ ...env, HOME: options.home ?? env.HOME });
|
|
286
173
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
287
|
-
const
|
|
288
|
-
const call = (method, params) => remote ? coworkHttpCall(remote, method, params, timeoutMs) : rpcCall(socketPath, method, params, timeoutMs, connect);
|
|
174
|
+
const call = (method, params) => coworkHttpCall(profile, method, params, timeoutMs);
|
|
289
175
|
return {
|
|
290
176
|
async available() {
|
|
291
177
|
try {
|
|
@@ -10,7 +10,6 @@ const LIFECYCLE_RETRY_MS = 250;
|
|
|
10
10
|
export function fleetWorkerEnv(source = process.env) {
|
|
11
11
|
const inheritedKeys = [
|
|
12
12
|
'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'OURS_CONFIG',
|
|
13
|
-
'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_COWORK_CONFIG',
|
|
14
13
|
];
|
|
15
14
|
const env = Object.fromEntries(inheritedKeys.flatMap(key => source[key] === undefined ? [] : [[key, source[key]]]));
|
|
16
15
|
const lifecycleStateDir = source[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV]
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { prepareManagedAgent, releaseManagedAgent } from './agent-ours/service.js';
|
|
2
2
|
import { type ResolvedRole } from './config.js';
|
|
3
|
-
import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
|
|
3
|
+
import { probeIdentityPresence, type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
|
|
4
4
|
import { type DaemonGenerationProbe } from './daemon-recovery.js';
|
|
5
5
|
import { type Exec } from './exec.js';
|
|
6
6
|
import { RoleControlServer } from './session/control.js';
|
|
@@ -20,6 +20,7 @@ export interface RunnerDeps {
|
|
|
20
20
|
log(line: string): void;
|
|
21
21
|
/** HTTP transport for the monitor's daemon long-poll (injectable for tests). */
|
|
22
22
|
fetch: FetchLike;
|
|
23
|
+
identityProbeDeps?: Parameters<typeof probeIdentityPresence>[3];
|
|
23
24
|
probeGeneration(env: NodeJS.ProcessEnv): Promise<DaemonGenerationProbe>;
|
|
24
25
|
/** Construct the supervisor mail monitor (injectable so tests stub it out). */
|
|
25
26
|
createMonitor(opts: MonitorOpts): MonitorHandle;
|
package/dist/runner.js
CHANGED
|
@@ -936,7 +936,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
936
936
|
}
|
|
937
937
|
if (temp && now >= nextIdentityPollAt) {
|
|
938
938
|
nextIdentityPollAt = now + TEMP_IDENTITY_POLL_MS;
|
|
939
|
-
const presence = await probeIdentityPresence(role.identity, deps.fetch, resolvedMonitorDeps.env);
|
|
939
|
+
const presence = await probeIdentityPresence(role.identity, deps.fetch, resolvedMonitorDeps.env, deps.identityProbeDeps);
|
|
940
940
|
if (presence.state === 'present') {
|
|
941
941
|
identityObserved = true;
|
|
942
942
|
identityAbsentSince = undefined;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clientConfigPath } from '../client-profile.js';
|
|
1
2
|
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { home, logsRoot } from '../paths.js';
|
|
@@ -22,7 +23,7 @@ const xml = (value) => value
|
|
|
22
23
|
.replaceAll("'", ''');
|
|
23
24
|
function plist(name, binPath) {
|
|
24
25
|
const log = join(logsRoot(), `${name}.log`);
|
|
25
|
-
const config = process.env
|
|
26
|
+
const config = clientConfigPath(process.env);
|
|
26
27
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
28
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
29
|
<plist version="1.0">
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clientConfigPath } from '../client-profile.js';
|
|
1
2
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { delimiter, dirname, join } from 'node:path';
|
|
3
4
|
import { userInfo } from 'node:os';
|
|
@@ -69,30 +70,10 @@ export function makeSystemdBackend(exec = realExec) {
|
|
|
69
70
|
dirname(process.execPath),
|
|
70
71
|
...(process.env.PATH ?? '').split(delimiter),
|
|
71
72
|
].filter(Boolean))].join(delimiter);
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// ours-fleet resolves its daemon from OURS_CONFIG / OURS_PORT / OURS_STATE_DIR
|
|
76
|
-
// and otherwise falls back to ~/.ours and port 3050 (src/monitor.ts). A host
|
|
77
|
-
// set up against a non-default daemon — the installer's multi-daemon profiles
|
|
78
|
-
// do exactly this — passes that selection to `ours-fleet init` in the
|
|
79
|
-
// environment, and it died there: nothing persisted it, so every runner
|
|
80
|
-
// systemd started at boot resolved the default daemon again, and on a host
|
|
81
|
-
// where only the non-default daemon exists that is a daemon that is not there.
|
|
82
|
-
//
|
|
83
|
-
// ONLY OURS_CONFIG is baked, deliberately. It names which daemon, and leaves
|
|
84
|
-
// that daemon's own config file authoritative for port and state directory, so
|
|
85
|
-
// a later edit to it still wins. Baking OURS_PORT/OURS_STATE_DIR would freeze
|
|
86
|
-
// those into a unit file that outranks the config for ever after — the failure
|
|
87
|
-
// mode @ours.network/cli avoids for the same reason (packages/cli/src/
|
|
88
|
-
// service.ts: "The port is deliberately NOT baked").
|
|
89
|
-
//
|
|
90
|
-
// Absent from init's environment ⇒ no line, and the unit is byte-for-byte what
|
|
91
|
-
// it has always been. This teaches fleet nothing about installer profiles; it
|
|
92
|
-
// persists the selection fleet was initialised with.
|
|
73
|
+
// Persist one profile path, never a URL or service port. Profile updates
|
|
74
|
+
// are picked up by every fresh process, including lingering user services.
|
|
93
75
|
const unitEnv = ['PATH=' + servicePath];
|
|
94
|
-
|
|
95
|
-
unitEnv.push('OURS_CONFIG=' + process.env.OURS_CONFIG);
|
|
76
|
+
unitEnv.push('OURS_CONFIG=' + clientConfigPath(process.env));
|
|
96
77
|
const environmentLines = unitEnv
|
|
97
78
|
.map(value => `Environment="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`)
|
|
98
79
|
.join('\n');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.17",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
43
43
|
"@fastify/static": "^10.1.2",
|
|
44
44
|
"@fastify/websocket": "^11.2.0",
|
|
45
|
-
"@ours.network/cli": "2.8.1-nightly.
|
|
46
|
-
"@ours.network/mcp": "1.2.0-nightly.
|
|
47
|
-
"@ours.network/sdk": "3.8.1-nightly.
|
|
45
|
+
"@ours.network/cli": "2.8.1-nightly.12",
|
|
46
|
+
"@ours.network/mcp": "1.2.0-nightly.9",
|
|
47
|
+
"@ours.network/sdk": "3.8.1-nightly.14",
|
|
48
48
|
"commander": "^12.1.0",
|
|
49
49
|
"fastify": "^5.4.0",
|
|
50
50
|
"react": "^19.1.1",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@agentclientprotocol/codex-acp": "1.10.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@ours.network/daemon": "3.8.1-nightly.
|
|
61
|
+
"@ours.network/daemon": "3.8.1-nightly.6",
|
|
62
62
|
"@playwright/test": "^1.54.1",
|
|
63
63
|
"@types/node": "^20.14.0",
|
|
64
64
|
"@types/react": "^19.1.9",
|