@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.992

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 (38) hide show
  1. package/changelog.json +12 -0
  2. package/deploy/unraid/Dockerfile +15 -2
  3. package/deploy/unraid/README.txt +44 -9
  4. package/deploy/unraid/github_known_hosts +3 -0
  5. package/deploy/unraid/ouro-sync-ssh +98 -0
  6. package/deploy/unraid/ouro-sync.sudoers +3 -0
  7. package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
  8. package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
  9. package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
  10. package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
  11. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  12. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
  13. package/deploy/unraid/sanctuary.xml +2 -1
  14. package/dist/heart/config.js +2 -1
  15. package/dist/heart/daemon/agent-discovery.js +5 -1
  16. package/dist/heart/daemon/agent-home.js +67 -31
  17. package/dist/heart/daemon/boot-sync-probe.js +36 -10
  18. package/dist/heart/daemon/cli-exec.js +79 -20
  19. package/dist/heart/daemon/cli-help.js +12 -0
  20. package/dist/heart/daemon/cli-parse.js +14 -0
  21. package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
  22. package/dist/heart/daemon/container-spec-auditor.js +18 -2
  23. package/dist/heart/daemon/daemon-entry.js +22 -0
  24. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  25. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +21 -5
  26. package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
  27. package/dist/heart/daemon/sanctuary-package-management.js +10 -0
  28. package/dist/heart/sync-lock.js +431 -0
  29. package/dist/heart/sync.js +1252 -399
  30. package/dist/mind/prompt.js +92 -19
  31. package/dist/repertoire/guardrails.js +285 -3
  32. package/dist/repertoire/tools-bundle.js +107 -185
  33. package/dist/repertoire/tools.js +5 -1
  34. package/dist/senses/pipeline.js +37 -3
  35. package/dist/senses/private-runtime.js +1 -6
  36. package/dist/senses/voice/twilio-phone.js +16 -16
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +1 -1
@@ -42,6 +42,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.runMcpServeCliLifecycle = runMcpServeCliLifecycle;
43
43
  exports.summarizeDaemonStartupFailure = summarizeDaemonStartupFailure;
44
44
  exports.writeSyncProbeSummary = writeSyncProbeSummary;
45
+ exports.writeSyncProbeStatuses = writeSyncProbeStatuses;
45
46
  exports.summarizeSyncProbeFindings = summarizeSyncProbeFindings;
46
47
  exports.mergeStartupStability = mergeStartupStability;
47
48
  exports.ensureDaemonRunning = ensureDaemonRunning;
@@ -54,6 +55,7 @@ exports.resolveMailImportFilePath = resolveMailImportFilePath;
54
55
  exports.runOuroCli = runOuroCli;
55
56
  const child_process_1 = require("child_process");
56
57
  const bluebubbles_host_account_1 = require("../bluebubbles-host-account");
58
+ const sanctuary_bundle_migration_1 = require("./sanctuary-bundle-migration");
57
59
  const crypto_1 = require("crypto");
58
60
  const fs = __importStar(require("fs"));
59
61
  const os = __importStar(require("os"));
@@ -563,6 +565,7 @@ function agentResolutionFailureMode(command) {
563
565
  case "setup":
564
566
  case "bluebubbles.replay":
565
567
  case "bluebubbles.context-smoke":
568
+ case "sync":
566
569
  return "throw";
567
570
  default:
568
571
  return undefined;
@@ -744,6 +747,10 @@ function writeSyncProbeSummary(deps, findings) {
744
747
  }
745
748
  deps.writeStdout(lines.join("\n"));
746
749
  }
750
+ /** What the boot probe saw in each bundle: paused, busy, or commits ahead and behind. */
751
+ function writeSyncProbeStatuses(deps, statuses) {
752
+ deps.writeStdout(["sync status:", ...statuses.map((status) => ` ${status.agent}: ${status.text}`)].join("\n"));
753
+ }
747
754
  function bootPhasePlan(_daemonAlive) {
748
755
  return ["update check", "system setup", "sync probe", "starting daemon", "provider checks", "final daemon check"];
749
756
  }
@@ -1621,24 +1628,63 @@ function readAgentSyncConfigForCliMutation(agent, deps) {
1621
1628
  return {
1622
1629
  enabled: parsed.sync?.enabled === true,
1623
1630
  remote: typeof parsed.sync?.remote === "string" && parsed.sync.remote.trim() ? parsed.sync.remote : "origin",
1631
+ branch: typeof parsed.sync?.branch === "string" && parsed.sync.branch.trim() ? parsed.sync.branch : "main",
1624
1632
  };
1625
1633
  }
1626
1634
  catch {
1627
1635
  /* v8 ignore next -- defensive: post-mutation sync should not break an already-successful CLI repair if agent.json becomes unreadable @preserve */
1628
- return { enabled: false, remote: "origin" };
1636
+ return { enabled: false, remote: "origin", branch: "main" };
1629
1637
  }
1630
1638
  }
1631
- function pushAgentBundleAfterCliMutation(agent, deps) {
1639
+ /** How long a CLI command waits for the bundle sync lock before reporting busy. */
1640
+ const CLI_SYNC_LOCK_WAIT_MS = 60_000;
1641
+ /**
1642
+ * Sync the bundle after a CLI change and report what actually happened. The
1643
+ * sync is awaited: a one-shot CLI that exits explicitly would otherwise kill a
1644
+ * push it had already reported.
1645
+ */
1646
+ async function pushAgentBundleAfterCliMutation(agent, deps) {
1632
1647
  const sync = readAgentSyncConfigForCliMutation(agent, deps);
1633
1648
  if (!sync.enabled)
1634
1649
  return null;
1635
1650
  const agentRoot = providerCliAgentRoot({ agent }, deps);
1636
- const result = (0, sync_1.postTurnPush)(agentRoot, { enabled: true, remote: sync.remote });
1651
+ const result = await (0, sync_1.syncNow)(agentRoot, sync, agent, deps.syncLockWaitMs ?? CLI_SYNC_LOCK_WAIT_MS);
1652
+ if (result.paused)
1653
+ return `bundle sync: paused until ${result.pausedUntil}; nothing pushed`;
1637
1654
  if (result.ok) {
1638
1655
  return `bundle sync: ran post-change sync (remote: ${sync.remote})`;
1639
1656
  }
1640
1657
  return `bundle sync: could not push bundle changes (${result.error})`;
1641
1658
  }
1659
+ function bundleHasRemote(agentRoot, remote) {
1660
+ try {
1661
+ return (0, child_process_1.execFileSync)("git", ["remote"], { cwd: agentRoot, stdio: "pipe" }).toString().split("\n").map((line) => line.trim()).includes(remote);
1662
+ }
1663
+ catch {
1664
+ /* v8 ignore next -- syncNow just ran git in this bundle; this only words the success line @preserve */
1665
+ return false;
1666
+ }
1667
+ }
1668
+ /** `ouro sync`: one fetch, merge and push, reported as it happened. Exits 1 unless the bundle synced. */
1669
+ async function executeSync(agent, deps) {
1670
+ const sync = readAgentSyncConfigForCliMutation(agent, deps);
1671
+ if (!sync.enabled)
1672
+ return returnCliFailure(deps, `sync is off for ${agent}: agent.json has no "sync": { "enabled": true }`);
1673
+ const agentRoot = providerCliAgentRoot({ agent }, deps);
1674
+ const result = await (0, sync_1.syncNow)(agentRoot, sync, agent, deps.syncLockWaitMs ?? CLI_SYNC_LOCK_WAIT_MS);
1675
+ if (result.paused)
1676
+ return returnCliFailure(deps, `sync paused until ${result.pausedUntil}`);
1677
+ if (result.busy) {
1678
+ return returnCliFailure(deps, `sync busy: another sync holds the lock${result.holderPid == null ? "" : ` (pid ${result.holderPid})`}`);
1679
+ }
1680
+ if (!result.ok)
1681
+ return returnCliFailure(deps, `sync failed for ${agent}: ${result.error}`);
1682
+ const message = bundleHasRemote(agentRoot, sync.remote)
1683
+ ? `synced ${agent}: up to date with ${sync.remote}/${sync.branch}`
1684
+ : `synced ${agent}: committed locally; no remote "${sync.remote}"`;
1685
+ deps.writeStdout(message);
1686
+ return message;
1687
+ }
1642
1688
  function writeAgentVaultConfig(agentName, configPath, config, vault) {
1643
1689
  const nextConfig = {
1644
1690
  ...config,
@@ -1907,7 +1953,7 @@ async function executeVaultCreate(command, deps) {
1907
1953
  /* v8 ignore next -- defensive: success path assigns store before continuing @preserve */
1908
1954
  if (!store)
1909
1955
  throw new Error(`vault create failed for ${command.agent}: local unlock material was not saved`);
1910
- const syncSummary = pushAgentBundleAfterCliMutation(command.agent, deps);
1956
+ const syncSummary = await pushAgentBundleAfterCliMutation(command.agent, deps);
1911
1957
  const fallbackLines = [
1912
1958
  `vault created for ${command.agent}`,
1913
1959
  `vault: ${email} at ${serverUrl}`,
@@ -1983,7 +2029,7 @@ async function executeVaultReplace(command, deps) {
1983
2029
  throw new Error(`vault replace failed for ${command.agent}: no vault repair result`);
1984
2030
  if (!repair.ok)
1985
2031
  return repair.message;
1986
- const syncSummary = pushAgentBundleAfterCliMutation(command.agent, deps);
2032
+ const syncSummary = await pushAgentBundleAfterCliMutation(command.agent, deps);
1987
2033
  const fallbackLines = [
1988
2034
  `vault replaced for ${command.agent}`,
1989
2035
  `vault: ${email} at ${serverUrl}`,
@@ -2095,7 +2141,7 @@ async function executeVaultRecover(command, deps) {
2095
2141
  progress.end();
2096
2142
  }
2097
2143
  const providerList = [...importedProviders].sort();
2098
- const syncSummary = pushAgentBundleAfterCliMutation(command.agent, deps);
2144
+ const syncSummary = await pushAgentBundleAfterCliMutation(command.agent, deps);
2099
2145
  const fallbackLines = [
2100
2146
  `vault recovered for ${command.agent}`,
2101
2147
  `vault: ${email} at ${serverUrl}`,
@@ -2269,13 +2315,13 @@ function currentMachineId(deps) {
2269
2315
  now: () => providerCliNow(deps),
2270
2316
  }).machineId;
2271
2317
  }
2272
- function executeAgentMove(command, deps) {
2318
+ async function executeAgentMove(command, deps) {
2273
2319
  const identity = (0, machine_identity_1.loadOrCreateMachineIdentity)({ homeDir: providerCliHomeDir(deps), now: () => providerCliNow(deps) });
2274
2320
  /* v8 ignore next -- production default: uses real bundles root @preserve */
2275
2321
  const bundlesRoot = deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)();
2276
2322
  const discover = () => (0, agent_discovery_1.listAllBundleAgents)({ bundlesRoot, machineId: identity.machineId });
2277
2323
  const fallbackBefore = new Set(discover().filter((row) => row.homeState === "fallback").map((row) => row.name));
2278
- const result = (0, agent_home_1.moveAgentHomeHere)({
2324
+ const result = await (0, agent_home_1.moveAgentHomeHere)({
2279
2325
  agent: command.agent,
2280
2326
  machineId: identity.machineId,
2281
2327
  machineName: (0, agent_home_1.machineDisplayName)(os.hostname(), identity.machineId),
@@ -3682,7 +3728,7 @@ async function executeConnectTeams(agent, deps) {
3682
3728
  finally {
3683
3729
  progress.end();
3684
3730
  }
3685
- const syncSummary = pushAgentBundleAfterCliMutation(agent, deps);
3731
+ const syncSummary = await pushAgentBundleAfterCliMutation(agent, deps);
3686
3732
  return writeCapabilityOutcome(deps, {
3687
3733
  subtitle: `${agent}'s Teams sense is configured.`,
3688
3734
  summary: `Teams is ready for ${agent}.`,
@@ -3910,7 +3956,7 @@ async function executeConnectBlueBubbles(agent, deps) {
3910
3956
  progress.end();
3911
3957
  throw error;
3912
3958
  }
3913
- const syncSummary = pushAgentBundleAfterCliMutation(agent, deps);
3959
+ const syncSummary = await pushAgentBundleAfterCliMutation(agent, deps);
3914
3960
  return writeCapabilityOutcome(deps, {
3915
3961
  subtitle: `${agent}'s local bridge is attached.`,
3916
3962
  summary: `BlueBubbles is attached on this machine for ${agent}.`,
@@ -4256,7 +4302,7 @@ async function ensureAgentMailroom(agent, input, deps, progressLabel) {
4256
4302
  /* v8 ignore next -- defensive: successful setup always stores before leaving the guarded block. @preserve */
4257
4303
  if (!stored)
4258
4304
  throw new Error("Mailroom setup did not store runtime credentials");
4259
- const syncSummary = pushAgentBundleAfterCliMutation(agent, deps);
4305
+ const syncSummary = await pushAgentBundleAfterCliMutation(agent, deps);
4260
4306
  return { mailboxAddress, sourceAlias, mode, registryPath, storePath, hostedControlUrl, blobStoreLabel, stored, syncSummary };
4261
4307
  }
4262
4308
  async function executeConnectMail(agent, deps, input = {}) {
@@ -5154,7 +5200,7 @@ async function executeConnectVoice(agent, deps) {
5154
5200
  async function executeConnectA2A(agent, deps, bind = {}) {
5155
5201
  const { defaultA2APort } = await Promise.resolve().then(() => __importStar(require("../../a2a/config")));
5156
5202
  enableAgentSense(agent, "a2a", deps);
5157
- const syncSummary = pushAgentBundleAfterCliMutation(agent, deps);
5203
+ const syncSummary = await pushAgentBundleAfterCliMutation(agent, deps);
5158
5204
  // Bind address, port and advertised URL are per machine (D-038): a tailnet IP on one
5159
5205
  // host means nothing on another, so they live in this machine's runtime config.
5160
5206
  const a2a = {
@@ -5221,7 +5267,7 @@ async function executeConnectTelegram(agent, deps) {
5221
5267
  finally {
5222
5268
  progress.end();
5223
5269
  }
5224
- const syncSummary = pushAgentBundleAfterCliMutation(agent, deps);
5270
+ const syncSummary = await pushAgentBundleAfterCliMutation(agent, deps);
5225
5271
  return writeCapabilityOutcome(deps, {
5226
5272
  subtitle: `${agent}'s private Telegram sense is configured.`,
5227
5273
  summary: `Telegram is ready for ${agent}.`,
@@ -5248,7 +5294,7 @@ async function executeConnectTelegram(agent, deps) {
5248
5294
  async function executeConnectWorkbench(agent, deps) {
5249
5295
  const { workbenchMcpCommand, workbenchHasStaleBundleEntry } = readConnectBaySenseFlags(agent, deps);
5250
5296
  const changed = removeWorkbenchMcpRegistration(agent, deps);
5251
- const syncSummary = changed ? pushAgentBundleAfterCliMutation(agent, deps) : "";
5297
+ const syncSummary = changed ? await pushAgentBundleAfterCliMutation(agent, deps) : "";
5252
5298
  const executablePath = findInstalledWorkbenchMcp(deps, workbenchMcpCommand);
5253
5299
  if (!executablePath) {
5254
5300
  throw new Error([
@@ -5404,8 +5450,8 @@ function writeAgentProviderBinding(input) {
5404
5450
  meta: { agent: input.agent, lane: input.lane, provider: input.provider, model: input.model, configPath },
5405
5451
  });
5406
5452
  }
5407
- function appendBundleSyncSummary(message, agent, deps) {
5408
- const syncSummary = pushAgentBundleAfterCliMutation(agent, deps);
5453
+ async function appendBundleSyncSummary(message, agent, deps) {
5454
+ const syncSummary = await pushAgentBundleAfterCliMutation(agent, deps);
5409
5455
  return syncSummary ? [message, syncSummary].join("\n") : message;
5410
5456
  }
5411
5457
  async function executeProviderUse(command, deps, options = {}) {
@@ -5445,7 +5491,7 @@ async function executeProviderUse(command, deps, options = {}) {
5445
5491
  model: command.model,
5446
5492
  deps,
5447
5493
  });
5448
- const message = appendBundleSyncSummary(`forced ${command.agent} ${command.lane} to ${command.provider} / ${command.model}: failed (${credential.error})`, command.agent, deps);
5494
+ const message = await appendBundleSyncSummary(`forced ${command.agent} ${command.lane} to ${command.provider} / ${command.model}: failed (${credential.error})`, command.agent, deps);
5449
5495
  return writeMessage(message);
5450
5496
  }
5451
5497
  progress?.completePhase(`reading ${command.provider} credentials`, "found");
@@ -5472,7 +5518,7 @@ async function executeProviderUse(command, deps, options = {}) {
5472
5518
  model: command.model,
5473
5519
  deps,
5474
5520
  });
5475
- const message = appendBundleSyncSummary(`${command.force ? "forced " : ""}${command.agent} ${command.lane} ${command.provider} / ${command.model}: ${status}`, command.agent, deps);
5521
+ const message = await appendBundleSyncSummary(`${command.force ? "forced " : ""}${command.agent} ${command.lane} ${command.provider} / ${command.model}: ${status}`, command.agent, deps);
5476
5522
  (0, runtime_1.emitNervesEvent)({
5477
5523
  component: "daemon",
5478
5524
  event: "daemon.provider_use_completed",
@@ -6003,7 +6049,7 @@ async function executeLegacyConfigModel(command, deps) {
6003
6049
  },
6004
6050
  };
6005
6051
  fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf-8");
6006
- const message = appendBundleSyncSummary(`deprecated: updated ${command.agent} model in agent.json on ${lane}/${binding.provider}: ${binding.model} -> ${command.modelName}\nUse \`ouro use --agent ${command.agent} --lane ${lane} --provider ${binding.provider} --model ${command.modelName}\` next time.`, command.agent, deps);
6052
+ const message = await appendBundleSyncSummary(`deprecated: updated ${command.agent} model in agent.json on ${lane}/${binding.provider}: ${binding.model} -> ${command.modelName}\nUse \`ouro use --agent ${command.agent} --lane ${lane} --provider ${binding.provider} --model ${command.modelName}\` next time.`, command.agent, deps);
6007
6053
  return writeMessage(message);
6008
6054
  }
6009
6055
  // ── System setup ──
@@ -6647,6 +6693,12 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
6647
6693
  if (command.kind === "nerves-review") {
6648
6694
  return executeNervesReviewCommand(command, deps);
6649
6695
  }
6696
+ // Pure: no daemon, no network, no bundle. The cutover runs it in a container with --network none.
6697
+ if (command.kind === "sanctuary.git-exclude") {
6698
+ const text = (0, sanctuary_bundle_migration_1.sanctuaryGitExcludeText)();
6699
+ deps.writeStdout(text);
6700
+ return text;
6701
+ }
6650
6702
  if (args.length === 0) {
6651
6703
  const discovered = await Promise.resolve(deps.listDiscoveredAgents ? deps.listDiscoveredAgents() : (0, cli_defaults_1.defaultListDiscoveredAgents)());
6652
6704
  /* v8 ignore start -- the interactive home shell is exercised extensively in daemon-cli tests; V8 miscounts this orchestrator because it chains through recursive command handoffs and early chat health exits @preserve */
@@ -7146,6 +7198,9 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7146
7198
  if (syncProbeResult.findings.length > 0) {
7147
7199
  writeSyncProbeSummary(deps, syncProbeResult.findings);
7148
7200
  }
7201
+ if (syncProbeResult.statuses && syncProbeResult.statuses.length > 0) {
7202
+ writeSyncProbeStatuses(deps, syncProbeResult.statuses);
7203
+ }
7149
7204
  }
7150
7205
  catch (probeError) {
7151
7206
  const message = probeError instanceof Error ? probeError.message : String(probeError);
@@ -9081,6 +9136,10 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
9081
9136
  }
9082
9137
  return output;
9083
9138
  }
9139
+ // ── sync: fetch, merge and push one bundle now ──
9140
+ if (command.kind === "sync") {
9141
+ return executeSync(command.agent, deps);
9142
+ }
9084
9143
  // ── clone: clone an agent bundle from a git remote ──
9085
9144
  if (command.kind === "clone") {
9086
9145
  (0, runtime_1.emitNervesEvent)({
@@ -9149,7 +9208,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
9149
9208
  }
9150
9209
  // 8. Output success message
9151
9210
  (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.clone_complete", message: "clone complete", meta: { agentName, targetPath } });
9152
- const syncSummary = syncEnabled ? pushAgentBundleAfterCliMutation(agentName, deps) : null;
9211
+ const syncSummary = syncEnabled ? await pushAgentBundleAfterCliMutation(agentName, deps) : null;
9153
9212
  const syncMsg = syncEnabled ? "\nsync enabled (remote: origin)" : "\nwarning: no agent.json found — this may not be a valid agent bundle";
9154
9213
  const syncPushMsg = syncSummary ? `\n${syncSummary}` : "";
9155
9214
  deps.writeStdout(`cloned ${agentName} to ${targetPath}${syncMsg}${syncPushMsg}`);
@@ -80,6 +80,12 @@ exports.COMMAND_REGISTRY = {
80
80
  usage: "ouro clone <remote> [--agent <name>]",
81
81
  example: "ouro clone https://github.com/user/myagent.ouro.git",
82
82
  },
83
+ sync: {
84
+ category: "Lifecycle",
85
+ description: "Sync an agent bundle with its git remote now: fetch, merge, push",
86
+ usage: "ouro sync [--agent <name>]",
87
+ example: "ouro sync --agent slugger",
88
+ },
83
89
  doctor: {
84
90
  category: "Lifecycle",
85
91
  description: "Run diagnostic checks on the ouro installation",
@@ -211,6 +217,12 @@ exports.COMMAND_REGISTRY = {
211
217
  example: "ouro work sentinel --agent my-agent --format json",
212
218
  subcommands: ["card", "gauntlet", "sentinel"],
213
219
  },
220
+ sanctuary: {
221
+ category: "Internal",
222
+ description: "Print the git exclude list for the Butler's bundle, one entry per line. Starts nothing and reads nothing.",
223
+ usage: "ouro sanctuary git-exclude --print",
224
+ example: "ouro sanctuary git-exclude --print",
225
+ },
214
226
  "nerves-review": {
215
227
  category: "Internal",
216
228
  description: `Read-only review of recent nerves events from one agent log stream (default: ${core_1.DEFAULT_NERVES_PROCESS})`,
@@ -327,6 +327,7 @@ function usage() {
327
327
  " ouro rollback [<version>]",
328
328
  " ouro versions",
329
329
  " ouro clone <remote> [--agent <name>]",
330
+ " ouro sync [--agent <name>]",
330
331
  " ouro doctor",
331
332
  ].join("\n");
332
333
  }
@@ -2163,6 +2164,12 @@ function parseCloneCommand(args) {
2163
2164
  ? { kind: "clone", remote, agent }
2164
2165
  : { kind: "clone", remote };
2165
2166
  }
2167
+ function parseSyncCommand(args) {
2168
+ const { agent, rest } = extractAgentFlag(args);
2169
+ if (rest.length > 0)
2170
+ throw new Error("Usage: ouro sync [--agent <name>]");
2171
+ return agent ? { kind: "sync", agent } : { kind: "sync" };
2172
+ }
2166
2173
  function parseMcpServeCommand(args) {
2167
2174
  let agent;
2168
2175
  let friendId;
@@ -3281,6 +3288,11 @@ function parseOuroCommand(args) {
3281
3288
  return parsePokeCommand(args.slice(1));
3282
3289
  if (head === "nerves-review")
3283
3290
  return parseNervesReviewCommand(args.slice(1));
3291
+ if (head === "sanctuary") {
3292
+ if (args.length === 3 && args[1] === "git-exclude" && args[2] === "--print")
3293
+ return { kind: "sanctuary.git-exclude" };
3294
+ throw new Error("Usage: ouro sanctuary git-exclude --print");
3295
+ }
3284
3296
  if (head === "link")
3285
3297
  return parseLinkCommand(args.slice(1));
3286
3298
  if (head === "acp-serve")
@@ -3293,6 +3305,8 @@ function parseOuroCommand(args) {
3293
3305
  return parsePluginCommand(args.slice(1));
3294
3306
  if (head === "clone")
3295
3307
  return parseCloneCommand(args.slice(1));
3308
+ if (head === "sync")
3309
+ return parseSyncCommand(args.slice(1));
3296
3310
  if (head === "doctor") {
3297
3311
  const tail = args.slice(1);
3298
3312
  const json = tail.includes("--json");
@@ -67,7 +67,22 @@ function parseSingleInspect(raw) {
67
67
  return null;
68
68
  }
69
69
  }
70
- function runContainerSpecAuditorCli(args, deps = {}) {
70
+ function splitSyncFlag(args) {
71
+ const index = args.indexOf("--sync-enabled");
72
+ if (index === -1)
73
+ return { args, syncEnabled: false };
74
+ const value = args[index + 1];
75
+ if (value !== "true" && value !== "false")
76
+ return null;
77
+ const rest = [...args.slice(0, index), ...args.slice(index + 2)];
78
+ if (rest.includes("--sync-enabled"))
79
+ return null;
80
+ return { args: rest, syncEnabled: value === "true" };
81
+ }
82
+ function runContainerSpecAuditorCli(rawArgs, deps = {}) {
83
+ const sync = splitSyncFlag(rawArgs);
84
+ const args = sync?.args ?? [];
85
+ const syncEnabled = sync?.syncEnabled === true;
71
86
  const readFile = deps.readFile ?? ((filePath) => fs.readFileSync(filePath));
72
87
  const readText = (filePath) => decodeUtf8(readFile(filePath));
73
88
  const write = deps.write ?? ((text) => process.stdout.write(text));
@@ -78,14 +93,14 @@ function runContainerSpecAuditorCli(args, deps = {}) {
78
93
  const sourceContract = sourceCandidate?.["--mount-contract"];
79
94
  const sourceEffective = sourceContract === "legacy-alpha742" ? sourceCandidate : null;
80
95
  const selectedEffective = effective ?? sourceEffective;
81
- if (!staged && !persistent && !selectedEffective) {
82
- write(JSON.stringify({ ok: false, error: "usage: all modes require --mount-contract canonical-pre-gateway|canonical-gateway; staged --template <path> --runtime-policy <path> --expected-image <id>; persistent --persistent-template <path> --runtime-policy <path> --expected-image-reference <tag>; effective --inspect <path> --image-inspect <path> --expected-image <id> --expected-image-reference <tag> --expected-icon <url>; source compatibility effective --inspect <path> --image-inspect <path> --expected-image <id> --mount-contract legacy-alpha742" }) + "\n");
96
+ if (!sync || (!staged && !persistent && !selectedEffective)) {
97
+ write(JSON.stringify({ ok: false, error: "usage: all modes require --mount-contract canonical-pre-gateway|canonical-gateway and accept --sync-enabled true|false; staged --template <path> --runtime-policy <path> --expected-image <id>; persistent --persistent-template <path> --runtime-policy <path> --expected-image-reference <tag>; effective --inspect <path> --image-inspect <path> --expected-image <id> --expected-image-reference <tag> --expected-icon <url>; source compatibility effective --inspect <path> --image-inspect <path> --expected-image <id> --mount-contract legacy-alpha742" }) + "\n");
83
98
  (0, runtime_1.emitNervesEvent)({
84
99
  level: "error",
85
100
  component: "daemon",
86
101
  event: "daemon.container_spec_auditor_cli_error",
87
102
  message: "container spec auditor arguments were invalid",
88
- meta: { argumentCount: args.length },
103
+ meta: { argumentCount: rawArgs.length },
89
104
  });
90
105
  return 2;
91
106
  }
@@ -130,6 +145,7 @@ function runContainerSpecAuditorCli(args, deps = {}) {
130
145
  expectedImageReference: selectedEffective["--expected-image-reference"],
131
146
  expectedIcon: selectedEffective["--expected-icon"],
132
147
  mountContract: selectedEffective["--mount-contract"],
148
+ syncEnabled,
133
149
  });
134
150
  if (imageInspect.Id !== selectedEffective["--expected-image"]) {
135
151
  result.ok = false;
@@ -159,8 +175,8 @@ function runContainerSpecAuditorCli(args, deps = {}) {
159
175
  return 2;
160
176
  }
161
177
  const result = persistent
162
- ? (0, container_spec_auditor_1.auditSanctuaryPersistentTemplate)({ templateXml, runtimePolicyText, expectedImageReference: persistent["--expected-image-reference"], mountContract })
163
- : (0, container_spec_auditor_1.auditSanctuaryStagedFiles)({ templateXml, runtimePolicyText, expectedImage: staged["--expected-image"], mountContract });
178
+ ? (0, container_spec_auditor_1.auditSanctuaryPersistentTemplate)({ templateXml, runtimePolicyText, expectedImageReference: persistent["--expected-image-reference"], mountContract, syncEnabled })
179
+ : (0, container_spec_auditor_1.auditSanctuaryStagedFiles)({ templateXml, runtimePolicyText, expectedImage: staged["--expected-image"], mountContract, syncEnabled });
164
180
  write(JSON.stringify(result) + "\n");
165
181
  return result.ok ? 0 : 1;
166
182
  }
@@ -15,6 +15,8 @@ const EXPECTED_BINDS = [
15
15
  "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro",
16
16
  // Operator-set grants, root-owned on the host and read-only inside the Butler (the Butler honours a grant only from here).
17
17
  "/mnt/user/appdata/ouro-butler/trust/sanctuary:/etc/ouro/trust/sanctuary:ro",
18
+ // The bundle sync deploy key, root-owned on the host and read-only inside the Butler. The key can push to the owner's repo.
19
+ "/mnt/user/appdata/ouro-butler/sync:/run/ouro-sync:ro",
18
20
  ];
19
21
  const EXPECTED_MOUNTS = [
20
22
  ["/mnt/user/appdata/ouro-butler/runtime/.ouro-cli", "/home/ouro/.ouro-cli", true, "rprivate"],
@@ -23,11 +25,25 @@ const EXPECTED_MOUNTS = [
23
25
  ["/run/ouro-authority", "/run/ouro-authority", false, "rprivate"],
24
26
  ["/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche", "/home/ouro/AgentBundles/sanctuary.ouro/psyche", false, "rprivate"],
25
27
  ["/mnt/user/appdata/ouro-butler/trust/sanctuary", "/etc/ouro/trust/sanctuary", false, "rprivate"],
28
+ ["/mnt/user/appdata/ouro-butler/sync", "/run/ouro-sync", false, "rprivate"],
26
29
  ];
27
30
  const PSYCHE_TARGET = "/home/ouro/AgentBundles/sanctuary.ouro/psyche";
28
31
  /** The current resident shape: the canonical-gateway set without the separate read-only psyche mount. */
29
32
  const GATEWAY_BINDS_WITHOUT_PSYCHE = EXPECTED_BINDS.filter((bind) => !bind.includes(`:${PSYCHE_TARGET}:`));
30
33
  const GATEWAY_MOUNTS_WITHOUT_PSYCHE = EXPECTED_MOUNTS.filter((mount) => mount[1] !== PSYCHE_TARGET);
34
+ const SYNC_TARGET = "/run/ouro-sync";
35
+ /**
36
+ * The canonical-gateway shapes. With sync on, the key mount is required. With sync off (the live Butler until the
37
+ * cutover, and a rollback to a release without the mount) each shape is accepted with or without it.
38
+ */
39
+ function gatewayBinds(syncEnabled) {
40
+ const shapes = [EXPECTED_BINDS, GATEWAY_BINDS_WITHOUT_PSYCHE];
41
+ return syncEnabled ? shapes : [...shapes, ...shapes.map((binds) => binds.filter((bind) => !bind.includes(`:${SYNC_TARGET}:`)))];
42
+ }
43
+ function gatewayMounts(syncEnabled) {
44
+ const shapes = [EXPECTED_MOUNTS, GATEWAY_MOUNTS_WITHOUT_PSYCHE];
45
+ return syncEnabled ? shapes : [...shapes, ...shapes.map((mounts) => mounts.filter((mount) => mount[1] !== SYNC_TARGET))];
46
+ }
31
47
  const PRE_GATEWAY_BINDS = EXPECTED_BINDS.slice(0, 3);
32
48
  const PRE_GATEWAY_MOUNTS = EXPECTED_MOUNTS.slice(0, 3);
33
49
  const LEGACY_ALPHA742_IMAGE = "sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d";
@@ -71,7 +87,7 @@ function auditSanctuaryContainerSpec(value, options) {
71
87
  violations.push("unsupported mount contract");
72
88
  if (mountContract === "legacy-alpha742" && options.expectedImage !== LEGACY_ALPHA742_IMAGE)
73
89
  violations.push("legacy mount exception requires the pinned alpha.742 image ID");
74
- const acceptedMounts = mountContract === "legacy-alpha742" ? [LEGACY_ALPHA742_MOUNTS] : mountContract === "canonical-pre-gateway" ? [PRE_GATEWAY_MOUNTS] : [EXPECTED_MOUNTS, GATEWAY_MOUNTS_WITHOUT_PSYCHE];
90
+ const acceptedMounts = mountContract === "legacy-alpha742" ? [LEGACY_ALPHA742_MOUNTS] : mountContract === "canonical-pre-gateway" ? [PRE_GATEWAY_MOUNTS] : gatewayMounts(options.syncEnabled === true);
75
91
  const expectedArgs = mountContract !== "legacy-alpha742" ? PACKAGE_DAEMON_ARGS : LEGACY_DAEMON_ARGS;
76
92
  const expectedEntrypoint = mountContract !== "legacy-alpha742" ? PACKAGE_ENTRYPOINT : LEGACY_ENTRYPOINT;
77
93
  if (root.Image !== options.expectedImage)
@@ -180,7 +196,7 @@ function auditTemplate(input, expectedRepository, repositoryIsValid, repositoryV
180
196
  const violations = [];
181
197
  if (input.mountContract !== "canonical-pre-gateway" && input.mountContract !== "canonical-gateway")
182
198
  violations.push("unsupported template mount contract");
183
- const acceptedBinds = input.mountContract === "canonical-pre-gateway" ? [PRE_GATEWAY_BINDS] : [EXPECTED_BINDS, GATEWAY_BINDS_WITHOUT_PSYCHE];
199
+ const acceptedBinds = input.mountContract === "canonical-pre-gateway" ? [PRE_GATEWAY_BINDS] : gatewayBinds(input.syncEnabled === true);
184
200
  const template = parseDockerManTemplateXml(input.templateXml);
185
201
  if (!template)
186
202
  violations.push("canonical DockerMan XML structure is invalid");
@@ -84,6 +84,8 @@ const sanctuary_scheduler_liveness_1 = require("./sanctuary-scheduler-liveness")
84
84
  const sanctuary_scheduler_origin_1 = require("./sanctuary-scheduler-origin");
85
85
  const telegram_1 = require("../../senses/telegram");
86
86
  const sync_health_1 = require("../sync-health");
87
+ const sync_1 = require("../sync");
88
+ const sync_lock_1 = require("../sync-lock");
87
89
  function parseSocketPath(argv) {
88
90
  const socketIndex = argv.indexOf("--socket");
89
91
  if (socketIndex >= 0) {
@@ -701,6 +703,23 @@ void (0, daemon_bootstrap_startup_1.startDaemonAfterContainerCredentialBootstrap
701
703
  supercronicSupervisor?.start();
702
704
  const providerPreload = Promise.resolve();
703
705
  const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
706
+ // The daemon is PID 1 on every container boot, so a lock an earlier boot left looks like ours.
707
+ // Clearing a lock never stops the boot; an unreadable one is judged again by the next sync.
708
+ try {
709
+ for (const row of (0, agent_discovery_1.listBundleSyncRows)({ bundlesRoot })) {
710
+ if (!row.enabled || !row.gitInitialized)
711
+ continue;
712
+ try {
713
+ (0, sync_lock_1.clearOwnStaleSyncLock)(path.join(bundlesRoot, `${row.agent}.ouro`));
714
+ }
715
+ catch {
716
+ // Next bundle.
717
+ }
718
+ }
719
+ }
720
+ catch {
721
+ // The bundle list could not be read.
722
+ }
704
723
  const ouroPath = supercronicSupervisor
705
724
  ? "/usr/local/bin/node /opt/ouro/dist/heart/daemon/ouro-entry.js"
706
725
  : (0, os_cron_deps_1.resolveOuroBinaryPath)();
@@ -964,6 +983,7 @@ process.on("SIGINT", () => {
964
983
  // daemon kept dying. Tombstones are informational — having a "sigint"
965
984
  // tombstone is strictly better than silence.
966
985
  _tombstoneWritten = true;
986
+ (0, sync_1.abortInFlightSync)();
967
987
  (0, daemon_tombstone_1.writeDaemonTombstone)("sigint", new Error("daemon received SIGINT"));
968
988
  const forcedExit = setTimeout(() => process.exit(1), 12_000);
969
989
  forcedExit.unref();
@@ -977,6 +997,8 @@ process.on("SIGINT", () => {
977
997
  });
978
998
  process.on("SIGTERM", () => {
979
999
  _tombstoneWritten = true;
1000
+ // Kill sync's git process groups and release the bundle locks before anything slower.
1001
+ (0, sync_1.abortInFlightSync)();
980
1002
  (0, daemon_tombstone_1.writeDaemonTombstone)("sigterm", new Error("daemon received SIGTERM"));
981
1003
  const forcedExit = setTimeout(() => process.exit(1), 12_000);
982
1004
  forcedExit.unref();
@@ -60,7 +60,7 @@ function exactSanctuaryContainmentProfileBoundaries(value) {
60
60
  if (!value || typeof value !== "object" || Array.isArray(value))
61
61
  return false;
62
62
  const boundaries = value;
63
- const versions = { "sanctuary-owner": 17, "sanctuary-household": 5, "sanctuary-event": 4 };
63
+ const versions = { "sanctuary-owner": 18, "sanctuary-household": 5, "sanctuary-event": 4 };
64
64
  if (!(0, node_util_1.isDeepStrictEqual)(Object.keys(boundaries).sort(), Object.keys(versions).sort()))
65
65
  return false;
66
66
  const packageRoot = path.resolve(__dirname, "../../../deploy/unraid/sanctuary.ouro");
@@ -36,6 +36,7 @@ var _a;
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.SanctuaryAuthorityRootLifecycle = exports.SANCTUARY_UPGRADE_STEPS = exports.SANCTUARY_KEEPER_BOOT_LINES = void 0;
38
38
  exports.predecessorExpectsPsycheMount = predecessorExpectsPsycheMount;
39
+ exports.predecessorHasBundleSync = predecessorHasBundleSync;
39
40
  exports.recordSanctuaryRootLifecycleFailure = recordSanctuaryRootLifecycleFailure;
40
41
  exports.runSanctuaryAuthorityRootCli = runSanctuaryAuthorityRootCli;
41
42
  const node_child_process_1 = require("node:child_process");
@@ -57,6 +58,8 @@ const OFFSET = `${BUNDLE}/state/senses/telegram/offset.json`;
57
58
  const SOCKET = "/run/ouro-authority";
58
59
  // Operator-set grants, root-owned on the host and mounted read-only into the Butler at /etc/ouro/trust/sanctuary.
59
60
  const TRUST = "/mnt/user/appdata/ouro-butler/trust/sanctuary";
61
+ // The bundle sync deploy key's host folder, mounted read-only where the sync wrapper reads it (from alpha.991).
62
+ const SYNC = "/mnt/user/appdata/ouro-butler/sync";
60
63
  const STAGING = "/var/lib/ouro-authority/staging";
61
64
  const CGROUP = "/sys/fs/cgroup/ouro-authority";
62
65
  const BOOT = "/boot/config/custom/ouro-authority/start.sh";
@@ -855,7 +858,7 @@ class SanctuaryAuthorityRootLifecycle {
855
858
  const steps = {
856
859
  stop: () => this.#halt(),
857
860
  switch: async () => { await this.#halt(); this.#switchPackage(journal); },
858
- resident: () => this.#recreateResident(journal.to.imageReference, journal.to.imageId, { psycheMount: false }),
861
+ resident: () => this.#recreateResident(journal.to.imageReference, journal.to.imageId, { psycheMount: false, syncMount: true }),
859
862
  migrate: () => target().#bundle(["--operation", "migrate", "--rollback-image-id", journal.from.imageId, "--target-image-id", journal.to.imageId]),
860
863
  start: () => target().#startUpgraded(),
861
864
  };
@@ -918,7 +921,7 @@ class SanctuaryAuthorityRootLifecycle {
918
921
  predecessor.#publishBoot();
919
922
  if (this.#containers().find((container) => container.Name === "/ouro-butler")?.Image !== journal.from.imageId) {
920
923
  // A predecessor up to alpha.978 expects its psyche read-only over the bundle; alpha.979 and later own a writable one.
921
- predecessor.#recreateResident(journal.from.imageReference, journal.from.imageId, { psycheMount: predecessorExpectsPsycheMount(journal.from.imageReference) });
924
+ predecessor.#recreateResident(journal.from.imageReference, journal.from.imageId, { psycheMount: predecessorExpectsPsycheMount(journal.from.imageReference), syncMount: predecessorHasBundleSync(journal.from.imageReference) });
922
925
  }
923
926
  await predecessor.#startUpgraded(PREDECESSOR_READY_TIMEOUT_MS);
924
927
  this.#remove(UPGRADE);
@@ -1027,8 +1030,12 @@ class SanctuaryAuthorityRootLifecycle {
1027
1030
  #publishBoot() {
1028
1031
  this.#write(BOOT, fs.readFileSync(this.#p(`${ROOT}/package/deploy/unraid/sanctuary-authority-service.sh`), "utf8"));
1029
1032
  }
1030
- /** `psycheMount` adds the read-only psyche mount that releases up to alpha.978 expect; only a rollback to such a release passes true. */
1031
- #recreateResident(reference, imageId, { psycheMount }) {
1033
+ /**
1034
+ * `psycheMount` adds the read-only psyche mount that releases up to alpha.978 expect; only a rollback to such a release
1035
+ * passes true. `syncMount` adds the read-only bundle sync key mount that releases from alpha.991 carry; an older
1036
+ * release's audit refuses it.
1037
+ */
1038
+ #recreateResident(reference, imageId, { psycheMount, syncMount }) {
1032
1039
  if (this.#containers().some((container) => container.Name === "/ouro-butler"))
1033
1040
  this.#docker(["rm", "-f", "ouro-butler"]);
1034
1041
  this.#docker(["create", "--name", "ouro-butler", "--network", "host", "--restart", "unless-stopped", "--user", "10001:10001",
@@ -1036,7 +1043,8 @@ class SanctuaryAuthorityRootLifecycle {
1036
1043
  "-v", `${RUNTIME}:/home/ouro/.ouro-cli:rw`, "-v", `${BUNDLE}:/home/ouro/AgentBundles/sanctuary.ouro:rw`,
1037
1044
  "-v", `${EVENTS}:/run/ouro-events:ro`, "-v", `${SOCKET}:${SOCKET}:ro`,
1038
1045
  ...(psycheMount ? ["-v", `${BUNDLE}/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro`] : []),
1039
- "-v", `${TRUST}:/etc/ouro/trust/sanctuary:ro`, reference]);
1046
+ "-v", `${TRUST}:/etc/ouro/trust/sanctuary:ro`,
1047
+ ...(syncMount ? ["-v", `${SYNC}:/run/ouro-sync:ro`] : []), reference]);
1040
1048
  const resident = this.#containers().find((container) => container.Name === "/ouro-butler");
1041
1049
  if (resident?.Image !== imageId || resident.State.Running)
1042
1050
  throw new Error("Sanctuary resident recreation readback failed");
@@ -1260,6 +1268,14 @@ function predecessorExpectsPsycheMount(imageReference) {
1260
1268
  const release = /^0\.1\.0-alpha\.(\d+)$/u.exec(imageReference.slice(imageReference.lastIndexOf(":") + 1));
1261
1269
  return release !== null && Number(release[1]) <= 978;
1262
1270
  }
1271
+ /** Whether a release's image carries bundle sync (alpha.991 or later, or a release with no prerelease tag), so its resident gets the sync key mount. */
1272
+ function predecessorHasBundleSync(imageReference) {
1273
+ const tag = imageReference.slice(imageReference.lastIndexOf(":") + 1);
1274
+ if (/^\d+\.\d+\.\d+$/u.test(tag))
1275
+ return true;
1276
+ const release = /^0\.1\.0-alpha\.(\d+)$/u.exec(tag);
1277
+ return release !== null && Number(release[1]) >= 991;
1278
+ }
1263
1279
  /**
1264
1280
  * Keep the real failure where only root can read it (D-034). Stderr stays generic
1265
1281
  * because a parse error can quote private bytes; the authority root is as private