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