@evident-ai/runner-cdk 3.4.1-dev.1856549 → 3.4.1-dev.2f1b44b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,7 +97,7 @@ the construct's own dogfooding consumer.
97
97
  | `cluster`, `securityGroup`, `taskRole`, `logGroup`, `replicaBucket` | interfaces | Shared infra you create and pass in. |
98
98
  | `image` | `ecs.ContainerImage` | Your runner image. |
99
99
  | `agentSecret`, `wakerSecret` | `secretsmanager.ISecret` | Any Secrets Manager secret. |
100
- | `availableSecretKeys` | `Set<string>` | Keys present in the agent secret. Requires `EVIDENT_AGENT_KEY` + `GH_TOKEN`; MCP credentials (`BRAVE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `NEON_API_KEY`) are injected only when listed. |
100
+ | `availableSecretKeys` | `Set<string>` | Keys present in the agent secret. Every listed key is injected; `EVIDENT_AGENT_KEY` and `GH_TOKEN` are also injected for compatibility with adopters that do not enumerate their secret. |
101
101
 
102
102
  ### Two deliberate "no default" choices
103
103
 
@@ -162,7 +162,7 @@ and the agent installs on first use.
162
162
 
163
163
  | MicroVM prop | Omitted behavior |
164
164
  | --- | --- |
165
- | `runnerSecret` | No GitHub or MCP credentials are exported at `/run`. |
165
+ | `runnerSecret` | No GitHub or MCP credentials are exported at `/run`. When supplied, `/run` reads the JSON secret and exports every non-empty value whose key is a valid environment-variable name. |
166
166
  | `runnerOpencodeConfigPath` | OpenCode uses the baked project configuration. Relative paths resolve from the workspace; absolute paths resolve in the image. |
167
167
  | `gitUserName` / `gitUserEmail` | The hook uses the Evident bot defaults for git identity. |
168
168
 
@@ -42866,7 +42866,7 @@ Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.ht
42866
42866
  // ../../node_modules/.pnpm/@aws-sdk+signature-v4-multi-region@3.996.42/node_modules/@aws-sdk/signature-v4-multi-region/dist-cjs/index.js
42867
42867
  var require_dist_cjs23 = __commonJS({
42868
42868
  "../../node_modules/.pnpm/@aws-sdk+signature-v4-multi-region@3.996.42/node_modules/@aws-sdk/signature-v4-multi-region/dist-cjs/index.js"(exports2) {
42869
- var { SignatureV4: SignatureV43, signatureV4aContainer } = require_dist_cjs17();
42869
+ var { SignatureV4: SignatureV43, signatureV4aContainer } = require_dist_cjs2();
42870
42870
  var signatureV4CrtContainer = {
42871
42871
  CrtSignerV4: null
42872
42872
  };
@@ -61,13 +61,25 @@ export type EvidentScaleToZeroConstructProps = {
61
61
  replicaBucket: s3.IBucket;
62
62
  image: ecs.ContainerImage;
63
63
  /**
64
- * Container secrets. Requires EVIDENT_AGENT_KEY + GH_TOKEN; the MCP
65
- * credentials BRAVE_API_KEY / CLOUDFLARE_API_TOKEN / NEON_API_KEY are injected
66
- * only if present (see availableSecretKeys).
64
+ * Container secrets. `availableSecretKeys` are injected by name; EVIDENT_AGENT_KEY
65
+ * and GH_TOKEN remain available to external adopters that do not enumerate keys.
66
+ *
67
+ * `agentSecret` always supplies EVIDENT_AGENT_KEY. When `capabilitySecret` is
68
+ * ALSO given (#1868 WI-2), every capability key (GH_TOKEN included) is injected
69
+ * from THAT secret instead, and `availableSecretKeys` is ignored — the two
70
+ * deploy targets (this one and the MicroVM stack) then read the same
71
+ * capability keys from the same shared secret rather than each carrying its
72
+ * own copy. When `capabilitySecret` is absent, behavior is unchanged: GH_TOKEN
73
+ * plus every `availableSecretKeys` entry, all from `agentSecret` — an external
74
+ * adopter passing only `agentSecret`/`availableSecretKeys` is unaffected.
67
75
  */
68
76
  agentSecret: secretsmanager.ISecret;
69
- /** Key names present in the agent secret; gates the optional MCP creds. */
77
+ /** Key names present in the agent secret. Every listed key is injected into the container. */
70
78
  availableSecretKeys: Set<string>;
79
+ /** The shared capability secret (#1868 WI-2). See `agentSecret`'s doc above. */
80
+ capabilitySecret?: secretsmanager.ISecret;
81
+ /** Key names present in `capabilitySecret`. Ignored when `capabilitySecret` is absent. */
82
+ capabilityKeys?: Set<string>;
71
83
  /** Waker secret (EVIDENT_WAKE_SECRET), consumed by the waker at runtime. */
72
84
  wakerSecret: secretsmanager.ISecret;
73
85
  };
@@ -61,7 +61,7 @@ class EvidentScaleToZeroConstruct extends constructs_1.Construct {
61
61
  service;
62
62
  constructor(scope, id, props) {
63
63
  super(scope, id);
64
- const { agentName, evidentAgentId, gitRepo, gitBranch, idleTimeoutSeconds, cpu, memoryLimitMiB, resourcePrefix = DEFAULT_RESOURCE_PREFIX, envName, evidentApiUrl, evidentTunnelUrl, extraEnvironment, cluster, securityGroup, taskRole, logGroup, replicaBucket, image, agentSecret, availableSecretKeys, wakerSecret, } = props;
64
+ const { agentName, evidentAgentId, gitRepo, gitBranch, idleTimeoutSeconds, cpu, memoryLimitMiB, resourcePrefix = DEFAULT_RESOURCE_PREFIX, envName, evidentApiUrl, evidentTunnelUrl, extraEnvironment, cluster, securityGroup, taskRole, logGroup, replicaBucket, image, agentSecret, availableSecretKeys, capabilitySecret, capabilityKeys, wakerSecret, } = props;
65
65
  this.replicaPrefix = `agents/${evidentAgentId}`;
66
66
  // A plain STRING (not service.serviceName) so the container env is static and
67
67
  // has no construct-ordering dependency on the service.
@@ -127,16 +127,22 @@ class EvidentScaleToZeroConstruct extends constructs_1.Construct {
127
127
  ...extraEnvironment,
128
128
  };
129
129
  const secrets = {
130
+ // Identifies the runner to Evident. Always agentSecret — capabilitySecret
131
+ // never carries this key (#1868 WI-2: the two are a deliberate split, not
132
+ // a duplication of the same data).
130
133
  EVIDENT_AGENT_KEY: ecs.Secret.fromSecretsManager(agentSecret, 'EVIDENT_AGENT_KEY'),
131
- GH_TOKEN: ecs.Secret.fromSecretsManager(agentSecret, 'GH_TOKEN'),
132
134
  };
133
- // Optional MCP creds (consumed by opencode.runner.jsonc): BRAVE_API_KEY →
134
- // brave-search, CLOUDFLARE_API_TOKEN the Cloudflare MCP, NEON_API_KEY
135
- // the Neon MCP (read-only). Injected only if present in the SOPS file — a
136
- // referenced-but-absent key fails ECS task startup, so an operator can
137
- // enable an MCP by just adding its key + redeploy.
138
- for (const key of ['BRAVE_API_KEY', 'CLOUDFLARE_API_TOKEN', 'NEON_API_KEY']) {
139
- if (availableSecretKeys.has(key)) {
135
+ if (capabilitySecret !== undefined) {
136
+ for (const key of capabilityKeys ?? new Set()) {
137
+ secrets[key] = ecs.Secret.fromSecretsManager(capabilitySecret, key);
138
+ }
139
+ }
140
+ else {
141
+ // Legacy shape, unchanged: GH_TOKEN (required to clone this construct's
142
+ // workspace at boot) plus every availableSecretKeys entry, all from
143
+ // agentSecret — what every external adopter still gets.
144
+ secrets.GH_TOKEN = ecs.Secret.fromSecretsManager(agentSecret, 'GH_TOKEN');
145
+ for (const key of availableSecretKeys) {
140
146
  secrets[key] = ecs.Secret.fromSecretsManager(agentSecret, key);
141
147
  }
142
148
  }
@@ -124,7 +124,11 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
124
124
  ...(props.runnerOpencodeConfigPath
125
125
  ? { RUNNER_OPENCODE_CONFIG: props.runnerOpencodeConfigPath }
126
126
  : {}),
127
- ...(props.runnerSecret ? { RUNNER_SECRET_ARN: props.runnerSecret.secretArn } : {}),
127
+ ...(props.runnerSecret
128
+ ? {
129
+ RUNNER_SECRET_ARN: props.runnerSecret.secretArn,
130
+ }
131
+ : {}),
128
132
  ...(props.gitUserName ? { GIT_USER_NAME: props.gitUserName } : {}),
129
133
  ...(props.gitUserEmail ? { GIT_USER_EMAIL: props.gitUserEmail } : {}),
130
134
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Parses the `MICROVM_SHAPES` environment variable (a JSON array, written by
3
- * CloudFormation from `infrastructure/evident-microvm/src/shapes.ts` at
3
+ * CloudFormation from `aws/runner-cdk/src/microvm/shapes.ts` at
4
4
  * deploy time — see D6/D7 in the plan) into a `ShapeCatalogue` the pure
5
5
  * decision core can query, without that core ever touching `process.env`
6
6
  * itself.
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Parses the `MICROVM_SHAPES` environment variable (a JSON array, written by
4
- * CloudFormation from `infrastructure/evident-microvm/src/shapes.ts` at
4
+ * CloudFormation from `aws/runner-cdk/src/microvm/shapes.ts` at
5
5
  * deploy time — see D6/D7 in the plan) into a `ShapeCatalogue` the pure
6
6
  * decision core can query, without that core ever touching `process.env`
7
7
  * itself.
@@ -24,6 +24,8 @@ CONTEXT_FILE="/dev/shm/evident-run-context"
24
24
  TUNNEL_PID_FILE="/dev/shm/evident-tunnel.pid"
25
25
  OPENCODE_PID_FILE="/dev/shm/evident-opencode.pid"
26
26
  LITESTREAM_PID_FILE="/dev/shm/evident-litestream.pid"
27
+ CREDS_SYNC_PID_FILE="/dev/shm/evident-creds-sync.pid"
28
+ CREDS_SYNC_LAST_ERROR_FILE="/dev/shm/evident-creds-sync.last-error"
27
29
 
28
30
  # Where the runner's credential store lives inside the durable-state bucket.
29
31
  # The BUCKET is the same for every VM from an image version, so the stack bakes
@@ -90,7 +92,9 @@ report_session_db_giveup() {
90
92
  # Returns the CLI's own exit code. Domain outcomes (nothing persisted yet, a
91
93
  # corrupt object) are LOGGED and exit 0, the predicates answer "no" with 10, and
92
94
  # `session-db-classify`'s three typed answers are 30 (fatal)/31 (replica
93
- # unusable)/32 (retry) see `runner/synchroniser/src/cli.ts`'s own
95
+ # unusable)/32 (retry), extended by `session-db-verify`'s 33 (integrity
96
+ # exhausted, replica separated and local disposed) / 34 (could not prove
97
+ # separation or disposal) — see `runner/synchroniser/src/cli.ts`'s own
94
98
  # comment for what each means, not restated here. Any OTHER non-zero status
95
99
  # means the tool itself broke, which is the only case worth an ERROR here —
96
100
  # EXCEPT 124/137 (a `timeout` deadline/SIGKILL) when the caller asked for one:
@@ -116,7 +120,7 @@ run_synchroniser() {
116
120
  [ -n "${deadline}" ] && launcher=(timeout -k "${CREDENTIAL_RESTORE_KILL_GRACE_SECONDS}" "${deadline}")
117
121
  "${launcher[@]}" "${SYNCHRONISER}" "${call_args[@]}" || rc=$?
118
122
  case "${rc}" in
119
- 0 | 10 | 30 | 31 | 32) ;;
123
+ 0 | 10 | 30 | 31 | 32 | 33 | 34) ;;
120
124
  124 | 137)
121
125
  [ -n "${deadline}" ] || error "synchroniser '${call_args[*]}' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw"
122
126
  ;;
@@ -303,7 +307,7 @@ bounded_restore_call() {
303
307
  }
304
308
 
305
309
  fetch_runner_secret() {
306
- local step_started_s="$1" remaining rc=0 payload value stderr_file started_ms populated=0 missing=0
310
+ local step_started_s="$1" remaining rc=0 payload stderr_file started_ms populated=0 skipped=0 key value
307
311
  if [ -z "${RUNNER_SECRET_ARN:-}" ]; then
308
312
  log "runner secret is not configured; continuing without GitHub and MCP credentials"
309
313
  return 0
@@ -335,21 +339,19 @@ fetch_runner_secret() {
335
339
  warn "RUNNER-SECRET-UNPARSEABLE: secret value is not a JSON object"
336
340
  return 0
337
341
  fi
338
- local -a populated_keys=()
339
- for key in GH_TOKEN BRAVE_API_KEY CLOUDFLARE_API_TOKEN NEON_API_KEY; do
340
- value="$(jq -r --arg k "${key}" '.[$k] // empty' <<<"${payload}")"
341
- if [ -n "${value}" ]; then
342
- export "${key}=${value}"
343
- populated=$((populated + 1))
344
- populated_keys+=("${key}")
345
- else
346
- missing=$((missing + 1))
342
+ while IFS= read -r -d '' key && IFS= read -r -d '' value; do
343
+ if [[ ! "${key}" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
344
+ warn "RUNNER-SECRET-KEY-SKIPPED: ${key@Q} is not a valid environment variable name"
345
+ skipped=$((skipped + 1))
346
+ continue
347
347
  fi
348
- done
348
+ export "${key}=${value}"
349
+ populated=$((populated + 1))
350
+ done < <(jq -j 'to_entries[] | select(.value | type == "string" and length > 0) | .key, "\u0000", .value, "\u0000"' <<<"${payload}")
349
351
  if [ "${populated}" -eq 0 ]; then
350
- warn "RUNNER-SECRET-UNPOPULATED: populate the runner secret as documented in infrastructure/evident-microvm/README.md"
352
+ warn "RUNNER-SECRET-UNPOPULATED: populate the runner secret as documented in infrastructure/evident-runner/MICROVM.md"
351
353
  else
352
- log "RUNNER-SECRET-OK: populated ${populated_keys[*]}; ${missing} allow-listed keys empty or absent"
354
+ log "RUNNER-SECRET-OK: exported ${populated} secret values; skipped ${skipped} invalid environment variable names"
353
355
  fi
354
356
  return 0
355
357
  }
@@ -385,7 +387,7 @@ restore_credentials() {
385
387
  (neither claude/credentials.json nor opencode/auth.json yielded valid JSON) and neither \
386
388
  ANTHROPIC_API_KEY nor OPENAI_API_KEY is set. This VM boots and connects; a turn that needs \
387
389
  a model provider fails until one is connected. See 'Seeding a credential store' in \
388
- infrastructure/evident-microvm/README.md."
390
+ infrastructure/evident-runner/MICROVM.md."
389
391
  ;;
390
392
  124 | 137)
391
393
  warn "CREDENTIAL-RESTORE-TIMEOUT: model-auth-ready did not finish within its ${auth_remaining}s share of the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore deadline; continuing without it"
@@ -777,6 +779,99 @@ restore_session_db() {
777
779
  }
778
780
  # --- Session DB restore (end) -----------------------------------------------
779
781
 
782
+ # --- Session DB integrity verification (#1868 WI-4) --------------------------
783
+ #
784
+ # The restore loop above only proves litestream could WRITE a file; it never
785
+ # proves the file is intact (#1345: a leaf-page-corrupt-but-openable DB
786
+ # re-restores unchanged forever). `session-db-verify` runs a real `PRAGMA
787
+ # integrity_check` and, on failure, walks retained restore points back until
788
+ # one passes — the MicroVM side of the identical check
789
+ # runner/docker-images/fargate/entrypoint.sh already runs after ITS restore
790
+ # loop, through the same runner-synchroniser CLI.
791
+
792
+ # Bounded by `timed_synchroniser`, not an external `timeout` around
793
+ # `run_synchroniser` itself (a shell function, not an exported binary — that
794
+ # would fail with rc 127, silently mis-triggering the allowlist's "tool
795
+ # broke" branch). The grace period is `CREDENTIAL_RESTORE_KILL_GRACE_SECONDS`
796
+ # (2s): `run_synchroniser`'s `timeout -k` hardcodes that one constant for
797
+ # every `timed_synchroniser` caller, not a value unique to this step, so the
798
+ # real worst case is DEADLINE + 2s, once — accounted for in
799
+ # hook-scripts.test.ts's budget-ladder test.
800
+ #
801
+ # 4s, NOT measured on this platform: #930's synchroniser-call sample put a
802
+ # single call at ~1.2s (a node cold start), and this ONE call also runs a
803
+ # PRAGMA integrity_check that scales with the restored DB's size, which #930
804
+ # never exercised. The 124/137 branch below is the DESIGNED-FOR outcome on a
805
+ # large DB, not an edge case — SYNCHRONISER-TIMING op=session-db-verify is
806
+ # what should actually size this once real boots report it.
807
+ SESSION_DB_VERIFY_DEADLINE_SECONDS="${EVIDENT_SESSION_DB_VERIFY_DEADLINE_SECONDS:-4}"
808
+
809
+ verify_session_db() {
810
+ # Guards mirror start_litestream's first three, in the same order and for
811
+ # the same reason: verifying a DB this boot already decided not to
812
+ # replicate (or never restored at all) changes nothing about how /run
813
+ # proceeds, and would spend budget only to report on a moot outcome.
814
+ if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
815
+ log "skipping session-DB verification: persistence is disabled"
816
+ return 0
817
+ fi
818
+
819
+ if [ -e "${SESSION_DB_NO_REPLICATE_MARKER}" ]; then
820
+ log "skipping session-DB verification: this boot's session DB was not proven safe to replicate (see the SESSION-DB-* warning above)"
821
+ return 0
822
+ fi
823
+
824
+ if [ ! -s "${LITESTREAM_CONFIG_FILE}" ]; then
825
+ log "skipping session-DB verification: no usable ${LITESTREAM_CONFIG_FILE}"
826
+ return 0
827
+ fi
828
+
829
+ # Exported below this step's own deadline so the CLI's in-process walkback
830
+ # loop gives up on its own before the process-group kill lands — its 180s
831
+ # default (config.ts) is far outside this one step's slice of the hook's
832
+ # SIGTERM budget.
833
+ local verify_rc=0
834
+ EVIDENT_SESSION_DB_WALKBACK_BUDGET_SECONDS="${SESSION_DB_VERIFY_DEADLINE_SECONDS}" \
835
+ timed_synchroniser session-db-verify "${SESSION_DB_VERIFY_DEADLINE_SECONDS}" \
836
+ session-db-verify "${LITESTREAM_CONFIG_FILE}" || verify_rc=$?
837
+
838
+ case "${verify_rc}" in
839
+ 0) ;; # verified intact, or nothing to verify yet — the CLI already logged which
840
+ 33)
841
+ # Integrity exhausted, but the classifier already proved the corrupt
842
+ # replica separated and the local copy disposed of — booting with a
843
+ # fresh DB and a new backup chain is safe, exactly like ECS's own `33)`.
844
+ log "SESSION-DB-INTEGRITY-EXHAUSTED: booting continues and litestream still replicates, starting an empty backup chain after the corrupt replica was separated."
845
+ ;;
846
+ 34)
847
+ # The ONE deliberate exception to "nothing about the session DB may
848
+ # ever fail /run" (Q3): a 34 means separation/disposal could NOT be
849
+ # proven, so continuing would hand opencode a DB it may not be safe to
850
+ # open or write — the same evidence-quality bar `check_runner_key`
851
+ # already applies one step earlier in this hook (contrary evidence,
852
+ # not absent evidence, is what's fatal). The CLI already logged
853
+ # SESSION-DB-REPLICA-SEPARATION-UNVERIFIED / SESSION-DB-LOCAL-DISCARD-FAILED.
854
+ error "SESSION-DB-INTEGRITY-EXHAUSTED: the corrupt session DB could not be proven separated from the active backup prefix or removed from disk, so nothing will be started (see the ERROR above)."
855
+ return 1
856
+ ;;
857
+ 124 | 137)
858
+ # The designed-for outcome on a large DB (see the deadline comment
859
+ # above), not a broken tool: continue with the restored DB exactly as
860
+ # ECS's own `*)` branch does for an unexpected code.
861
+ warn "SESSION-DB-VERIFY-TIMEOUT: verification did not finish within its ${SESSION_DB_VERIFY_DEADLINE_SECONDS}s deadline; continuing with the restored opencode.db as-is, unverified"
862
+ ;;
863
+ *)
864
+ # run_synchroniser already logged the "tool broke" ERROR for this. A
865
+ # broken verifier must not turn a boot that works today into a
866
+ # crash-loop.
867
+ warn "SESSION-DB-VERIFY-UNKNOWN: session-db-verify exited ${verify_rc}, which is none of its documented answers; continuing with the restored opencode.db as-is"
868
+ ;;
869
+ esac
870
+
871
+ return 0
872
+ }
873
+ # --- Session DB integrity verification (end) ---------------------------------
874
+
780
875
  # `kill -0` answers "does this pid exist", which is not the question any caller
781
876
  # here is asking. A process that has exited but has not been reaped — a zombie —
782
877
  # still exists, so `kill -0` reports a corpse as ALIVE. That condition is the
@@ -869,6 +964,7 @@ regenerate_machine_id() {
869
964
  tunnel_is_running() { is_running "${TUNNEL_PID_FILE}"; }
870
965
  opencode_is_running() { is_running "${OPENCODE_PID_FILE}"; }
871
966
  litestream_is_running() { is_running "${LITESTREAM_PID_FILE}"; }
967
+ creds_sync_is_running() { is_running "${CREDS_SYNC_PID_FILE}"; }
872
968
 
873
969
  # `jq -e` alone is not enough: its exit status reflects the LAST OUTPUT VALUE,
874
970
  # and an interpolation of a missing field is still a non-empty string, so a
@@ -1089,6 +1185,155 @@ kill_litestream() {
1089
1185
  }
1090
1186
  # --- litestream replicate (end) ----------------------------------------------
1091
1187
 
1188
+ # --- credential sync loop (#1868 WI-3, ECS parity) ---------------------------
1189
+ #
1190
+ # sync_credentials (above) covers the three boundary flushes /run's restore,
1191
+ # /suspend and /terminate already call. What it does NOT cover is a VM that
1192
+ # runs for a long time between those boundaries: a provider re-authenticated
1193
+ # through the proxied UI hours into a run would sit unflushed until the next
1194
+ # suspend/terminate, and a VM that dies without one (a crash, an OOM kill)
1195
+ # loses everything since boot. runner/docker-images/fargate/entrypoint.sh's
1196
+ # own sync_credentials_loop is the ECS side of the identical gap; this is the
1197
+ # same fix, backgrounded the same way as start_opencode/start_litestream so it
1198
+ # outlives this hook process, `( … ) &` rather than `setsid`: a plain
1199
+ # backgrounded subshell is reparented to init and keeps running once its
1200
+ # parent hook script exits (verified: PPID=1, still alive, with no controlling
1201
+ # terminal in this image to send it a stray SIGHUP), and it inherits every
1202
+ # function this file defines, so it can call run_synchroniser directly with no
1203
+ # re-exec.
1204
+
1205
+ # Bounded confirmation window `stop_credential_sync` polls after signalling the
1206
+ # loop, sized against the SIGTERM budget ladder (#812 WI-4's
1207
+ # hook-scripts.test.ts): /terminate's own steps already use 49 of the 55s
1208
+ # ceiling, leaving 6s of headroom — this matches the *_KILL_GRACE_SECONDS
1209
+ # convention (CREDENTIAL_RESTORE_KILL_GRACE_SECONDS,
1210
+ # SESSION_DB_RESTORE_KILL_GRACE_SECONDS, both 2s) rather than a longer
1211
+ # drain-style wait, since the loop's current child is one fast
1212
+ # `run_synchroniser sync-once` call (#930: ~1.2s measured), not a writer
1213
+ # needing a graceful drain.
1214
+ CREDS_SYNC_STOP_WAIT_SECONDS=2
1215
+
1216
+ # Best-effort per tick, exactly like sync_credentials above: a failed tick
1217
+ # must never end the loop, or a single transient S3 error would silently
1218
+ # disable sync for the rest of the VM's life.
1219
+ start_credential_sync() {
1220
+ if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
1221
+ warn "CREDS-SYNC-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; no interval credential sync this boot."
1222
+ return 0
1223
+ fi
1224
+
1225
+ if creds_sync_is_running; then
1226
+ warn "credential sync loop already running (pid $(cat "${CREDS_SYNC_PID_FILE}")); reusing it"
1227
+ return 0
1228
+ fi
1229
+
1230
+ # CREDS_SYNC_INTERVAL is exported by restore_session_db's `eval "$(run_synchroniser env)"`
1231
+ # on /run (config.ts's own default is 60s), but that eval can fail or be skipped by an
1232
+ # earlier give-up — never leave the loop unbound under set -u for a value with a safe,
1233
+ # named fallback (unlike OPENCODE_DB_PATH, a guessed sync cadence is not actively wrong).
1234
+ local interval="${CREDS_SYNC_INTERVAL:-60}"
1235
+ if [ -z "${CREDS_SYNC_INTERVAL:-}" ]; then
1236
+ warn "CREDS-SYNC-INTERVAL-DEFAULTED: CREDS_SYNC_INTERVAL was not set by run_synchroniser env; using ${interval}s"
1237
+ fi
1238
+
1239
+ rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
1240
+
1241
+ (
1242
+ # Releases the fds this subshell inherited from the hook process before
1243
+ # settling in for the VM's whole remaining life: nothing here writes to
1244
+ # them (every synchroniser call already redirects its own), so there is
1245
+ # no reason to keep holding the hook's original stdout/stderr open. A
1246
+ # long-lived process that instead inherited a pipe's write end (a test
1247
+ # harness reading the hook's own output, for one) would keep that pipe
1248
+ # from ever reporting EOF — testing-guide.mdc's own lesson, and the same
1249
+ # reason start_opencode/start_litestream never inherit stdio either. That
1250
+ # redirect also means `warn`/`log`/`error` calls in here go nowhere, so a
1251
+ # failed sync-once is instead recorded to CREDS_SYNC_LAST_ERROR_FILE and
1252
+ # surfaced by stop_credential_sync, which DOES have live stdio.
1253
+ exec >/dev/null 2>&1 </dev/null
1254
+
1255
+ # A TERM this subshell receives (from stop_credential_sync, below) only
1256
+ # kills THIS wrapper by default — its currently-running child (`sleep`,
1257
+ # or a `run_synchroniser sync-once` call) is a separate process that
1258
+ # would otherwise be orphaned and keep running, free to upload STALE
1259
+ # credentials to S3 after the boundary flush that /suspend and
1260
+ # /terminate perform immediately following the stop. Tracking the
1261
+ # current child explicitly and forwarding the signal closes that race.
1262
+ creds_sync_child_pid=""
1263
+ trap 'trap - TERM; [ -n "${creds_sync_child_pid}" ] && kill -TERM "${creds_sync_child_pid}" 2>/dev/null; exit 0' TERM
1264
+
1265
+ while true; do
1266
+ sleep "${interval}" &
1267
+ creds_sync_child_pid=$!
1268
+ wait "${creds_sync_child_pid}" 2>/dev/null
1269
+ creds_sync_child_pid=""
1270
+
1271
+ run_synchroniser sync-once claude &
1272
+ creds_sync_child_pid=$!
1273
+ wait "${creds_sync_child_pid}" 2>/dev/null || echo "claude" >"${CREDS_SYNC_LAST_ERROR_FILE}"
1274
+ creds_sync_child_pid=""
1275
+
1276
+ run_synchroniser sync-once opencode &
1277
+ creds_sync_child_pid=$!
1278
+ wait "${creds_sync_child_pid}" 2>/dev/null || echo "opencode" >"${CREDS_SYNC_LAST_ERROR_FILE}"
1279
+ creds_sync_child_pid=""
1280
+ done
1281
+ ) &
1282
+
1283
+ echo $! >"${CREDS_SYNC_PID_FILE}"
1284
+ log "CREDS-SYNC-STARTED: pid=$! interval=${interval}s"
1285
+ }
1286
+
1287
+ # Signals the loop, then confirms (bounded — see CREDS_SYNC_STOP_WAIT_SECONDS)
1288
+ # that it and its current child are actually gone before returning: /suspend
1289
+ # and /terminate start their own boundary flush immediately after this call,
1290
+ # and an orphaned in-flight sync-once surviving past that point can overwrite
1291
+ # fresher credentials with stale ones. The TERM trap inside the loop (above)
1292
+ # forwards the signal to its current child almost instantly — this poll is a
1293
+ # defensive confirmation, not the primary mechanism, so it stays short; a
1294
+ # SIGKILL backstop covers a child that ignores TERM entirely.
1295
+ #
1296
+ # The DIED branch is a liveness report, not a no-op: every recovery/no-op path
1297
+ # must say what it found (development-workflow.mdc) — a stopped-before-called
1298
+ # loop and a died-on-its-own loop are different facts an operator needs told
1299
+ # apart, not the same "nothing to stop" line.
1300
+ stop_credential_sync() {
1301
+ if [ ! -s "${CREDS_SYNC_PID_FILE}" ]; then
1302
+ log "CREDS-SYNC-NOT-RUNNING: no credential sync loop to stop"
1303
+ return 0
1304
+ fi
1305
+
1306
+ local pid
1307
+ pid="$(cat "${CREDS_SYNC_PID_FILE}")"
1308
+ if ! process_is_alive "${pid}"; then
1309
+ rm -f "${CREDS_SYNC_PID_FILE}"
1310
+ warn "CREDS-SYNC-DIED: credential sync loop (pid=${pid}) had already exited before this stop"
1311
+ return 0
1312
+ fi
1313
+
1314
+ kill -TERM "${pid}" 2>/dev/null || true
1315
+ rm -f "${CREDS_SYNC_PID_FILE}"
1316
+
1317
+ local waited_ms=0
1318
+ while process_is_alive "${pid}" && [ "${waited_ms}" -lt $((CREDS_SYNC_STOP_WAIT_SECONDS * 1000)) ]; do
1319
+ sleep 0.1
1320
+ waited_ms=$((waited_ms + 100))
1321
+ done
1322
+
1323
+ if process_is_alive "${pid}"; then
1324
+ kill -KILL "${pid}" 2>/dev/null || true
1325
+ warn "CREDS-SYNC-STOP-TIMEOUT: pid=${pid} still alive after ${CREDS_SYNC_STOP_WAIT_SECONDS}s; sent SIGKILL"
1326
+ fi
1327
+
1328
+ if [ -s "${CREDS_SYNC_LAST_ERROR_FILE}" ]; then
1329
+ warn "CREDS-SYNC-HAD-FAILURES: sync-once failed at least once for: $(tr '\n' ' ' <"${CREDS_SYNC_LAST_ERROR_FILE}")"
1330
+ rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
1331
+ fi
1332
+
1333
+ log "CREDS-SYNC-STOPPED: pid=${pid}"
1334
+ }
1335
+ # --- credential sync loop (end) -----------------------------------------------
1336
+
1092
1337
  # --- flush_session_db (#812 WI-4) -------------------------------------------
1093
1338
  #
1094
1339
  # The checked, synchronous flush /suspend and /terminate need before they
@@ -5,7 +5,10 @@
5
5
  # session-DB replicator /suspend stopped before the snapshot (#812 WI-4):
6
6
  # litestream does not survive a suspend/resume freeze on this design (Q2) —
7
7
  # /suspend stops it and /resume starts a fresh one, the same pattern already
8
- # proven for the tunnel.
8
+ # proven for the tunnel. The interval credential sync loop (#1868 WI-3) is the
9
+ # same story one function over: /suspend stops it too, so a resumed VM that
10
+ # never restarted it here would never sync credentials again for the rest of
11
+ # its life.
9
12
  set -euo pipefail
10
13
 
11
14
  # shellcheck source=./common.sh
@@ -46,6 +49,7 @@ fi
46
49
  # already-running) handle the rest — this needs no logic of its own.
47
50
  load_state_config || warn "could not resolve durable-state config; the session DB will not resume replicating"
48
51
  start_litestream
52
+ start_credential_sync
49
53
 
50
54
  # Diagnostic-only, unlike /run's gate: a failed resume costs the user their
51
55
  # whole session, so this never exits — it only converts a silent "resumed but
@@ -28,6 +28,7 @@ cleanup() {
28
28
  stop_tunnel || warn "stop_tunnel failed while cleaning up"
29
29
  stop_opencode || warn "stop_opencode failed while cleaning up"
30
30
  kill_litestream || warn "kill_litestream failed while cleaning up"
31
+ stop_credential_sync || warn "stop_credential_sync failed while cleaning up"
31
32
  }
32
33
  trap cleanup EXIT
33
34
 
@@ -79,13 +80,22 @@ check_runner_key "${runner_key}" "${api_url}" || exit 1
79
80
  restore_session_db
80
81
  log "session DB restore done ${SECONDS}s into the hook"
81
82
 
82
- # 7 — apply the overlay before OpenCode resolves its project configuration.
83
+ # 7 — integrity-check the restored DB (#1868 WI-4), after the config file
84
+ # exists (verify_session_db reads it) and before anything opens the DB —
85
+ # the only window in which that's true. The ONE step in this sequence that
86
+ # can still fail /run past the runner-key gate: a 34 means the corrupt DB's
87
+ # separation/disposal could not be proven safe (see verify_session_db's own
88
+ # comment for why that's a deliberate exception to "nothing about the
89
+ # session DB may ever fail /run").
90
+ verify_session_db || exit 1
91
+
92
+ # 8 — apply the overlay before OpenCode resolves its project configuration.
83
93
  apply_runner_opencode_config
84
94
 
85
- # 8 — configure git after credentials are restored and before agent shells start.
95
+ # 9 — configure git after credentials are restored and before agent shells start.
86
96
  configure_github_access
87
97
 
88
- # 9 — opencode. Started here, not at build time: a warm process in the shared
98
+ # 10 — opencode. Started here, not at build time: a warm process in the shared
89
99
  # snapshot would carry its installation id and database into every VM. Not
90
100
  # waited on: a slow opencode boot is not a reason to fail /run (the tunnel CLI
91
101
  # auto-starts opencode when it finds none healthy,
@@ -93,14 +103,19 @@ configure_github_access
93
103
  # reclaims a runner that never comes online).
94
104
  start_opencode
95
105
 
96
- # 10 — begin replicating the session DB (#812 WI-3), now that opencode has
106
+ # 11 — begin replicating the session DB (#812 WI-3), now that opencode has
97
107
  # opened it and before any work can arrive over the tunnel. Bare, like
98
108
  # restore_session_db above: start_litestream never returns non-zero (every
99
109
  # guard inside it is its own `return 0`), so there is nothing here for
100
110
  # `set -e` to abort on.
101
111
  start_litestream
102
112
 
103
- # 11 — the first per-VM identity on the wire. The subshell's umask makes the file
113
+ # 11a — the interval credential sync (#1868 WI-3), matching ECS's own
114
+ # post-litestream position. Bare for the same reason: every guard inside
115
+ # start_credential_sync is its own `return 0`.
116
+ start_credential_sync
117
+
118
+ # 12 — the first per-VM identity on the wire. The subshell's umask makes the file
104
119
  # unreadable to anyone else from the moment it exists, before the key is in it.
105
120
  (
106
121
  umask 077
@@ -8,6 +8,9 @@ set -euo pipefail
8
8
  # shellcheck source=./common.sh
9
9
  source "$(dirname "$0")/common.sh"
10
10
 
11
+ # Stopped BEFORE the boundary flush (#1868 WI-3): the interval loop and this
12
+ # flush must not race each other on the same credential stores.
13
+ stop_credential_sync
11
14
  sync_credentials
12
15
  stop_tunnel
13
16
 
@@ -8,6 +8,9 @@ set -uo pipefail
8
8
  # shellcheck source=./common.sh
9
9
  source "$(dirname "$0")/common.sh"
10
10
 
11
+ # Stopped BEFORE the boundary flush (#1868 WI-3): the interval loop and this
12
+ # flush must not race each other on the same credential stores.
13
+ stop_credential_sync
11
14
  sync_credentials
12
15
  stop_tunnel
13
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evident-ai/runner-cdk",
3
- "version": "3.4.1-dev.1856549",
3
+ "version": "3.4.1-dev.2f1b44b",
4
4
  "description": "Reusable CDK constructs for an Evident agent runner: a single scale-to-zero Fargate runner (task + service + per-agent self-stop role + waker Lambda), or a per-session AWS Lambda MicroVM that boots on demand and suspends between messages. Instantiate once per agent from your own stack.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",