@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.
Files changed (48) hide show
  1. package/README.md +137 -1
  2. package/dist/briefing.js +4 -3
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +13 -6
  6. package/dist/client-profile.d.ts +17 -0
  7. package/dist/client-profile.js +115 -0
  8. package/dist/creation.js +10 -7
  9. package/dist/daemon-recovery.d.ts +2 -0
  10. package/dist/daemon-recovery.js +53 -2
  11. package/dist/docs.d.ts +1 -1
  12. package/dist/docs.js +23 -2
  13. package/dist/doctor.js +62 -6
  14. package/dist/harness/acp-mcp.d.ts +14 -0
  15. package/dist/harness/acp-mcp.js +68 -0
  16. package/dist/harness/claude-code.js +1 -69
  17. package/dist/harness/codex.js +4 -0
  18. package/dist/harness/hermes-compatibility.d.ts +24 -0
  19. package/dist/harness/hermes-compatibility.js +191 -0
  20. package/dist/harness/hermes-config.d.ts +12 -0
  21. package/dist/harness/hermes-config.js +367 -0
  22. package/dist/harness/hermes-permissions.d.ts +4 -0
  23. package/dist/harness/hermes-permissions.js +36 -0
  24. package/dist/harness/hermes-session.d.ts +24 -0
  25. package/dist/harness/hermes-session.js +85 -0
  26. package/dist/harness/hermes-startup.d.ts +3 -0
  27. package/dist/harness/hermes-startup.js +21 -0
  28. package/dist/harness/hermes.d.ts +5 -0
  29. package/dist/harness/hermes.js +62 -0
  30. package/dist/harness/registry.js +1 -1
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/init-wizard.d.ts +11 -6
  34. package/dist/init-wizard.js +33 -0
  35. package/dist/monitor.d.ts +34 -3
  36. package/dist/monitor.js +174 -74
  37. package/dist/owner-channel/channel.js +4 -2
  38. package/dist/owner-channel/ours-client.d.ts +12 -5
  39. package/dist/owner-channel/ours-client.js +48 -12
  40. package/dist/runner.js +21 -7
  41. package/dist/session/acp.d.ts +15 -0
  42. package/dist/session/acp.js +22 -7
  43. package/dist/session/codex-app-server.js +31 -5
  44. package/dist/spawn.d.ts +1 -0
  45. package/dist/spawn.js +1 -0
  46. package/dist/supervisor/launchd.js +8 -1
  47. package/examples/fleet/brains/hermes.yaml +5 -0
  48. 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. After the final Yes, host integration runs before locked
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=true (search the deferred tool registry first if needed).`);
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=true (search the deferred tool registry first if needed).`);
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=true), then`);
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.');
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.1.4",
3
- "buildId": "a288595a172d",
4
- "commit": "72d6d26ea49eaf1941885e721eab76e177f974af",
5
- "dirty": false,
6
- "builtAt": "2026-09-11T09:05:53.118Z",
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
@@ -1,3 +1,4 @@
1
1
  #!/usr/bin/env node
2
2
  import './harness/claude-code.js';
3
3
  import './harness/codex.js';
4
+ import './harness/hermes.js';
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('interactively add missing Fleet defaults while preserving existing configuration'))
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 result = await executeInitWizard(new TerminalPrompter(process.stdin, process.stdout), configuration, {
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
- return;
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 attachClient({
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 attachClient({
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
@@ -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 { resolveEndpoint } from './monitor.js';
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;