@pleri/olam-cli 0.1.135 → 0.1.137

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.
@@ -11142,7 +11142,7 @@ function loadRepoManifest(repoDir) {
11142
11142
  }
11143
11143
  return { ...body, source };
11144
11144
  }
11145
- var runtimeSchema, appSchema, serviceSchema, deploySchema, BootstrapKindSchema, BootstrapStepSchema, RepoManifestSchema, KNOWN_TOP_LEVEL_KEYS, FORBIDDEN_KEYS;
11145
+ var runtimeSchema, appSchema, serviceSchema, deploySchema, planChatSchema, BootstrapKindSchema, BootstrapStepSchema, RepoManifestSchema, KNOWN_TOP_LEVEL_KEYS, FORBIDDEN_KEYS;
11146
11146
  var init_repo_manifest = __esm({
11147
11147
  "../core/dist/world/repo-manifest.js"() {
11148
11148
  "use strict";
@@ -11248,6 +11248,9 @@ var init_repo_manifest = __esm({
11248
11248
  deploySchema = external_exports.object({
11249
11249
  tags: external_exports.array(external_exports.string()).optional()
11250
11250
  }).passthrough();
11251
+ planChatSchema = external_exports.object({
11252
+ enabled: external_exports.boolean()
11253
+ }).strict();
11251
11254
  BootstrapKindSchema = external_exports.enum(["gems", "node", "pg"]);
11252
11255
  BootstrapStepSchema = external_exports.union([
11253
11256
  external_exports.string(),
@@ -11270,7 +11273,8 @@ var init_repo_manifest = __esm({
11270
11273
  secrets: external_exports.string().optional(),
11271
11274
  bootstrap: external_exports.array(BootstrapStepSchema).optional(),
11272
11275
  start: external_exports.string().optional(),
11273
- deploy: deploySchema.optional()
11276
+ deploy: deploySchema.optional(),
11277
+ plan_chat: planChatSchema.optional()
11274
11278
  // TODO(phase-C): runtime field consumed by stack-install.ts (T16).
11275
11279
  // TODO(phase-D): app.fixed (sticky port) consumed by port-allocation.
11276
11280
  // TODO(phase-D): idempotent_check on bootstrap steps in bootstrap-runner.
@@ -11278,11 +11282,11 @@ var init_repo_manifest = __esm({
11278
11282
  }).passthrough().superRefine((val, ctx) => {
11279
11283
  refineForbiddenKeys(val, [], ctx, true);
11280
11284
  }).superRefine((val, ctx) => {
11281
- const hasContent = val.version !== void 0 || val.runtime !== void 0 || val.app !== void 0 || val.services !== void 0 || val.env !== void 0 || val.secrets !== void 0 || val.bootstrap !== void 0 || val.start !== void 0 || val.deploy !== void 0;
11285
+ const hasContent = val.version !== void 0 || val.runtime !== void 0 || val.app !== void 0 || val.services !== void 0 || val.env !== void 0 || val.secrets !== void 0 || val.bootstrap !== void 0 || val.start !== void 0 || val.deploy !== void 0 || val.plan_chat !== void 0;
11282
11286
  if (!hasContent) {
11283
11287
  ctx.addIssue({
11284
11288
  code: external_exports.ZodIssueCode.custom,
11285
- message: "Manifest must declare at least one of: version, runtime, app, services, env, secrets, bootstrap, start, deploy"
11289
+ message: "Manifest must declare at least one of: version, runtime, app, services, env, secrets, bootstrap, start, deploy, plan_chat"
11286
11290
  });
11287
11291
  }
11288
11292
  });
@@ -11296,6 +11300,10 @@ var init_repo_manifest = __esm({
11296
11300
  "bootstrap",
11297
11301
  "start",
11298
11302
  "deploy",
11303
+ // olam-plan-chat-chunks-substrate Phase A task A4: opt-in for the chunks
11304
+ // thought substrate. Read by `resolveThoughtSubstrate` in
11305
+ // `packages/core/src/thought/substrate-router.ts`.
11306
+ "plan_chat",
11299
11307
  // F-7 (olam-hybrid-shared-postgres dogfood finding): native inheritance —
11300
11308
  // `.olam.yaml` may declare `inherits: .adb.yaml` to deep-merge an adb-shaped
11301
11309
  // base manifest into itself. Retires the atlas-one `bin/olam-create-wrap.sh`
@@ -11576,7 +11584,24 @@ var init_schema2 = __esm({
11576
11584
  * (B1/C4) reads this file and forwards the value as
11577
11585
  * `AGENTMEMORY_SECRET` into worlds.
11578
11586
  */
11579
- secret_ref: external_exports.string().min(1).optional().default("~/.olam/cloud-memory-secret")
11587
+ secret_ref: external_exports.string().min(1).optional().default("~/.olam/cloud-memory-secret"),
11588
+ /**
11589
+ * DO SQLite write-through bridge toggle. Defaults to `true` (bridge
11590
+ * active — every state-mutating call captures the engine's export to
11591
+ * a DO snapshot, restored on cold-start).
11592
+ *
11593
+ * Operators set `bridge: false` to bisect bridge vs engine bugs:
11594
+ * the CLI threads this into the Worker as `OLAM_BRIDGE_DISABLED=1`,
11595
+ * which makes `AgentMemoryContainer.fetch` a plain pass-through
11596
+ * (no capture, no restore). The next deploy with `bridge: true`
11597
+ * resumes captures automatically.
11598
+ *
11599
+ * See `docs/design/olam-agent-memory-do-bridge-schema.md` for the
11600
+ * full storage contract; the bridge is OFF only as a diagnosis aid.
11601
+ *
11602
+ * Plan reference: docs/plans/olam-agent-memory-do-sqlite-bridge/phase-c-tasks.md C3
11603
+ */
11604
+ bridge: external_exports.boolean().optional().default(true)
11580
11605
  });
11581
11606
  MemorySchema = external_exports.object({
11582
11607
  mode: external_exports.enum(["local", "cloud"]).optional().default("local"),
@@ -24222,6 +24247,10 @@ var createWorldContainer = async (docker, worldId, worldName, image, env, resour
24222
24247
  opts.push(`size=${m.size}`);
24223
24248
  if (m.mode !== void 0)
24224
24249
  opts.push(`mode=${m.mode.toString(8).padStart(4, "0")}`);
24250
+ if (m.uid !== void 0)
24251
+ opts.push(`uid=${m.uid}`);
24252
+ if (m.gid !== void 0)
24253
+ opts.push(`gid=${m.gid}`);
24225
24254
  acc[m.target] = opts.join(",");
24226
24255
  return acc;
24227
24256
  }, {})
@@ -30679,6 +30708,119 @@ function createServer4(ctx, initError) {
30679
30708
  return server;
30680
30709
  }
30681
30710
 
30711
+ // ../mcp-server/src/utils/native-probe.ts
30712
+ import { createRequire as createRequire4 } from "node:module";
30713
+ var PROBE_REQUIRE = createRequire4(import.meta.url);
30714
+ function runtimeModuleVersion() {
30715
+ return Number.parseInt(process.versions.modules, 10);
30716
+ }
30717
+ function parseAbiMessage(message) {
30718
+ const compiledMatch = message.match(/NODE_MODULE_VERSION\s+(\d+)/);
30719
+ const requiredMatch = message.match(/requires\s+NODE_MODULE_VERSION\s+(\d+)/);
30720
+ const compiledRaw = compiledMatch?.[1];
30721
+ const requiredRaw = requiredMatch?.[1];
30722
+ return {
30723
+ compiledFor: compiledRaw !== void 0 ? Number.parseInt(compiledRaw, 10) : void 0,
30724
+ required: requiredRaw !== void 0 ? Number.parseInt(requiredRaw, 10) : void 0
30725
+ };
30726
+ }
30727
+ function classifyLoadError(err, bindingPath) {
30728
+ const message = typeof err.message === "string" ? err.message : "";
30729
+ if (err.code === "MODULE_NOT_FOUND" || /Cannot find module 'better-sqlite3'/.test(message)) {
30730
+ return {
30731
+ kind: "missing",
30732
+ diagnostic: formatMissingPackage(err)
30733
+ };
30734
+ }
30735
+ const abi = parseAbiMessage(message);
30736
+ if (err.code === "ERR_DLOPEN_FAILED" || abi.compiledFor !== void 0 || abi.required !== void 0) {
30737
+ return {
30738
+ kind: "abi",
30739
+ diagnostic: formatAbiMismatch(err, abi.compiledFor, abi.required, bindingPath),
30740
+ compiledFor: abi.compiledFor,
30741
+ required: abi.required
30742
+ };
30743
+ }
30744
+ return { kind: "unclassified", diagnostic: "" };
30745
+ }
30746
+ function formatMissingPackage(err) {
30747
+ return [
30748
+ "",
30749
+ "\u274C MCP olam server cannot start.",
30750
+ " Reason: better-sqlite3 native module is not installed.",
30751
+ ` Runtime: ${process.version} (NODE_MODULE_VERSION ${runtimeModuleVersion()})`,
30752
+ ` Detail: ${err.message}`,
30753
+ "",
30754
+ "Fix:",
30755
+ " Run `npm install` in the workspace that hosts this binary,",
30756
+ " or reinstall the CLI: `npm install -g @pleri/olam-cli`.",
30757
+ ""
30758
+ ].join("\n");
30759
+ }
30760
+ function formatAbiMismatch(err, compiledFor, required2, bindingPath) {
30761
+ const lines = [
30762
+ "",
30763
+ "\u274C MCP olam server cannot start.",
30764
+ " Reason: better-sqlite3 native binding is ABI-incompatible.",
30765
+ ` Runtime: ${process.version} (NODE_MODULE_VERSION ${required2 ?? runtimeModuleVersion()})`
30766
+ ];
30767
+ if (compiledFor !== void 0) {
30768
+ lines.push(` Binding: compiled for NODE_MODULE_VERSION ${compiledFor}`);
30769
+ }
30770
+ lines.push(` Path: ${bindingPath}`);
30771
+ lines.push(` Node: ${process.execPath}`);
30772
+ lines.push("");
30773
+ lines.push("Fix:");
30774
+ lines.push(" Rebuild the native binding against the runtime Node version:");
30775
+ lines.push(" npm rebuild better-sqlite3");
30776
+ lines.push("");
30777
+ lines.push(" If you installed under a different Node version (via nvm/asdf/mise),");
30778
+ lines.push(" switch to that version first, then rebuild, OR reinstall the package");
30779
+ lines.push(" so prebuild-install fetches the correct binary for the current Node.");
30780
+ lines.push("");
30781
+ lines.push(` Underlying error: ${err.message.split("\n")[0]}`);
30782
+ lines.push("");
30783
+ return lines.join("\n");
30784
+ }
30785
+ function defaultResolver() {
30786
+ try {
30787
+ return PROBE_REQUIRE.resolve("better-sqlite3");
30788
+ } catch {
30789
+ return "(unresolved)";
30790
+ }
30791
+ }
30792
+ function defaultLoader() {
30793
+ return PROBE_REQUIRE("better-sqlite3");
30794
+ }
30795
+ function defaultEmit(message) {
30796
+ process.stderr.write(message.endsWith("\n") ? message : `${message}
30797
+ `);
30798
+ }
30799
+ var defaultExit = (code) => process.exit(code);
30800
+ function assertBetterSqlite3Loadable(deps = {}) {
30801
+ const loader = deps.loader ?? defaultLoader;
30802
+ const resolver = deps.resolver ?? defaultResolver;
30803
+ const emit = deps.emit ?? defaultEmit;
30804
+ const exit = deps.exit ?? defaultExit;
30805
+ try {
30806
+ const Database = loader();
30807
+ const db = new Database(":memory:");
30808
+ try {
30809
+ db.prepare("SELECT 1").get();
30810
+ } finally {
30811
+ db.close();
30812
+ }
30813
+ } catch (raw) {
30814
+ const err = raw;
30815
+ const classified = classifyLoadError(err, resolver());
30816
+ if (classified.kind === "unclassified") {
30817
+ throw err;
30818
+ }
30819
+ emit(classified.diagnostic);
30820
+ exit(1);
30821
+ }
30822
+ }
30823
+
30682
30824
  // ../mcp-server/src/index.ts
30683
30825
  init_loader();
30684
30826
 
@@ -31707,18 +31849,18 @@ function computeFingerprint(runtimes) {
31707
31849
  const joined = parts.join("_");
31708
31850
  return sanitizeTag(joined);
31709
31851
  }
31710
- function computeImageTag(runtimes) {
31711
- const baseDigest = getBaseImageDigest();
31852
+ function computeImageTag(runtimes, baseImageRef = `${BASE_IMAGE}:latest`) {
31853
+ const baseDigest = getImageDigest(baseImageRef);
31712
31854
  const prefix = baseDigest.slice(0, 8);
31713
31855
  const fingerprint = computeFingerprint(runtimes);
31714
31856
  const tag = `${prefix}_${fingerprint}`;
31715
31857
  return sanitizeTag(tag);
31716
31858
  }
31717
- function computeImageName(runtimes) {
31718
- return `${BASE_IMAGE}:${computeImageTag(runtimes)}`;
31859
+ function computeImageName(runtimes, baseImageRef = `${BASE_IMAGE}:latest`) {
31860
+ return `${BASE_IMAGE}:${computeImageTag(runtimes, baseImageRef)}`;
31719
31861
  }
31720
- function lookupCachedImage(runtimes) {
31721
- const tag = computeImageTag(runtimes);
31862
+ function lookupCachedImage(runtimes, baseImageRef = `${BASE_IMAGE}:latest`) {
31863
+ const tag = computeImageTag(runtimes, baseImageRef);
31722
31864
  const imageName = `${BASE_IMAGE}:${tag}`;
31723
31865
  try {
31724
31866
  execSync3(`docker image inspect ${imageName} > /dev/null 2>&1`, {
@@ -31735,19 +31877,25 @@ function commitAsImage(containerName, imageName) {
31735
31877
  const now = (/* @__PURE__ */ new Date()).toISOString();
31736
31878
  execSync3(`docker commit --change 'LABEL ${LABEL_PREFIX}=true' --change 'LABEL ${LABEL_PREFIX}.created-at=${now}' --change 'LABEL ${LABEL_PREFIX}.base-digest=${baseDigest}' ${containerName} ${imageName}`, { stdio: "pipe", timeout: 12e4 });
31737
31879
  }
31738
- var cachedBaseDigest;
31739
- function getBaseImageDigest() {
31740
- if (cachedBaseDigest)
31741
- return cachedBaseDigest;
31880
+ var cachedImageDigests = /* @__PURE__ */ new Map();
31881
+ function getImageDigest(imageRef) {
31882
+ const memo = cachedImageDigests.get(imageRef);
31883
+ if (memo)
31884
+ return memo;
31742
31885
  try {
31743
- const digest = execSync3(`docker inspect ${BASE_IMAGE}:latest --format '{{.Id}}'`, { encoding: "utf-8", timeout: 5e3 }).trim();
31744
- cachedBaseDigest = digest.replace("sha256:", "").slice(0, 16);
31745
- return cachedBaseDigest;
31886
+ const digest = execSync3(`docker inspect ${imageRef} --format '{{.Id}}'`, { encoding: "utf-8", timeout: 5e3 }).trim();
31887
+ const short = digest.replace("sha256:", "").slice(0, 16);
31888
+ cachedImageDigests.set(imageRef, short);
31889
+ return short;
31746
31890
  } catch {
31747
- cachedBaseDigest = crypto4.createHash("sha256").update("unknown").digest("hex").slice(0, 16);
31748
- return cachedBaseDigest;
31891
+ const fallback = crypto4.createHash("sha256").update(imageRef).digest("hex").slice(0, 16);
31892
+ cachedImageDigests.set(imageRef, fallback);
31893
+ return fallback;
31749
31894
  }
31750
31895
  }
31896
+ function getBaseImageDigest() {
31897
+ return getImageDigest(`${BASE_IMAGE}:latest`);
31898
+ }
31751
31899
  function sanitizeTag(raw) {
31752
31900
  let tag = raw.toLowerCase().replace(/[^a-z0-9._-]/g, "-");
31753
31901
  if (tag.length > MAX_TAG_LENGTH) {
@@ -33636,9 +33784,32 @@ ${detail}`);
33636
33784
  }
33637
33785
  }
33638
33786
  const repoSecretUrls = enrichedRepos.filter((r) => typeof r.manifest?.secrets === "string" && r.manifest.secrets.length > 0).map((r) => ({ repoName: r.name, secretsUrl: r.manifest.secrets }));
33639
- let stackCacheHit = false;
33640
33787
  let selectedImage;
33788
+ let cacheArchOverride;
33789
+ const selected = selectDevboxImageForWorld({
33790
+ config: this.config,
33791
+ repos,
33792
+ worldspec: opts.worldspec
33793
+ });
33794
+ if (selected) {
33795
+ selectedImage = selected.image;
33796
+ cacheArchOverride = selected.cacheArch;
33797
+ if (selected.source === "worldspec") {
33798
+ console.log(`[WorldManager] worldspec override \u2014 using ${selected.image} (tag=${selected.tag})`);
33799
+ } else {
33800
+ console.log(`[WorldManager] image_selector matched \u2014 using ${selected.image} (tag=${selected.tag}${selected.cacheArch ? `, cache_arch=${selected.cacheArch}` : ""})`);
33801
+ }
33802
+ } else {
33803
+ const hasRailsRepo = repos.some((r) => r.type === "rails");
33804
+ if (hasRailsRepo) {
33805
+ selectedImage = resolveDevboxImage(this.config, "amd64");
33806
+ cacheArchOverride = "x64";
33807
+ console.log(`[WorldManager] Rails repo detected \u2014 using ${selectedImage} + x64 mise-cache (Rosetta path)`);
33808
+ }
33809
+ }
33810
+ let stackCacheHit = false;
33641
33811
  const preDetectedStacks = /* @__PURE__ */ new Map();
33812
+ const cacheBaseRef = selectedImage ?? "olam-devbox:latest";
33642
33813
  if (this.provider.capabilities.supportsCustomImages) {
33643
33814
  try {
33644
33815
  const hostExec = makeHostExecFn();
@@ -33653,7 +33824,7 @@ ${detail}`);
33653
33824
  }
33654
33825
  const runtimes = collectUniqueRuntimes(Array.from(preDetectedStacks.values()));
33655
33826
  if (runtimes.size > 0) {
33656
- const cacheResult = lookupCachedImage(runtimes);
33827
+ const cacheResult = lookupCachedImage(runtimes, cacheBaseRef);
33657
33828
  if (cacheResult.hit) {
33658
33829
  selectedImage = cacheResult.imageName;
33659
33830
  stackCacheHit = true;
@@ -33667,30 +33838,6 @@ ${detail}`);
33667
33838
  console.warn(`[WorldManager] host-side stack detection failed: ${msg}`);
33668
33839
  }
33669
33840
  }
33670
- let cacheArchOverride;
33671
- if (!stackCacheHit) {
33672
- const selected = selectDevboxImageForWorld({
33673
- config: this.config,
33674
- repos,
33675
- worldspec: opts.worldspec
33676
- });
33677
- if (selected) {
33678
- selectedImage = selected.image;
33679
- cacheArchOverride = selected.cacheArch;
33680
- if (selected.source === "worldspec") {
33681
- console.log(`[WorldManager] worldspec override \u2014 using ${selected.image} (tag=${selected.tag})`);
33682
- } else {
33683
- console.log(`[WorldManager] image_selector matched \u2014 using ${selected.image} (tag=${selected.tag}${selected.cacheArch ? `, cache_arch=${selected.cacheArch}` : ""})`);
33684
- }
33685
- } else {
33686
- const hasRailsRepo = repos.some((r) => r.type === "rails");
33687
- if (hasRailsRepo) {
33688
- selectedImage = resolveDevboxImage(this.config, "amd64");
33689
- cacheArchOverride = "x64";
33690
- console.log(`[WorldManager] Rails repo detected \u2014 using ${selectedImage} + x64 mise-cache (Rosetta path)`);
33691
- }
33692
- }
33693
- }
33694
33841
  const appPorts = [];
33695
33842
  for (const repo of enrichedRepos) {
33696
33843
  const manifestPort = repo.manifest?.app?.port;
@@ -33844,12 +33991,22 @@ ${detail}`);
33844
33991
  ...extraNetworks ? { extraNetworks } : {},
33845
33992
  // Closes SEC-002 residual: hybrid worlds get a tmpfs mount at
33846
33993
  // /run/olam to receive the per-world postgres credentials. Size
33847
- // 256K is generous for a few env files; mode 0700 means only
33848
- // root (the container's PID 1, which then drops to the app user
33849
- // via the entrypoint) can list the directory. Non-hybrid worlds
33850
- // skip this entirely no behavior change.
33994
+ // 256K is generous for a few env files; mode 0700 keeps the dir
33995
+ // owner-only. uid/gid 999 matches the `olam` user from the Phase
33996
+ // E E4 base image contract — without setting these, the tmpfs is
33997
+ // root-owned and `docker exec` (which runs as the image's USER,
33998
+ // i.e. olam) hits EACCES on credential writes. Non-hybrid worlds
33999
+ // skip the mount entirely — no behavior change.
33851
34000
  ...tmpfsPostgresCredContent ? {
33852
- tmpfsMounts: [{ target: "/run/olam", size: 256 * 1024, mode: 448 }],
34001
+ tmpfsMounts: [
34002
+ {
34003
+ target: "/run/olam",
34004
+ size: 256 * 1024,
34005
+ mode: 448,
34006
+ uid: 999,
34007
+ gid: 999
34008
+ }
34009
+ ],
33853
34010
  tmpfsCredentialWrites: [
33854
34011
  { path: "/run/olam/postgres.env", content: tmpfsPostgresCredContent }
33855
34012
  ]
@@ -35915,6 +36072,7 @@ function loadProjectEnv(startDir = process.cwd()) {
35915
36072
 
35916
36073
  // ../mcp-server/src/index.ts
35917
36074
  async function main() {
36075
+ assertBetterSqlite3Loadable();
35918
36076
  let ctx;
35919
36077
  let initError;
35920
36078
  try {
@@ -0,0 +1,92 @@
1
+ // Bearer-secret management for plan-chat-service.mjs.
2
+ //
3
+ // Mirrors the agent-memory-service pattern from the sibling olam-agent-memory
4
+ // repo: a single 0600 file at ~/.olam/plan-chat-secret holds the bearer
5
+ // hex string. Helpers generate, read, and rotate atomically. Rotation
6
+ // writes to a tmpfile and renames; mid-rotation reads see either the old
7
+ // or new value, never a partial write.
8
+
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+
14
+ export const SECRET_DIR = path.join(os.homedir(), '.olam');
15
+ export const SECRET_PATH = path.join(SECRET_DIR, 'plan-chat-secret');
16
+ const SECRET_BYTES = 32; // 64 hex chars
17
+ const SECRET_MODE = 0o600;
18
+
19
+ /**
20
+ * Generate a fresh hex bearer (64 chars; 256 bits of entropy).
21
+ */
22
+ export function generateSecret() {
23
+ return crypto.randomBytes(SECRET_BYTES).toString('hex');
24
+ }
25
+
26
+ /**
27
+ * Read the on-disk bearer. Returns null if absent. Throws on permission errors.
28
+ */
29
+ export function readSecret(secretPath = SECRET_PATH) {
30
+ try {
31
+ const value = fs.readFileSync(secretPath, 'utf8').trim();
32
+ if (!value) return null;
33
+ return value;
34
+ } catch (err) {
35
+ if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') return null;
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Write the bearer to disk atomically. Creates `~/.olam` if missing. Enforces
42
+ * 0600 perms on the destination (older mode permissions on the tmpfile are
43
+ * tightened immediately after write).
44
+ */
45
+ export function writeSecret(value, secretPath = SECRET_PATH) {
46
+ if (typeof value !== 'string' || value.length === 0) {
47
+ throw new Error('plan-chat-secret: refusing to write empty bearer');
48
+ }
49
+ fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
50
+ const tmp = `${secretPath}.tmp-${process.pid}-${Date.now()}`;
51
+ fs.writeFileSync(tmp, value + '\n', { mode: SECRET_MODE });
52
+ try {
53
+ fs.chmodSync(tmp, SECRET_MODE);
54
+ fs.renameSync(tmp, secretPath);
55
+ } catch (err) {
56
+ try { fs.unlinkSync(tmp); } catch { /* swallow */ }
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Read the bearer if it exists, else generate, write, and return it.
63
+ * Idempotent across processes; first writer wins (rename is atomic).
64
+ */
65
+ export function ensureSecret(secretPath = SECRET_PATH) {
66
+ const existing = readSecret(secretPath);
67
+ if (existing) return existing;
68
+ const fresh = generateSecret();
69
+ writeSecret(fresh, secretPath);
70
+ return fresh;
71
+ }
72
+
73
+ /**
74
+ * Rotate: generate a new bearer, write atomically, return the new value.
75
+ * Callers should restart any running plan-chat-service so it re-reads.
76
+ */
77
+ export function rotateSecret(secretPath = SECRET_PATH) {
78
+ const fresh = generateSecret();
79
+ writeSecret(fresh, secretPath);
80
+ return fresh;
81
+ }
82
+
83
+ /**
84
+ * Constant-time compare. Returns true iff both strings are non-empty and
85
+ * byte-equal. Avoids leaking timing on bearer comparison.
86
+ */
87
+ export function timingSafeEqual(a, b) {
88
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
89
+ if (a.length === 0 || b.length === 0) return false;
90
+ if (a.length !== b.length) return false;
91
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
92
+ }