@evident-ai/runner-synchroniser 3.5.2-dev.507f0a3 → 3.5.2-dev.7130f36

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 (3) hide show
  1. package/README.md +43 -9
  2. package/dist/cli.js +434 -74
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -56,7 +56,7 @@ build time.
56
56
  | `env` | resolved config as shell-eval'able vars | `0` = ran; non-zero = tool broken |
57
57
  | `litestream-config` | the generated `litestream.yml` | `0` = ran; non-zero = tool broken |
58
58
  | `restore <claude\|opencode>` | — | `0` = ran; non-zero = tool broken |
59
- | `sync-once <claude\|opencode>` | — | `0` = uploaded/unchanged/absent/disabled; `40` = not persisted; other = tool broken |
59
+ | `sync-once <claude\|opencode>` | — | `0` = uploaded (per-runner) or published/adopted/unchanged/absent/disabled (shared vault); `40` = not persisted this tick; other = tool broken |
60
60
  | `model-auth-ready` | — | `0` = ready, `10` = not ready; other = tool broken |
61
61
  | `self-stop` | — | `0` = stopped, `20` = keep the task; other = tool broken |
62
62
  | `session-db-classify <litestream-restore-exit-code> <attempt> [--on-unusable-replica=<prune\|leave\|clear\|crash>] [--recovery-occurred] [--fresh-db-fallback]` | — | `0` = restored/no-replica/disabled, `32` = re-run and ask again, `31` = unusable (booted fresh, replicates into the existing prefix), `30` = fatal; other = usage/tool broken |
@@ -329,8 +329,10 @@ same way it already reuses `SESSION-DB-REPLICA-UNUSABLE` for `session-db-classif
329
329
  `restore` logs domain outcomes and still exits `0`: a missing remote object or a
330
330
  restore failure should not abort a boot, because a runner with no credentials yet
331
331
  is a runner a human can still log into. `sync-once` returns `40` when the current
332
- credential file is not persisted (`failed`, `hashFailed`, or `localInvalid`), while
333
- `absent` and `disabled` remain legitimate `0` outcomes.
332
+ credential file is not persisted. In per-runner mode, that means `failed`, `hashFailed`,
333
+ or `localInvalid`; in shared-vault mode it also includes `remoteFailed`, `remoteInvalid`,
334
+ `noEtag`, `contended`, and `adoptFailed`. `published` (created or replaced), `adopted`,
335
+ `unchanged`, `absent`, and `disabled` are legitimate `0` outcomes.
334
336
 
335
337
  The consequence is the point: **any non-zero status from `restore` means the tool itself
336
338
  broke** — and `sync-once` uses `40` for its typed persistence answer; other non-zero
@@ -379,6 +381,7 @@ key, so they never appear in `env`'s output or `litestream.yml`.
379
381
  | ------------------------------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
380
382
  | `HOME` | Every credential and database path is derived from it. | Throws; logged `FATAL:`, exits `1`. Affects every command. |
381
383
  | `LITESTREAM_BUCKET` / `LITESTREAM_PREFIX` | Object-store bucket and key prefix. | Persistence off (needs **both**): `restore`/`sync-once` no-op, `env` warns. |
384
+ | `CREDENTIAL_VAULT_PREFIX` | Shared prefix for both credential objects; it does not redirect the session database. | Unset or blank: use this runner's `LITESTREAM_PREFIX`; ignored when persistence is disabled. |
382
385
  | `AWS_REGION` | Region for the AWS clients (S3, and the ECS/STS calls `self-stop` makes). | Left to the SDK's own resolution. |
383
386
  | `CREDS_SYNC_INTERVAL` | Seconds between sync ticks, reported by `env` for the caller's loop. | Falls back to `60`; also on unparseable or non-positive values. |
384
387
  | `EVIDENT_SESSION_DB_WALKBACK_MAX_POINTS` | Distinct restore points `session-db-verify`'s walkback will try before giving up. | Falls back to `10`; also on unparseable or non-positive values. |
@@ -402,6 +405,11 @@ The paths and keys that follow from the bucket/prefix:
402
405
  | OpenCode | `$HOME/.local/share/opencode/auth.json` | `<prefix>/opencode/auth.json` |
403
406
  | OpenCode DB | `$HOME/.local/share/opencode/opencode.db` | `<prefix>/opencode.db` (litestream) |
404
407
 
408
+ When persistence is enabled and `CREDENTIAL_VAULT_PREFIX` is set, both credential keys
409
+ use that shared prefix instead of `<prefix>`. The OpenCode database continues replicating
410
+ under `LITESTREAM_PREFIX`; the vault prefix only redirects the two credential objects. The
411
+ Evident CLI resolves this value from the runner's vault binding.
412
+
405
413
  The session database is replicated by litestream itself; this package only generates its
406
414
  config. `env` emits `PERSISTENCE_BUCKET`, `CLAUDE_CREDS`, `OPENCODE_DB_PATH` and
407
415
  `CREDS_SYNC_INTERVAL`, each single-quoted so a value containing shell metacharacters is
@@ -435,18 +443,44 @@ running that version — the runner installs this package from a floating npm ta
435
443
  - **Nothing throws out of `restore`/`sync`.** `sync-once` runs on an unsupervised timer,
436
444
  where an unguarded failure would silently stop persisting credentials for the rest of
437
445
  the task's life. Failures degrade to a warning and are retried on the next tick.
438
- - **`sync-once` uploads only on change.** Each store's last uploaded hash (a sha256, not
439
- the credentials) is kept in a state file next to it, so every invocation is a
440
- self-contained process.
446
+ - **Per-runner `sync-once` uploads only on change.** Its last uploaded hash (a sha256, not
447
+ the credentials) is kept in a state file next to the credential file, so every invocation
448
+ is a self-contained process.
449
+ - **Shared-vault mode is two-way and conditional.** `sync-once` adopts a changed shared
450
+ credential or publishes a locally changed one with a conditional write. A writer that
451
+ loses the race adopts the winner instead of overwriting it. A `40` exit is an expected
452
+ "not persisted this tick" result, not a broken tool; the polling loop accepts it and
453
+ retries on the next tick.
441
454
  - **Not-found is distinguished from failed.** A missing object is reported as "nothing to
442
455
  restore"; an access or network error is reported as a failure, because that tells the
443
456
  operator their persisted credentials may still be recoverable.
444
457
 
458
+ ### Shared-vault log markers
459
+
460
+ In shared-vault mode, these markers identify the synchronization result without logging
461
+ credential bytes. Hash values are short prefixes and ETags remain opaque:
462
+
463
+ | Marker | Meaning |
464
+ | ------ | ------- |
465
+ | `VAULT-MODE` | Both credential stores use the shared vault prefix. |
466
+ | `VAULT-PUBLISHED` | A credential was created in, or conditionally replaced in, the vault. |
467
+ | `VAULT-ADOPTED` | A valid vault credential was installed locally. |
468
+ | `VAULT-CONTENDED` | Another writer won the conditional write, so this runner is adopting its result. |
469
+ | `VAULT-CONTENDED-UNRESOLVED` | The winning object could not be read; the next tick will retry. |
470
+ | `VAULT-REMOTE-INVALID` | The vault object was not valid JSON and was either replaced or left untouched. |
471
+ | `VAULT-REMOTE-UNREADABLE` | The vault read failed; the next tick will retry. |
472
+ | `VAULT-NO-ETAG` | Publication was skipped because a remote object had no ETag. |
473
+ | `VAULT-ADOPT-FAILED` | A valid vault credential could not be installed locally; the next tick will retry. |
474
+
445
475
  ## The `ObjectStore` port
446
476
 
447
- `restore`/`sync` and `session-db-classify` talk to a four-method interface: `get(key)`
448
- returning `null` when the object is absent, `put(key, body)`, `list(prefix)` and
449
- `delete(key)` for session-DB recovery. `list(prefix)` returns `{ key, size }` entries
477
+ The `ObjectStore` port has seven methods. `get(key)` returns `{ body, etag }` or `null`
478
+ when the object is absent; `etag` is an opaque compare-and-swap token and may be `null`
479
+ when the store did not report one. `put(key, body)` is the unconditional write used by
480
+ per-runner synchronization. `putIfAbsent(key, body)` creates with a conditional
481
+ `If-None-Match: *` write, and `putIfMatch(key, body, etag)` replaces with a conditional
482
+ `If-Match: <etag>` write. Both return `'written'` or `'conflict'`; only contention
483
+ returns `'conflict'`, while every other error throws. `list(prefix)` returns `{ key, size }` entries
450
484
  (`ObjectSummary[]`) — every object under `prefix`, size in bytes. It throws on any error
451
485
  and never maps one to `[]` (an empty array means "reached the store, nothing there"); the
452
486
  adapter owns pagination, and `delete` is idempotent. S3 vocabulary (`@aws-sdk/client-s3`,
package/dist/cli.js CHANGED
@@ -50903,22 +50903,24 @@ function resolveConfig(env4) {
50903
50903
  const bucket = nonEmpty(env4.LITESTREAM_BUCKET);
50904
50904
  const prefix = nonEmpty(env4.LITESTREAM_PREFIX);
50905
50905
  const persistenceEnabled = bucket !== null && prefix !== null;
50906
+ const vaultPrefix = persistenceEnabled ? nonEmpty(env4.CREDENTIAL_VAULT_PREFIX)?.replace(/\/+$/, "") || null : null;
50906
50907
  return {
50907
50908
  homeDir,
50908
50909
  claude: {
50909
50910
  label: "Claude",
50910
50911
  path: `${homeDir}/.claude/.credentials.json`,
50911
- key: persistenceEnabled ? `${prefix}/claude/credentials.json` : null
50912
+ key: persistenceEnabled ? `${vaultPrefix ?? prefix}/claude/credentials.json` : null
50912
50913
  },
50913
50914
  opencode: {
50914
50915
  label: "OpenCode",
50915
50916
  path: `${homeDir}/.local/share/opencode/auth.json`,
50916
- key: persistenceEnabled ? `${prefix}/opencode/auth.json` : null
50917
+ key: persistenceEnabled ? `${vaultPrefix ?? prefix}/opencode/auth.json` : null
50917
50918
  },
50918
50919
  opencodeDbPath: `${homeDir}/.local/share/opencode/opencode.db`,
50919
50920
  sessionDbRecoveryReportPath: nonEmpty(env4.EVIDENT_SESSION_DB_RECOVERY_REPORT) ?? `${homeDir}/.local/state/evident/session-db-recovery.jsonl`,
50920
50921
  bucket: persistenceEnabled ? bucket : null,
50921
50922
  prefix: persistenceEnabled ? prefix : null,
50923
+ vaultPrefix,
50922
50924
  region: nonEmpty(env4.AWS_REGION),
50923
50925
  syncIntervalSeconds: positiveIntOr(env4.CREDS_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL_SECONDS),
50924
50926
  walkbackMaxPoints: positiveIntOr(
@@ -51317,20 +51319,23 @@ init_esm_shims();
51317
51319
 
51318
51320
  // src/valid-json.ts
51319
51321
  init_esm_shims();
51320
- async function isValidJson(fileOps, path) {
51321
- let contents;
51322
+ function isValidJsonBytes(contents) {
51323
+ if (contents.length === 0) return false;
51322
51324
  try {
51323
- contents = await fileOps.readFile(path);
51325
+ const value = JSON.parse(contents.toString("utf8"));
51326
+ return value !== null && value !== false;
51324
51327
  } catch {
51325
51328
  return false;
51326
51329
  }
51327
- if (contents.length === 0) return false;
51330
+ }
51331
+ async function isValidJson(fileOps, path) {
51332
+ let contents;
51328
51333
  try {
51329
- const value = JSON.parse(contents.toString("utf8"));
51330
- return value !== null && value !== false;
51334
+ contents = await fileOps.readFile(path);
51331
51335
  } catch {
51332
51336
  return false;
51333
51337
  }
51338
+ return isValidJsonBytes(contents);
51334
51339
  }
51335
51340
 
51336
51341
  // src/model-auth.ts
@@ -51387,55 +51392,20 @@ function isDeletableReplicaKey(prefix, key) {
51387
51392
 
51388
51393
  // src/restore.ts
51389
51394
  init_esm_shims();
51395
+ import { dirname as dirname4 } from "node:path";
51396
+
51397
+ // src/credential-install.ts
51398
+ init_esm_shims();
51390
51399
  import { dirname as dirname3 } from "node:path";
51391
- async function restore(params) {
51392
- const { label, path, bucket, key, store, fileOps, log } = params;
51400
+ async function installCredentialBytes({
51401
+ label,
51402
+ path,
51403
+ body,
51404
+ source,
51405
+ fileOps,
51406
+ log
51407
+ }) {
51393
51408
  const dir = dirname3(path);
51394
- try {
51395
- await fileOps.mkdirp(dir);
51396
- } catch (error) {
51397
- log(`WARNING: could not create ${label} credential directory ${dir}: ${describeError(error)}`);
51398
- return { kind: "failed", reason: `could not create ${dir}: ${describeError(error)}` };
51399
- }
51400
- try {
51401
- await fileOps.chmod(dir, 448);
51402
- } catch (error) {
51403
- log(
51404
- `WARNING: could not restrict ${label} credential directory ${dir}: ${describeError(error)}`
51405
- );
51406
- }
51407
- const local = await fileOps.stat(path);
51408
- if (local !== null && local.size > 0 && !await isValidJson(fileOps, path)) {
51409
- log(
51410
- `WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping restore.`
51411
- );
51412
- return { kind: "localInvalid" };
51413
- }
51414
- if (local !== null && local.size === 0) {
51415
- log(
51416
- `Local ${label} credentials at ${path} are empty; will attempt to restore from S3 instead of protecting them.`
51417
- );
51418
- }
51419
- if (await isValidJson(fileOps, path)) {
51420
- await chmodOrWarn(fileOps, log, path, `local ${label} credentials at ${path}`);
51421
- log(`Keeping valid local ${label} credentials at ${path}; no restore.`);
51422
- return { kind: "local" };
51423
- }
51424
- if (store === null || bucket === null || key === null) {
51425
- return { kind: "disabled" };
51426
- }
51427
- const location = `s3://${bucket}/${key}`;
51428
- let body;
51429
- try {
51430
- body = await store.get(key);
51431
- } catch (error) {
51432
- log(`WARNING: ${label} credentials download failed from ${location}: ${describeError(error)}`);
51433
- return { kind: "failed", reason: describeError(error) };
51434
- }
51435
- if (body === null) {
51436
- log(`No persisted ${label} credentials at ${location}; nothing to restore.`);
51437
- return { kind: "missing" };
51438
- }
51439
51409
  const temp = `${dir}/.credentials.${Math.random().toString(36).slice(2, 8)}`;
51440
51410
  try {
51441
51411
  await fileOps.createExclusive(temp);
@@ -51464,12 +51434,12 @@ async function restore(params) {
51464
51434
  await fileOps.writeFile(temp, body);
51465
51435
  } catch (error) {
51466
51436
  await fileOps.remove(temp);
51467
- log(`WARNING: ${label} credentials download failed from ${location}: ${describeError(error)}`);
51437
+ log(`WARNING: ${label} credentials download failed from ${source}: ${describeError(error)}`);
51468
51438
  return { kind: "failed", reason: describeError(error) };
51469
51439
  }
51470
51440
  if (!await isValidJson(fileOps, temp)) {
51471
51441
  await fileOps.remove(temp);
51472
- log(`WARNING: discarded invalid ${label} credentials from ${location}.`);
51442
+ log(`WARNING: discarded invalid ${label} credentials from ${source}.`);
51473
51443
  return { kind: "invalid" };
51474
51444
  }
51475
51445
  try {
@@ -51482,8 +51452,7 @@ async function restore(params) {
51482
51452
  return { kind: "failed", reason: `could not install ${path}: ${describeError(error)}` };
51483
51453
  }
51484
51454
  await chmodOrWarn(fileOps, log, path, `restored ${label} credentials at ${path}`);
51485
- log(`Restored ${label} credentials from ${location} to ${path}.`);
51486
- return { kind: "restored" };
51455
+ return { kind: "installed" };
51487
51456
  }
51488
51457
  async function chmodOrWarn(fileOps, log, path, subject) {
51489
51458
  try {
@@ -51493,6 +51462,71 @@ async function chmodOrWarn(fileOps, log, path, subject) {
51493
51462
  }
51494
51463
  }
51495
51464
 
51465
+ // src/restore.ts
51466
+ async function restore(params) {
51467
+ const { label, path, bucket, key, store, fileOps, log } = params;
51468
+ const dir = dirname4(path);
51469
+ try {
51470
+ await fileOps.mkdirp(dir);
51471
+ } catch (error) {
51472
+ log(`WARNING: could not create ${label} credential directory ${dir}: ${describeError(error)}`);
51473
+ return { kind: "failed", reason: `could not create ${dir}: ${describeError(error)}` };
51474
+ }
51475
+ try {
51476
+ await fileOps.chmod(dir, 448);
51477
+ } catch (error) {
51478
+ log(
51479
+ `WARNING: could not restrict ${label} credential directory ${dir}: ${describeError(error)}`
51480
+ );
51481
+ }
51482
+ const local = await fileOps.stat(path);
51483
+ if (local !== null && local.size > 0 && !await isValidJson(fileOps, path)) {
51484
+ log(
51485
+ `WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping restore.`
51486
+ );
51487
+ return { kind: "localInvalid" };
51488
+ }
51489
+ if (local !== null && local.size === 0) {
51490
+ log(
51491
+ `Local ${label} credentials at ${path} are empty; will attempt to restore from S3 instead of protecting them.`
51492
+ );
51493
+ }
51494
+ if (await isValidJson(fileOps, path)) {
51495
+ await chmodOrWarn(fileOps, log, path, `local ${label} credentials at ${path}`);
51496
+ log(`Keeping valid local ${label} credentials at ${path}; no restore.`);
51497
+ return { kind: "local" };
51498
+ }
51499
+ if (store === null || bucket === null || key === null) {
51500
+ return { kind: "disabled" };
51501
+ }
51502
+ const location = `s3://${bucket}/${key}`;
51503
+ let object;
51504
+ try {
51505
+ object = await store.get(key);
51506
+ } catch (error) {
51507
+ log(`WARNING: ${label} credentials download failed from ${location}: ${describeError(error)}`);
51508
+ return { kind: "failed", reason: describeError(error) };
51509
+ }
51510
+ if (object === null) {
51511
+ log(`No persisted ${label} credentials at ${location}; nothing to restore.`);
51512
+ return { kind: "missing" };
51513
+ }
51514
+ const installation = await installCredentialBytes({
51515
+ label,
51516
+ path,
51517
+ body: object.body,
51518
+ source: location,
51519
+ fileOps,
51520
+ log
51521
+ });
51522
+ if (installation.kind === "installed") {
51523
+ log(`Restored ${label} credentials from ${location} to ${path}.`);
51524
+ return { kind: "restored" };
51525
+ }
51526
+ if (installation.kind === "invalid") return { kind: "invalid" };
51527
+ return { kind: "failed", reason: installation.reason };
51528
+ }
51529
+
51496
51530
  // src/s3-object-store.ts
51497
51531
  init_esm_shims();
51498
51532
 
@@ -61271,6 +61305,11 @@ function isNotFound(error) {
61271
61305
  if (candidate.name === "NoSuchKey" || candidate.name === "NotFound") return true;
61272
61306
  return candidate.$metadata?.httpStatusCode === 404;
61273
61307
  }
61308
+ function isConditionalConflict(error) {
61309
+ if (typeof error !== "object" || error === null) return false;
61310
+ const candidate = error;
61311
+ return candidate.name === "PreconditionFailed" || candidate.name === "ConditionalRequestConflict" || candidate.$metadata?.httpStatusCode === 412 || candidate.$metadata?.httpStatusCode === 409;
61312
+ }
61274
61313
  var S3ObjectStore = class {
61275
61314
  constructor(bucket, region = null, client) {
61276
61315
  this.bucket = bucket;
@@ -61283,7 +61322,10 @@ var S3ObjectStore = class {
61283
61322
  new GetObjectCommand({ Bucket: this.bucket, Key: key })
61284
61323
  );
61285
61324
  if (!response.Body) return null;
61286
- return Buffer.from(await response.Body.transformToByteArray());
61325
+ return {
61326
+ body: Buffer.from(await response.Body.transformToByteArray()),
61327
+ etag: response.ETag ?? null
61328
+ };
61287
61329
  } catch (error) {
61288
61330
  if (isNotFound(error)) return null;
61289
61331
  throw error;
@@ -61292,6 +61334,38 @@ var S3ObjectStore = class {
61292
61334
  async put(key, body) {
61293
61335
  await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body }));
61294
61336
  }
61337
+ async putIfAbsent(key, body) {
61338
+ try {
61339
+ await this.client.send(
61340
+ new PutObjectCommand({
61341
+ Bucket: this.bucket,
61342
+ Key: key,
61343
+ Body: body,
61344
+ IfNoneMatch: "*"
61345
+ })
61346
+ );
61347
+ return "written";
61348
+ } catch (error) {
61349
+ if (isConditionalConflict(error)) return "conflict";
61350
+ throw error;
61351
+ }
61352
+ }
61353
+ async putIfMatch(key, body, etag) {
61354
+ try {
61355
+ await this.client.send(
61356
+ new PutObjectCommand({
61357
+ Bucket: this.bucket,
61358
+ Key: key,
61359
+ Body: body,
61360
+ IfMatch: etag
61361
+ })
61362
+ );
61363
+ return "written";
61364
+ } catch (error) {
61365
+ if (isConditionalConflict(error) || isNotFound(error)) return "conflict";
61366
+ throw error;
61367
+ }
61368
+ }
61295
61369
  async list(prefix) {
61296
61370
  const objects = [];
61297
61371
  let continuationToken;
@@ -61399,7 +61473,8 @@ async function pruneNewestL0(store, prefix, keys, log) {
61399
61473
  }
61400
61474
  let bytes;
61401
61475
  try {
61402
- bytes = await store.get(target);
61476
+ const object = await store.get(target);
61477
+ bytes = object === null ? null : object.body;
61403
61478
  } catch (error) {
61404
61479
  log(
61405
61480
  `WARNING: could not re-read ${target} to check whether it is actually corrupt before pruning it: ${describeError(error)}. Declining to prune without positive evidence.`
@@ -61628,7 +61703,7 @@ async function discardLocalDebris(fileOps, dbPath, log) {
61628
61703
 
61629
61704
  // src/session-db-recovery-report.ts
61630
61705
  init_esm_shims();
61631
- import { dirname as dirname4 } from "node:path";
61706
+ import { dirname as dirname5 } from "node:path";
61632
61707
  function base(at2, stage) {
61633
61708
  return {
61634
61709
  v: 1,
@@ -61736,7 +61811,7 @@ async function appendSessionDbRecoveryRecord(record, path, fileOps, log) {
61736
61811
  );
61737
61812
  return;
61738
61813
  }
61739
- await fileOps.mkdirp(dirname4(path));
61814
+ await fileOps.mkdirp(dirname5(path));
61740
61815
  await fileOps.appendFile(path, line);
61741
61816
  } catch (error) {
61742
61817
  log(
@@ -62083,21 +62158,25 @@ var nodeSqliteIntegrity = {
62083
62158
 
62084
62159
  // src/sync.ts
62085
62160
  init_esm_shims();
62086
- async function sync(params) {
62087
- const { label, path, bucket, key, store, lastHash, fileOps, log } = params;
62088
- if (store === null || bucket === null || key === null) {
62089
- return { outcome: { kind: "disabled" }, hash: lastHash };
62090
- }
62161
+
62162
+ // src/local-credential.ts
62163
+ init_esm_shims();
62164
+ async function readLocalCredential({
62165
+ label,
62166
+ path,
62167
+ fileOps,
62168
+ log
62169
+ }) {
62091
62170
  const exists = await fileOps.stat(path) !== null;
62092
62171
  const valid = await isValidJson(fileOps, path);
62093
62172
  if (exists && !valid) {
62094
62173
  log(
62095
62174
  `WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync.`
62096
62175
  );
62097
- return { outcome: { kind: "localInvalid" }, hash: lastHash };
62176
+ return { kind: "invalid" };
62098
62177
  }
62099
62178
  if (!valid) {
62100
- return { outcome: { kind: "absent" }, hash: lastHash };
62179
+ return { kind: "absent" };
62101
62180
  }
62102
62181
  try {
62103
62182
  await fileOps.chmod(path, 384);
@@ -62113,8 +62192,28 @@ async function sync(params) {
62113
62192
  log(
62114
62193
  `WARNING: could not hash local ${label} credentials at ${path} (${describeError(error)}); skipping this sync tick.`
62115
62194
  );
62195
+ return { kind: "hashFailed" };
62196
+ }
62197
+ return { kind: "ok", hash };
62198
+ }
62199
+
62200
+ // src/sync.ts
62201
+ async function sync(params) {
62202
+ const { label, path, bucket, key, store, lastHash, fileOps, log } = params;
62203
+ if (store === null || bucket === null || key === null) {
62204
+ return { outcome: { kind: "disabled" }, hash: lastHash };
62205
+ }
62206
+ const local = await readLocalCredential({ label, path, fileOps, log });
62207
+ if (local.kind === "invalid") {
62208
+ return { outcome: { kind: "localInvalid" }, hash: lastHash };
62209
+ }
62210
+ if (local.kind === "absent") {
62211
+ return { outcome: { kind: "absent" }, hash: lastHash };
62212
+ }
62213
+ if (local.kind === "hashFailed") {
62116
62214
  return { outcome: { kind: "hashFailed" }, hash: lastHash };
62117
62215
  }
62216
+ const { hash } = local;
62118
62217
  if (hash === lastHash) {
62119
62218
  return { outcome: { kind: "unchanged" }, hash: lastHash };
62120
62219
  }
@@ -62129,6 +62228,256 @@ async function sync(params) {
62129
62228
  return { outcome: { kind: "uploaded" }, hash };
62130
62229
  }
62131
62230
 
62231
+ // src/vault-sync.ts
62232
+ init_esm_shims();
62233
+ import { createHash as createHash10 } from "node:crypto";
62234
+ import { dirname as dirname6 } from "node:path";
62235
+ async function syncVault(params) {
62236
+ const { label, path, bucket, key, store, lastHash, fileOps, log } = params;
62237
+ if (store === null || bucket === null || key === null) {
62238
+ return { outcome: { kind: "disabled" }, hash: lastHash };
62239
+ }
62240
+ const location = `s3://${bucket}/${key}`;
62241
+ let remote;
62242
+ try {
62243
+ remote = await store.get(key);
62244
+ } catch (error) {
62245
+ const reason = describeError(error);
62246
+ log(
62247
+ `WARNING: VAULT-REMOTE-UNREADABLE: could not read the shared ${label} credential at ${location}: ${reason}`
62248
+ );
62249
+ return { outcome: { kind: "remoteFailed", reason }, hash: lastHash };
62250
+ }
62251
+ let local;
62252
+ try {
62253
+ local = await readLocalCredential({ label, path, fileOps, log });
62254
+ } catch (error) {
62255
+ log(
62256
+ `WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync. (${describeError(error)})`
62257
+ );
62258
+ return { outcome: { kind: "localInvalid" }, hash: lastHash };
62259
+ }
62260
+ if (local.kind === "invalid") {
62261
+ return { outcome: { kind: "localInvalid" }, hash: lastHash };
62262
+ }
62263
+ if (local.kind === "hashFailed") {
62264
+ return { outcome: { kind: "hashFailed" }, hash: lastHash };
62265
+ }
62266
+ if (remote === null) {
62267
+ if (local.kind === "absent") return { outcome: { kind: "absent" }, hash: lastHash };
62268
+ let body;
62269
+ try {
62270
+ body = await fileOps.readFile(path);
62271
+ } catch (error) {
62272
+ log(
62273
+ `WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync. (${describeError(error)})`
62274
+ );
62275
+ return { outcome: { kind: "localInvalid" }, hash: lastHash };
62276
+ }
62277
+ return publish(
62278
+ { ...params, bucket, key, store, lastHash, log },
62279
+ body,
62280
+ local.hash,
62281
+ null,
62282
+ location
62283
+ );
62284
+ }
62285
+ const remoteHash = hashBytes(remote.body);
62286
+ const remoteValid = isValidJsonBytes(remote.body);
62287
+ if (local.kind === "absent") {
62288
+ if (!remoteValid) {
62289
+ log(
62290
+ `WARNING: VAULT-REMOTE-INVALID: the shared ${label} credential at ${location} is not valid JSON; keeping this runner's local file and not adopting it.`
62291
+ );
62292
+ return { outcome: { kind: "remoteInvalid" }, hash: lastHash };
62293
+ }
62294
+ return adopt2(
62295
+ { ...params, bucket, key, store, lastHash, log },
62296
+ remote.body,
62297
+ remoteHash,
62298
+ location
62299
+ );
62300
+ }
62301
+ let localBody;
62302
+ try {
62303
+ localBody = await fileOps.readFile(path);
62304
+ } catch (error) {
62305
+ log(
62306
+ `WARNING: local ${label} credentials at ${path} are invalid or unreadable; keeping them and skipping sync. (${describeError(error)})`
62307
+ );
62308
+ return { outcome: { kind: "localInvalid" }, hash: lastHash };
62309
+ }
62310
+ if (remoteValid && remoteHash === local.hash) {
62311
+ return { outcome: { kind: "unchanged" }, hash: remoteHash };
62312
+ }
62313
+ if (!remoteValid) {
62314
+ if (remote.etag === null) {
62315
+ return noEtag({ label, location, lastHash, log });
62316
+ }
62317
+ log(
62318
+ `WARNING: VAULT-REMOTE-INVALID: the shared ${label} credential at ${location} is not valid JSON; replacing it with this runner's local credential.`
62319
+ );
62320
+ return publish(
62321
+ { ...params, bucket, key, store, lastHash, log },
62322
+ localBody,
62323
+ local.hash,
62324
+ remote,
62325
+ location
62326
+ );
62327
+ }
62328
+ if (remoteHash !== lastHash) {
62329
+ return adopt2(
62330
+ { ...params, bucket, key, store, lastHash, log },
62331
+ remote.body,
62332
+ remoteHash,
62333
+ location
62334
+ );
62335
+ }
62336
+ if (remote.etag === null) {
62337
+ return noEtag({ label, location, lastHash, log });
62338
+ }
62339
+ return publish(
62340
+ { ...params, bucket, key, store, lastHash, log },
62341
+ localBody,
62342
+ local.hash,
62343
+ remote,
62344
+ location
62345
+ );
62346
+ }
62347
+ function isVaultCredentialPersisted(outcome) {
62348
+ switch (outcome.kind) {
62349
+ case "disabled":
62350
+ case "absent":
62351
+ case "unchanged":
62352
+ case "published":
62353
+ case "adopted":
62354
+ return true;
62355
+ case "localInvalid":
62356
+ case "hashFailed":
62357
+ case "adoptFailed":
62358
+ case "contended":
62359
+ case "remoteFailed":
62360
+ case "remoteInvalid":
62361
+ case "noEtag":
62362
+ return false;
62363
+ default:
62364
+ return assertNever(outcome, "vault sync outcome");
62365
+ }
62366
+ }
62367
+ async function publish(params, body, hash, remote, location) {
62368
+ const { label, key, store, lastHash, log } = params;
62369
+ if (store === null || key === null) return { outcome: { kind: "disabled" }, hash: lastHash };
62370
+ if (remote !== null && remote.etag === null) {
62371
+ return noEtag({ label, location, lastHash, log });
62372
+ }
62373
+ let result;
62374
+ try {
62375
+ if (remote === null) {
62376
+ result = await store.putIfAbsent(key, body);
62377
+ } else {
62378
+ const { etag } = remote;
62379
+ if (etag === null) return noEtag({ label, location, lastHash, log });
62380
+ result = await store.putIfMatch(key, body, etag);
62381
+ }
62382
+ } catch (error) {
62383
+ const reason = describeError(error);
62384
+ log(`WARNING: ${label} credentials upload failed to ${location}: ${reason}`);
62385
+ return { outcome: { kind: "remoteFailed", reason }, hash: lastHash };
62386
+ }
62387
+ if (result === "written") {
62388
+ const mode = remote === null ? "created" : "replaced";
62389
+ const previous = remote === null ? "" : ` previous=${shortHash(lastHash ?? hashBytes(remote.body))}`;
62390
+ log(
62391
+ `VAULT-PUBLISHED: ${mode === "created" ? "created" : "replaced"} the shared ${label} credential at ${location} hash=${shortHash(hash)}${previous}`
62392
+ );
62393
+ return { outcome: { kind: "published", mode }, hash };
62394
+ }
62395
+ log(
62396
+ `WARNING: VAULT-CONTENDED: another writer changed the shared ${label} credential at ${location} first; adopting theirs instead of overwriting it.`
62397
+ );
62398
+ let winner;
62399
+ try {
62400
+ winner = await store.get(key);
62401
+ } catch (error) {
62402
+ return unresolvedContention(params, location, describeError(error));
62403
+ }
62404
+ if (winner === null)
62405
+ return unresolvedContention(params, location, "the winning object is absent");
62406
+ if (!isValidJsonBytes(winner.body)) {
62407
+ return unresolvedContention(params, location, "the winning object is not valid JSON");
62408
+ }
62409
+ const winnerHash = hashBytes(winner.body);
62410
+ if (winnerHash === hash) return { outcome: { kind: "unchanged" }, hash: winnerHash };
62411
+ return adopt2(params, winner.body, winnerHash, location);
62412
+ }
62413
+ async function adopt2(params, body, hash, location) {
62414
+ const { label, path, fileOps, log, lastHash } = params;
62415
+ const dir = dirname6(path);
62416
+ try {
62417
+ await fileOps.mkdirp(dir);
62418
+ } catch (error) {
62419
+ return adoptFailed(params, location, describeError(error));
62420
+ }
62421
+ let installation;
62422
+ try {
62423
+ installation = await installCredentialBytes({
62424
+ label,
62425
+ path,
62426
+ body,
62427
+ source: location,
62428
+ fileOps,
62429
+ log
62430
+ });
62431
+ } catch (error) {
62432
+ return adoptFailed(params, location, describeError(error));
62433
+ }
62434
+ if (installation.kind === "installed") {
62435
+ log(
62436
+ `VAULT-ADOPTED: installed the shared ${label} credential from ${location} hash=${shortHash(hash)} previous=${lastHash === null ? "none" : shortHash(lastHash)}`
62437
+ );
62438
+ return { outcome: { kind: "adopted" }, hash };
62439
+ }
62440
+ return adoptFailed(
62441
+ params,
62442
+ location,
62443
+ installation.kind === "invalid" ? "the winning object was not valid JSON" : installation.reason
62444
+ );
62445
+ }
62446
+ function adoptFailed(params, location, reason) {
62447
+ const { label, log, lastHash } = params;
62448
+ log(
62449
+ `WARNING: VAULT-ADOPT-FAILED: could not install the shared ${label} credential from ${location} (${reason}); keeping the local file and retrying on the next tick.`
62450
+ );
62451
+ return { outcome: { kind: "adoptFailed" }, hash: lastHash };
62452
+ }
62453
+ function unresolvedContention(params, location, reason) {
62454
+ const { label, log, lastHash } = params;
62455
+ log(
62456
+ `WARNING: VAULT-CONTENDED-UNRESOLVED: could not read the winning ${label} credential at ${location} (${reason}); retrying on the next tick.`
62457
+ );
62458
+ return { outcome: { kind: "contended" }, hash: lastHash };
62459
+ }
62460
+ function noEtag({
62461
+ label,
62462
+ location,
62463
+ lastHash,
62464
+ log
62465
+ }) {
62466
+ log(
62467
+ `WARNING: VAULT-NO-ETAG: the shared ${label} credential at ${location} came back without an ETag; skipping publication this tick rather than overwriting it unconditionally.`
62468
+ );
62469
+ return {
62470
+ outcome: { kind: "noEtag" },
62471
+ hash: lastHash
62472
+ };
62473
+ }
62474
+ function hashBytes(body) {
62475
+ return createHash10("sha256").update(body).digest("hex");
62476
+ }
62477
+ function shortHash(hash) {
62478
+ return hash.slice(0, 12);
62479
+ }
62480
+
62132
62481
  // src/cli.ts
62133
62482
  var EXIT_NOT_READY = 10;
62134
62483
  var EXIT_KEEP_TASK = 20;
@@ -62147,7 +62496,8 @@ var USAGE = `Usage: runner-synchroniser <command> [args]
62147
62496
  env print the resolved config as shell-sourceable lines
62148
62497
  litestream-config print the generated litestream.yml
62149
62498
  restore <claude|opencode> restore a credential store from the object store
62150
- sync-once <claude|opencode> upload a credential store if it changed
62499
+ sync-once <claude|opencode> synchronize a credential store; shared-vault mode may
62500
+ adopt or conditionally publish credentials
62151
62501
  model-auth-ready exit 0 when some model auth is configured, ${EXIT_NOT_READY} when not
62152
62502
  self-stop scale this agent's own ECS service to 0; exit 0 only
62153
62503
  when desiredCount is confirmed 0, ${EXIT_KEEP_TASK} to keep the task
@@ -62277,7 +62627,7 @@ async function commandSyncOnce(name, config, deps) {
62277
62627
  const store = storeOf(config, name);
62278
62628
  const statePath = `${store.path}${STATE_SUFFIX}`;
62279
62629
  const lastHash = await readLastHash(fileOps, statePath, log);
62280
- const result = await sync({
62630
+ const params = {
62281
62631
  label: store.label,
62282
62632
  path: store.path,
62283
62633
  bucket: config.bucket,
@@ -62286,10 +62636,14 @@ async function commandSyncOnce(name, config, deps) {
62286
62636
  lastHash,
62287
62637
  fileOps,
62288
62638
  log
62289
- });
62639
+ };
62640
+ const result = config.vaultPrefix === null ? await sync(params) : await syncVault(params);
62290
62641
  if (result.hash !== null && result.hash !== lastHash) {
62291
62642
  await writeLastHash(fileOps, statePath, result.hash, log);
62292
62643
  }
62644
+ if (config.vaultPrefix !== null) {
62645
+ return isVaultCredentialPersisted(result.outcome) ? 0 : EXIT_SYNC_NOT_PERSISTED;
62646
+ }
62293
62647
  return result.outcome.kind === "failed" || result.outcome.kind === "hashFailed" || result.outcome.kind === "localInvalid" ? EXIT_SYNC_NOT_PERSISTED : 0;
62294
62648
  }
62295
62649
  async function commandSessionDbClassify(args, config, deps) {
@@ -62377,8 +62731,14 @@ ${USAGE}`);
62377
62731
  const config = resolveConfig(env4);
62378
62732
  if (command12 === "env") {
62379
62733
  if (config.bucket === null) {
62734
+ const vaultPrefixIgnored = (env4.CREDENTIAL_VAULT_PREFIX?.trim() ?? "") !== "";
62735
+ log(
62736
+ "WARNING: credential persistence is DISABLED (LITESTREAM_BUCKET/LITESTREAM_PREFIX unset). Credentials stay on this task's ephemeral disk and are LOST when it is replaced." + (vaultPrefixIgnored ? " The requested CREDENTIAL_VAULT_PREFIX is ignored because persistence requires both LITESTREAM_BUCKET and LITESTREAM_PREFIX." : "")
62737
+ );
62738
+ }
62739
+ if (config.vaultPrefix !== null) {
62380
62740
  log(
62381
- "WARNING: credential persistence is DISABLED (LITESTREAM_BUCKET/LITESTREAM_PREFIX unset). Credentials stay on this task's ephemeral disk and are LOST when it is replaced."
62741
+ `VAULT-MODE: both Claude and OpenCode credential files are shared through bucket=${config.bucket} prefix=${config.vaultPrefix} instead of this runner's own prefix.`
62382
62742
  );
62383
62743
  }
62384
62744
  out(renderEnv(config));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evident-ai/runner-synchroniser",
3
- "version": "3.5.2-dev.507f0a3",
3
+ "version": "3.5.2-dev.7130f36",
4
4
  "description": "Restores and syncs the Evident runner's OpenCode credential stores (and litestream config) to an object store, so a runner survives task replacement with almost no state loss.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",