@ours.network/fleet 1.1.4 → 1.2.0-nightly.1
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 +137 -1
- package/dist/briefing.js +4 -3
- package/dist/build-info.json +5 -5
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +13 -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/docs.d.ts +1 -1
- package/dist/docs.js +23 -2
- package/dist/doctor.js +62 -6
- package/dist/harness/acp-mcp.d.ts +14 -0
- package/dist/harness/acp-mcp.js +68 -0
- package/dist/harness/claude-code.js +1 -69
- package/dist/harness/codex.js +4 -0
- package/dist/harness/hermes-compatibility.d.ts +24 -0
- package/dist/harness/hermes-compatibility.js +191 -0
- package/dist/harness/hermes-config.d.ts +12 -0
- package/dist/harness/hermes-config.js +367 -0
- package/dist/harness/hermes-permissions.d.ts +4 -0
- package/dist/harness/hermes-permissions.js +36 -0
- package/dist/harness/hermes-session.d.ts +24 -0
- package/dist/harness/hermes-session.js +85 -0
- package/dist/harness/hermes-startup.d.ts +3 -0
- package/dist/harness/hermes-startup.js +21 -0
- package/dist/harness/hermes.d.ts +5 -0
- package/dist/harness/hermes.js +62 -0
- package/dist/harness/registry.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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/acp.d.ts +15 -0
- package/dist/session/acp.js +22 -7
- package/dist/session/codex-app-server.js +31 -5
- package/dist/spawn.d.ts +1 -0
- package/dist/spawn.js +1 -0
- package/dist/supervisor/launchd.js +8 -1
- package/examples/fleet/brains/hermes.yaml +5 -0
- 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
|
|
@@ -1542,3 +1578,103 @@ that environment, and restart only affected agents after applying the override.
|
|
|
1542
1578
|
For temporary agents with a sealed stale override, have the task coordinator
|
|
1543
1579
|
arrange replacement with the corrected template. Reinstall/upgrade Fleet with
|
|
1544
1580
|
optional dependencies enabled to repair a missing or stale bundled runtime.
|
|
1581
|
+
|
|
1582
|
+
### Hermes ACP
|
|
1583
|
+
|
|
1584
|
+
A Hermes Brain uses `harness: hermes`, `session: acp`, and an explicit non-empty
|
|
1585
|
+
`model`. See [the example Brain](examples/fleet/brains/hermes.yaml). The model is
|
|
1586
|
+
illustrative: select one supported by the native provider provisioned for your
|
|
1587
|
+
role. The same Brain can be selected by Coordinator or an ordinary Agent; this
|
|
1588
|
+
does not switch an existing Coordinator automatically.
|
|
1589
|
+
|
|
1590
|
+
Each role has a private persistent Hermes home at
|
|
1591
|
+
`<role-state-dir>/harness/hermes`, separate from its project working directory.
|
|
1592
|
+
Stop the role before setup or upgrades. Point Hermes's native setup at that exact
|
|
1593
|
+
path using `HERMES_HOME`; provision the provider and credentials there. Do not copy
|
|
1594
|
+
or link an operator's active credentials. Fleet creates the home with mode 0700
|
|
1595
|
+
and writes its configuration with mode 0600. Fleet owns `model.default` and
|
|
1596
|
+
`approvals.mode: manual`; unrelated native configuration, credentials, memory,
|
|
1597
|
+
skills and runtime files remain native-owned. Removing the Brain model is an
|
|
1598
|
+
error, not a request to restore an old native model.
|
|
1599
|
+
|
|
1600
|
+
Every start creates a fresh ACP conversation and receives the full role briefing.
|
|
1601
|
+
Memory and skills in the role home survive; the previous conversation is not
|
|
1602
|
+
restored. Changing the Brain model takes effect on the next start. Temporary
|
|
1603
|
+
homes last for the temporary role's lifetime; persistent homes remain until an
|
|
1604
|
+
authorized role operation removes them.
|
|
1605
|
+
|
|
1606
|
+
Use Fleet-owned monitoring (`monitor.mode: fleet`). Fleet supplies the reserved
|
|
1607
|
+
`ours` connector through ACP, plus optional explicit
|
|
1608
|
+
`harness_options.mcp_servers`. An identical explicit
|
|
1609
|
+
`ours: { command: ours-mcp, args: [proxy] }` is merged once; a conflicting one is
|
|
1610
|
+
rejected. Disable all home-configured MCP servers and MCP-providing plugins in
|
|
1611
|
+
this role home. Startup does not silently remove them. An ACP connection means
|
|
1612
|
+
MCP availability is **unverified until actual use**; a configured connector name
|
|
1613
|
+
or a model's claim does not prove that a tool works. Fleet adds no probe prompt.
|
|
1614
|
+
|
|
1615
|
+
| Fleet approval | Hermes edit mode |
|
|
1616
|
+
| --- | --- |
|
|
1617
|
+
| `ask` | `default` |
|
|
1618
|
+
| `auto` | `accept_edits` |
|
|
1619
|
+
| `allow` | `dont_ask` |
|
|
1620
|
+
|
|
1621
|
+
These modes cover dangerous terminal command prompts and `write_file`/`patch`.
|
|
1622
|
+
They do not provide universal approval for browser, memory, skills, delegation
|
|
1623
|
+
or MCP side effects. Native protected-action floors remain. Fleet's permission
|
|
1624
|
+
wait is 50 seconds, below the tested native dangerous-command timeout of 60
|
|
1625
|
+
seconds. Workspace confinement is approximate without verified enforcing Fleet
|
|
1626
|
+
isolation; unrestricted filesystem mode is also available. Read-only mode,
|
|
1627
|
+
legacy approval `deny`, Fleet provider overrides, effort, model chains, native
|
|
1628
|
+
monitoring and `after_tool` are unsupported.
|
|
1629
|
+
|
|
1630
|
+
This dedicated home and environment filtering are not a complete isolation
|
|
1631
|
+
boundary: native Hermes can also read installation/system configuration and
|
|
1632
|
+
provider credential fallbacks outside it. Fleet uses its existing process
|
|
1633
|
+
lifecycle without adding a separate process-death barrier. Use the appropriate
|
|
1634
|
+
OS/service isolation for stronger separation.
|
|
1635
|
+
|
|
1636
|
+
The tested artifact is Hermes 0.21.1 at source commit
|
|
1637
|
+
`d15ed4445207dda418b984e8bda0f68f48b8c6f3`, Python ACP 0.9.0, protocol 1.
|
|
1638
|
+
Unknown builds require compatibility testing before managed launch. Configure a
|
|
1639
|
+
literal native `model.provider` in the stopped home so Fleet can compare the
|
|
1640
|
+
reported provider against an independent value; `auto` selection is unsupported.
|
|
1641
|
+
|
|
1642
|
+
For an enforcing Linux workspace sandbox, use the existing bubblewrap backend
|
|
1643
|
+
with `on_unavailable: strict`. Its allowlist must include read-only access to the
|
|
1644
|
+
Hermes source/virtualenv and the Python runtime used by its launcher, including
|
|
1645
|
+
any interpreter symlink path. Declare these installation-specific paths in
|
|
1646
|
+
`isolation.fs.read`; do not expose an entire operator home. The tested policy
|
|
1647
|
+
allowed a project write and refused a write to an outside read-only directory.
|
|
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
|
+
```
|
package/dist/briefing.js
CHANGED
|
@@ -124,6 +124,7 @@ function generateRoomMemberBriefing(role, v, opts, prefix) {
|
|
|
124
124
|
export function generateBriefing(role, v, opts) {
|
|
125
125
|
const L = [];
|
|
126
126
|
const id = role.identity;
|
|
127
|
+
const bindForce = role.harness === 'hermes' ? 'false' : 'true';
|
|
127
128
|
const hostUser = userInfo().username;
|
|
128
129
|
L.push(`# ${role.name} — Role Briefing`, '');
|
|
129
130
|
const lifetime = opts.temporaryIdentity ? 'temporary' : 'persistent';
|
|
@@ -154,13 +155,13 @@ export function generateBriefing(role, v, opts) {
|
|
|
154
155
|
const guarantee = opts.identityGuarantee ?? 'unverified';
|
|
155
156
|
if (guarantee === 'unverified') {
|
|
156
157
|
L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
|
|
157
|
-
L.push(` name "${id}" force
|
|
158
|
+
L.push(` name "${id}" force=${bindForce} (search the deferred tool registry first if needed).`);
|
|
158
159
|
L.push(` - This permanent identity was NOT verified before launch. If it does not exist, STOP`);
|
|
159
160
|
L.push(' and report the infrastructure error; identity creation belongs to the fleet lifecycle.');
|
|
160
161
|
}
|
|
161
162
|
else {
|
|
162
163
|
L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
|
|
163
|
-
L.push(` name "${id}" force
|
|
164
|
+
L.push(` name "${id}" force=${bindForce} (search the deferred tool registry first if needed).`);
|
|
164
165
|
L.push(` - It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when your role`);
|
|
165
166
|
L.push(' was started, so binding should succeed. If it unexpectedly reports no such identity,');
|
|
166
167
|
L.push(' STOP and report the infrastructure race; do not create or replace it yourself.');
|
|
@@ -305,7 +306,7 @@ export function generateBriefing(role, v, opts) {
|
|
|
305
306
|
L.push('to permanent creation, or delete identity state. After successful creation,');
|
|
306
307
|
}
|
|
307
308
|
else {
|
|
308
|
-
L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force
|
|
309
|
+
L.push(`On restart, WITHOUT asking: re-bind (**${v.bindTool}** name "${id}" force=${bindForce}), then`);
|
|
309
310
|
}
|
|
310
311
|
L.push(`${wakeNote} Then continue from your WORKLOG.`);
|
|
311
312
|
L.push('Do not blindly re-run whatever may have crashed you.');
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.1
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
5
|
-
"dirty":
|
|
6
|
-
"builtAt": "2026-09-
|
|
2
|
+
"version": "1.2.0-nightly.1",
|
|
3
|
+
"buildId": "1e4449b7d64e",
|
|
4
|
+
"commit": "79fb01a75d39a721491c789b857f892c7c9d000f",
|
|
5
|
+
"dirty": true,
|
|
6
|
+
"builtAt": "2026-09-18T12:07:17.159Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"monitor.interrupt.after_tool"
|
|
9
9
|
]
|
package/dist/cli.d.ts
CHANGED
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';
|
|
@@ -43,6 +43,7 @@ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-prox
|
|
|
43
43
|
import { beginFleetAuditCollection, consumeFleetAuditCollection, FleetCliExit, setFleetAuditLifecycleCheckpoint, } from './fleet-command-audit.js';
|
|
44
44
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
45
45
|
import './harness/codex.js'; // registers the codex adapter
|
|
46
|
+
import './harness/hermes.js';
|
|
46
47
|
import { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './rooms-tasks/cli.js';
|
|
47
48
|
import { RoleCreationService } from './application/role-creation-service.js';
|
|
48
49
|
import { RoleRemovalService } from './application/role-removal-service.js';
|
|
@@ -1268,13 +1269,14 @@ cOpt(program.command('doctor').description('prerequisite report'))
|
|
|
1268
1269
|
if (!rep.ok)
|
|
1269
1270
|
throw new FleetCliExit(1);
|
|
1270
1271
|
});
|
|
1271
|
-
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'))
|
|
1272
1274
|
.action(async (opts) => {
|
|
1273
1275
|
const configuration = opts.configuration ?? defaultConfigPath();
|
|
1274
|
-
if (!isInteractiveTerminal(process.stdin, process.stdout))
|
|
1276
|
+
if (!opts.settings && !isInteractiveTerminal(process.stdin, process.stdout))
|
|
1275
1277
|
die('ours-fleet init requires an interactive terminal; no host setup ran and no configuration changed');
|
|
1276
1278
|
try {
|
|
1277
|
-
const
|
|
1279
|
+
const initDeps = {
|
|
1278
1280
|
async hostSetup() {
|
|
1279
1281
|
for (const d of [agentsRoot(), tmpRoot(), logsRoot()])
|
|
1280
1282
|
mkdirSync(d, { recursive: true });
|
|
@@ -1282,10 +1284,13 @@ cOpt(program.command('init').description('interactively add missing Fleet defaul
|
|
|
1282
1284
|
console.log(message);
|
|
1283
1285
|
},
|
|
1284
1286
|
publish: publishSetup,
|
|
1285
|
-
}
|
|
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);
|
|
1286
1291
|
if (!result) {
|
|
1287
1292
|
console.log('Fleet setup cancelled. No host setup ran and no configuration changed.');
|
|
1288
|
-
|
|
1293
|
+
throw new FleetCliExit(1);
|
|
1289
1294
|
}
|
|
1290
1295
|
const prior = result.manifestExisted && result.rootExisted ? 'manifest and split configuration'
|
|
1291
1296
|
: result.manifestExisted ? 'manifest only'
|
|
@@ -1296,6 +1301,8 @@ cOpt(program.command('init').description('interactively add missing Fleet defaul
|
|
|
1296
1301
|
console.log(INIT_COMPLETION_GUIDANCE);
|
|
1297
1302
|
}
|
|
1298
1303
|
catch (error) {
|
|
1304
|
+
if (error instanceof FleetCliExit)
|
|
1305
|
+
throw error;
|
|
1299
1306
|
die(error);
|
|
1300
1307
|
}
|
|
1301
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.username || url.password || url.pathname !== '/'
|
|
101
|
+
|| url.search || url.hash)
|
|
102
|
+
throw invalid(configPath, 'endpoint must be a local HTTP 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;
|