@evident-ai/runner-cdk 3.4.1-dev.f80aa1c → 3.4.1-dev.fb92ede

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
@@ -11,7 +11,7 @@ Reusable AWS CDK constructs for running an [Evident](https://evident.run) agent
11
11
  - **`EvidentMicrovmConstruct`** — a per-session AWS Lambda MicroVM that boots on demand
12
12
  and suspends between messages. Installing the `@dev` tag of this package is all you
13
13
  need: the image build context ships inside it (see [The MicroVM image](#the-microvm-image)
14
- below), so there is no checkout of `sroze/evident` involved. See
14
+ below), so there is no checkout of the source repository involved. See
15
15
  [the AWS runner doc](https://evident.run/docs/aws-runner) for the full picture of both
16
16
  strategies.
17
17
 
@@ -76,8 +76,8 @@ agent.selfStopRoleArn;
76
76
  agent.replicaPrefix;
77
77
  ```
78
78
 
79
- For a complete working example, see `infrastructure/evident-runner/` in this repo — it is
80
- the construct's own dogfooding consumer.
79
+ For a complete working example, see [`GETTING-STARTED.md`](./GETTING-STARTED.md) in this
80
+ package — it is the construct's own dogfooding consumer.
81
81
 
82
82
  ## Props
83
83
 
@@ -121,10 +121,11 @@ context, which `cdk deploy` zips and uploads. The Lambda MicroVM service then bu
121
121
  image in *your* account, from a base image ARN you discover there — so, unlike the Fargate
122
122
  strategy, there is no registry image to pull.
123
123
 
124
- The context has two halves, and this package ships the half that is ours: the Dockerfile,
125
- the per-phase hook scripts and the bundled hook server, published inside the tarball at
126
- `dist/microvm-image-context/`. The other half is **your** repository, which is baked in as
127
- the agent's workspace. `stageMicrovmImageContext()` puts the two together:
124
+ The context has three parts: this package supplies the Dockerfile, the per-phase hook
125
+ scripts and the bundled hook server, published inside the tarball at
126
+ `dist/microvm-image-context/`; **your** repository is baked in as the agent's workspace;
127
+ and an optional overlay supplies deployment-specific build steps. `stageMicrovmImageContext()`
128
+ puts them together:
128
129
 
129
130
  ```ts
130
131
  import path from 'node:path';
@@ -143,6 +144,8 @@ new EvidentMicrovmConstruct(this, 'Runner', {
143
144
  // token the agent pushes with arrives per-session, never baked in.
144
145
  originUrl: 'https://github.com/acme/widgets.git',
145
146
  destination: path.join(__dirname, '..', 'build', 'image'),
147
+ // Optional deployment-specific installs and build warm-up.
148
+ overlayDir: '/path/to/microvm-overlay',
146
149
  }),
147
150
  // Must match the published image these were baked into, so export them
148
151
  // rather than restating the numbers.
@@ -156,6 +159,11 @@ new EvidentMicrovmConstruct(this, 'Runner', {
156
159
  });
157
160
  ```
158
161
 
162
+ Pass `overlayDir` to add deployment-specific build steps to the staged image. The
163
+ directory provides `setup-root` for root-owned installs and `setup-workspace` for
164
+ workspace build warm-up after dependency installation. See the [MicroVM image
165
+ README](../../runner/docker-images/microvm/README.md) for the full overlay contract.
166
+
159
167
  Your repo does not have to be a pnpm workspace. If it commits a `pnpm-lock.yaml` the image
160
168
  pre-installs dependencies at build time (a faster first boot); if not, that step is skipped
161
169
  and the agent installs on first use.
@@ -164,16 +172,21 @@ and the agent installs on first use.
164
172
  | --- | --- |
165
173
  | `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
174
  | `runnerOpencodeConfigPath` | OpenCode uses the baked project configuration. Relative paths resolve from the workspace; absolute paths resolve in the image. |
175
+ | `extraImageEnvironment` | Non-secret values baked into every VM launched from this image version; every VM can read them, so never put secrets here. |
167
176
  | `gitUserName` / `gitUserEmail` | The hook uses the Evident bot defaults for git identity. |
168
177
  | `evidentApiUrl` / `evidentRunnerId` | No deploy-time report of the published image version — a byte-identical template to not having these props at all. Both non-secret (a URL, a public pool UUID) and both required together: set them if you have an Evident runner id configured for this provisioner, so each deploy that publishes a new image version tells Evident about it (a CloudFormation custom resource, HMAC-signed with `doorbellSecret`). A failure to report never fails your deploy — it warns and Evident reads that provisioner as *unknown*, never as up to date or stale. |
169
178
 
179
+ `extraImageEnvironment` is the runtime counterpart to an overlay. Use it when an
180
+ overlay-installed component needs an environment variable after the VM boots. The
181
+ values are baked into every VM launched from that image version and are readable by
182
+ all of them, so never put a secret in this prop.
183
+
170
184
  ## Status / limitations
171
185
 
172
186
  - **Published to npm** as `@evident-ai/runner-cdk` (MIT). Install the `@dev` tag —
173
187
  `npm install @evident-ai/runner-cdk@dev` (or `pnpm add`/`yarn add`); no checkout of
174
188
  this repo is required. It builds to a self-contained `dist/` (`pnpm --filter
175
189
  @evident-ai/runner-cdk build`) with no `workspace:`/`@evident/*` runtime dependency.
176
- This repo's own consumer (`infrastructure/evident-runner`) still consumes it as a
177
- `workspace:*` dependency for dogfooding.
190
+ Evident's own consumer still consumes it as a `workspace:*` dependency for dogfooding.
178
191
  - **AWS/ECS-specific.** Non-AWS clouds are an open question on the epic, not a supported
179
192
  path.
@@ -49854,7 +49854,8 @@ function parseDoorbell(rawBody) {
49854
49854
  occurred_at: occurredAt,
49855
49855
  microvm_id: microvmId,
49856
49856
  run_payload: runPayload,
49857
- shape
49857
+ shape,
49858
+ recreate_on_outdated_image: recreateOnOutdatedImage
49858
49859
  } = body;
49859
49860
  if (type !== SUSPEND_EVENT_TYPE && type !== SHAPES_EVENT_TYPE && !isWakeType(type)) {
49860
49861
  return { ok: false, reason: DOORBELL_REJECTION.unsupportedType };
@@ -49885,10 +49886,14 @@ function parseDoorbell(rawBody) {
49885
49886
  if (Buffer.byteLength(runHookPayload, "utf8") > RUN_HOOK_PAYLOAD_MAX_BYTES) {
49886
49887
  return { ok: false, reason: DOORBELL_REJECTION.runPayloadTooLarge };
49887
49888
  }
49888
- return {
49889
- ok: true,
49890
- doorbell: shape === void 0 ? { ...fields, type, runHookPayload } : { ...fields, type, shape, runHookPayload }
49891
- };
49889
+ const doorbell = { ...fields, type, runHookPayload };
49890
+ if (shape !== void 0) {
49891
+ doorbell.shape = shape;
49892
+ }
49893
+ if (recreateOnOutdatedImage === true) {
49894
+ doorbell.recreateOnOutdatedImage = true;
49895
+ }
49896
+ return { ok: true, doorbell };
49892
49897
  }
49893
49898
 
49894
49899
  // src/microvm/controller/throttle-retry.ts
@@ -50128,7 +50133,7 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
50128
50133
  imageVersion: described.imageVersion
50129
50134
  });
50130
50135
  case "SUSPENDED":
50131
- if (await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm)) {
50136
+ if (doorbell.recreateOnOutdatedImage === true && await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm)) {
50132
50137
  return runMicrovm(
50133
50138
  doorbell,
50134
50139
  shape,
@@ -17,7 +17,7 @@ export type EvidentScaleToZeroConstructProps = {
17
17
  * (`agents/<id>`) and is the single expected `agent_id` the waker checks.
18
18
  */
19
19
  evidentAgentId: string;
20
- /** GitHub `owner/repo` the agent clones and works on (e.g. `sroze/evident`). */
20
+ /** GitHub `owner/repo` the agent clones and works on (e.g. `my-org/my-repo`). */
21
21
  gitRepo: string;
22
22
  /** Branch to clone. Defaults to `main` when omitted. */
23
23
  gitBranch?: string;
@@ -168,9 +168,8 @@ class EvidentScaleToZeroConstruct extends constructs_1.Construct {
168
168
  cluster,
169
169
  taskDefinition,
170
170
  serviceName,
171
- // Starts at 1; the runner self-scales to 0 on idle. A deploy resets this to
172
- // 1 (CFN), waking a sleeping agent that then re-naps (benign).
173
- desiredCount: 1,
171
+ // Deliberately omit DesiredCount: CloudFormation leaves the existing service's
172
+ // self-stop/waker count alone; either 1 or 0 would re-assert it on every deploy.
174
173
  enableExecuteCommand: true,
175
174
  assignPublicIp: true,
176
175
  vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
@@ -55,6 +55,12 @@ export interface EvidentMicrovmConstructProps {
55
55
  readonly gitUserName?: string;
56
56
  /** Optional git email the `/run` hook configures for agent commits. */
57
57
  readonly gitUserEmail?: string;
58
+ /**
59
+ * Non-secret values baked into every VM launched from this image version,
60
+ * typically values a deployment's overlay needs at runtime. Every VM can
61
+ * read them, so this must never contain a secret.
62
+ */
63
+ readonly extraImageEnvironment?: Record<string, string>;
58
64
  /**
59
65
  * TCP port the image's hook server listens on, baked into both the image
60
66
  * (`HOOKS_PORT` env var) and the `MicrovmImage`'s `hooks.port` — a mismatch
@@ -141,6 +141,7 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
141
141
  : {}),
142
142
  ...(props.gitUserName ? { GIT_USER_NAME: props.gitUserName } : {}),
143
143
  ...(props.gitUserEmail ? { GIT_USER_EMAIL: props.gitUserEmail } : {}),
144
+ ...props.extraImageEnvironment,
144
145
  };
145
146
  // ...and the hooks configuration every shape's image is built with.
146
147
  const imageHooks = {
@@ -40,6 +40,11 @@ export type WakeDoorbell = DoorbellFields & {
40
40
  * shape" — the controller resolves it against the shape catalogue.
41
41
  */
42
42
  shape?: string;
43
+ /**
44
+ * Set only when the body carries the literal `true`. When absent, the
45
+ * suspended VM is always resumed because recreating it destroys its filesystem.
46
+ */
47
+ recreateOnOutdatedImage?: boolean;
43
48
  };
44
49
  export type Doorbell = WakeDoorbell | (DoorbellFields & {
45
50
  type: typeof SUSPEND_EVENT_TYPE;
@@ -63,7 +63,7 @@ function parseDoorbell(rawBody) {
63
63
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
64
64
  return { ok: false, reason: exports.DOORBELL_REJECTION.invalidJson };
65
65
  }
66
- const { type, runner_id: runnerId, occurred_at: occurredAt, microvm_id: microvmId, run_payload: runPayload, shape, } = body;
66
+ const { type, runner_id: runnerId, occurred_at: occurredAt, microvm_id: microvmId, run_payload: runPayload, shape, recreate_on_outdated_image: recreateOnOutdatedImage, } = body;
67
67
  if (type !== SUSPEND_EVENT_TYPE && type !== exports.SHAPES_EVENT_TYPE && !isWakeType(type)) {
68
68
  return { ok: false, reason: exports.DOORBELL_REJECTION.unsupportedType };
69
69
  }
@@ -95,13 +95,14 @@ function parseDoorbell(rawBody) {
95
95
  if (Buffer.byteLength(runHookPayload, 'utf8') > constants_1.RUN_HOOK_PAYLOAD_MAX_BYTES) {
96
96
  return { ok: false, reason: exports.DOORBELL_REJECTION.runPayloadTooLarge };
97
97
  }
98
- // Omit `shape` entirely when absent, rather than setting it to `undefined`,
99
- // so a body with no shape parses to exactly the same object as before this
100
- // field existed.
101
- return {
102
- ok: true,
103
- doorbell: shape === undefined
104
- ? { ...fields, type, runHookPayload }
105
- : { ...fields, type, shape, runHookPayload },
106
- };
98
+ // Omit optional fields entirely when absent, rather than setting them to
99
+ // `undefined`, so older bodies keep the same parsed shape.
100
+ const doorbell = { ...fields, type, runHookPayload };
101
+ if (shape !== undefined) {
102
+ doorbell.shape = shape;
103
+ }
104
+ if (recreateOnOutdatedImage === true) {
105
+ doorbell.recreateOnOutdatedImage = true;
106
+ }
107
+ return { ok: true, doorbell };
107
108
  }
@@ -129,9 +129,8 @@ function compareImageVersions(a, b) {
129
129
  return 0;
130
130
  }
131
131
  /**
132
- * Whether the SUSPENDED VM must be thrown away and recreated because a newer
133
- * image has been published since it booted — a resume would otherwise keep
134
- * that VM on its old baked-in hooks and repo checkout for up to 8 hours.
132
+ * Whether an opted-in doorbell should throw away and recreate the SUSPENDED VM
133
+ * because a newer image has been published since it booted.
135
134
  *
136
135
  * FAIL-SAFE, one direction only: recreating destroys the VM's filesystem
137
136
  * (only the credentials and `opencode.db` in the object store survive), so
@@ -287,11 +286,12 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
287
286
  imageVersion: described.imageVersion,
288
287
  });
289
288
  case 'SUSPENDED':
290
- // A newer image has been published since this VM booted: resuming would
291
- // keep it on the old baked-in hooks and repo checkout for up to 8 h, so
292
- // take the same recreate path the arm below takes — which, passing no
293
- // `imageVersion`, boots the latest (handler.ts's `run`).
294
- if (await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm)) {
289
+ // False/absent disables silent automatic roll-forward; an explicit restart
290
+ // request (#1906) can still set the same wire flag. Recreate loses VM-local
291
+ // filesystem state; durable provider credentials survive in object storage,
292
+ // but a manual paste/upload not yet synced there may be lost (#2071).
293
+ if (doorbell.recreateOnOutdatedImage === true &&
294
+ (await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm))) {
295
295
  return runMicrovm(doorbell, shape, microvm, timing, polled, REASON.imageVersionOutdated, state);
296
296
  }
297
297
  return resumeMicrovm(doorbell, doorbell.microvmId, shape, microvm, timing, polled, described.startedAt, described.imageVersion);
@@ -14,6 +14,12 @@ export interface StageMicrovmImageContextOptions {
14
14
  readonly originUrl: string;
15
15
  /** Directory to write the build context to. Removed and recreated. */
16
16
  readonly destination: string;
17
+ /**
18
+ * Overlay scripts the image build runs as root and as `runner`. The overlay
19
+ * ships inside the shared snapshot, so it may install software but must carry
20
+ * no credential. When omitted, deterministic no-op scripts are written.
21
+ */
22
+ readonly overlayDir?: string;
17
23
  /**
18
24
  * The published template to copy from. Defaults to the one inside this
19
25
  * package; named directly by the tests, which assert against a template they
@@ -23,11 +29,14 @@ export interface StageMicrovmImageContextOptions {
23
29
  }
24
30
  /**
25
31
  * Writes the build context AWS unpacks — the Dockerfile, the hook server, the
26
- * per-phase hook scripts and the repository — and returns its path. The result
27
- * is what `EvidentMicrovmConstruct`'s `imageSource` takes.
32
+ * per-phase hook scripts, the deployment overlay and the repository — and
33
+ * returns its path. The result is what `EvidentMicrovmConstruct`'s `imageSource`
34
+ * takes.
28
35
  *
29
- * The first three come from this package's published `dist/`, so a consumer
30
- * needs no checkout of `sroze/evident` and no copy of the Dockerfile or hook
31
- * scripts (#1528). Only `repositoryPath` is theirs to supply.
36
+ * The Dockerfile, hook server and phase hooks come from this package's
37
+ * published `dist/`, so a consumer needs no checkout of the source repository and no
38
+ * copy of those files (#1528). A caller may supply an overlay; the default is
39
+ * deterministic no-op scripts. `repositoryPath` is the only required caller
40
+ * input.
32
41
  */
33
42
  export declare function stageMicrovmImageContext(options: StageMicrovmImageContextOptions): string;
@@ -106,17 +106,36 @@ function stageRepository(repositoryPath, destination, originUrl) {
106
106
  console.log(`[stage] ${repositoryPath} has no pnpm-lock.yaml — the image build will skip dependency installation`);
107
107
  }
108
108
  }
109
+ function stageScriptDirectory(source, destination) {
110
+ (0, node_fs_1.mkdirSync)(destination);
111
+ for (const entry of (0, node_fs_1.readdirSync)(source, { withFileTypes: true })) {
112
+ const sourcePath = path.join(source, entry.name);
113
+ const staged = path.join(destination, entry.name);
114
+ if (entry.isDirectory()) {
115
+ stageScriptDirectory(sourcePath, staged);
116
+ continue;
117
+ }
118
+ (0, node_fs_1.copyFileSync)(sourcePath, staged);
119
+ // Set modes here rather than inheriting them from the template, so they stay
120
+ // correct after an npm pack, zip, CI-cache or hand-copy round trip. The image
121
+ // executes extensionless scripts and sources `.sh` files.
122
+ (0, node_fs_1.chmodSync)(staged, entry.name.endsWith('.sh') ? 0o644 : 0o755);
123
+ }
124
+ }
109
125
  /**
110
126
  * Writes the build context AWS unpacks — the Dockerfile, the hook server, the
111
- * per-phase hook scripts and the repository — and returns its path. The result
112
- * is what `EvidentMicrovmConstruct`'s `imageSource` takes.
127
+ * per-phase hook scripts, the deployment overlay and the repository — and
128
+ * returns its path. The result is what `EvidentMicrovmConstruct`'s `imageSource`
129
+ * takes.
113
130
  *
114
- * The first three come from this package's published `dist/`, so a consumer
115
- * needs no checkout of `sroze/evident` and no copy of the Dockerfile or hook
116
- * scripts (#1528). Only `repositoryPath` is theirs to supply.
131
+ * The Dockerfile, hook server and phase hooks come from this package's
132
+ * published `dist/`, so a consumer needs no checkout of the source repository and no
133
+ * copy of those files (#1528). A caller may supply an overlay; the default is
134
+ * deterministic no-op scripts. `repositoryPath` is the only required caller
135
+ * input.
117
136
  */
118
137
  function stageMicrovmImageContext(options) {
119
- const { originUrl, templateDir = TEMPLATE_DIR } = options;
138
+ const { originUrl, overlayDir, templateDir = TEMPLATE_DIR } = options;
120
139
  // `file://` and the clone below only mean anything against absolute paths,
121
140
  // and a caller may reasonably pass either.
122
141
  const repositoryPath = path.resolve(options.repositoryPath);
@@ -132,16 +151,27 @@ function stageMicrovmImageContext(options) {
132
151
  (0, node_fs_1.copyFileSync)(path.join(templateDir, 'hook-server.js'), path.join(destination, 'hook-server.js'));
133
152
  const hooksSource = path.join(templateDir, 'hooks');
134
153
  const hooksStage = path.join(destination, 'hooks');
135
- (0, node_fs_1.mkdirSync)(hooksStage);
136
- for (const entry of (0, node_fs_1.readdirSync)(hooksSource)) {
137
- const staged = path.join(hooksStage, entry);
138
- (0, node_fs_1.copyFileSync)(path.join(hooksSource, entry), staged);
139
- // The runtime only runs a hook it can execute. Set here rather than
140
- // inherited from the template, so the staged mode is a property of THIS
141
- // function rather than of however the template reached disk — `npm pack`
142
- // does carry the bit, but a zip-based vendoring, a CI cache restore or a
143
- // hand-copied directory need not.
144
- (0, node_fs_1.chmodSync)(staged, entry.endsWith('.sh') ? 0o644 : 0o755);
154
+ stageScriptDirectory(hooksSource, hooksStage);
155
+ const overlayStage = path.join(destination, 'overlay');
156
+ if (overlayDir === undefined) {
157
+ (0, node_fs_1.mkdirSync)(overlayStage);
158
+ for (const name of ['setup-root', 'setup-workspace']) {
159
+ const staged = path.join(overlayStage, name);
160
+ (0, node_fs_1.writeFileSync)(staged, '#!/usr/bin/env bash\n# No overlay supplied.\n', { mode: 0o755 });
161
+ (0, node_fs_1.chmodSync)(staged, 0o755);
162
+ }
163
+ }
164
+ else {
165
+ const resolvedOverlayDir = path.resolve(overlayDir);
166
+ if (!(0, node_fs_1.existsSync)(resolvedOverlayDir)) {
167
+ throw new Error(`overlay directory does not exist: ${resolvedOverlayDir}`);
168
+ }
169
+ for (const required of ['setup-root', 'setup-workspace']) {
170
+ if (!(0, node_fs_1.existsSync)(path.join(resolvedOverlayDir, required))) {
171
+ throw new Error(`overlay directory ${resolvedOverlayDir} is missing required script ${required}`);
172
+ }
173
+ }
174
+ stageScriptDirectory(resolvedOverlayDir, overlayStage);
145
175
  }
146
176
  stageRepository(repositoryPath, path.join(destination, 'repo'), originUrl);
147
177
  return destination;
@@ -1,7 +1,9 @@
1
- # Evident per-session MicroVM image (#558, epic #556)
1
+ # Generic MicroVM runner image
2
2
  #
3
- # Same installed footprint as the ECS runner image, built for linux/arm64
4
- # (Lambda MicroVMs run on Graviton only) and with ONE process: the hook server.
3
+ # Built for linux/arm64 (Lambda MicroVMs run on Graviton only) and with ONE
4
+ # process: the hook server. `overlay/setup-root` and
5
+ # `overlay/setup-workspace` are the extension point for deployment-specific
6
+ # packages and build warm-up.
5
7
  #
6
8
  # THE BOOT SPLIT — the rule that governs everything below. AWS snapshots this
7
9
  # image ONCE at build and every MicroVM resumes from that same snapshot, so
@@ -9,7 +11,8 @@
9
11
  # this image version. Therefore this image must NOT start the tunnel
10
12
  # (`evident run`), clone the repo, or carry any credential, machine ID or
11
13
  # generated secret. Identity arrives per-VM in the `/run` hook payload.
12
- # `src/image/dockerfile.test.ts` fails the build if this regresses.
14
+ # `aws/runner-cdk/src/microvm/image/dockerfile.test.ts` fails the build if this
15
+ # regresses.
13
16
  #
14
17
  # No `--platform` on FROM: AWS builds this natively on Graviton. Build it
15
18
  # locally with `docker build --platform linux/arm64`.
@@ -27,9 +30,8 @@ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
27
30
 
28
31
  # System tooling + GitHub CLI + AWS CLI, in a single layer.
29
32
  # - awscli (v2): required — durable-state persistence uses `aws s3`.
30
- # - procps, postgresql(+contrib), direnv: required at RUNTIME so the VM is a
31
- # full dev box for this repo (local Postgres on 5433 as the non-root runner,
32
- # no sudo). Listed explicitly so apt keeps them.
33
+ # - procps: required at RUNTIME for process inspection and debugging. Listed
34
+ # explicitly so apt keeps it.
33
35
  # - gnupg, unzip: BUILD-ONLY (gh apt key / AWS CLI bundle); purged in this layer.
34
36
  #
35
37
  # Purge build-only packages BY NAME — do NOT use `apt-get autoremove`, which
@@ -38,7 +40,6 @@ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
38
40
  RUN apt-get update \
39
41
  && apt-get install -y --no-install-recommends \
40
42
  ca-certificates curl git jq less procps psmisc ripgrep \
41
- postgresql postgresql-contrib direnv \
42
43
  gnupg unzip \
43
44
  # --- GitHub CLI (`gh`) from the official apt repo ---
44
45
  && mkdir -p -m 755 /etc/apt/keyrings \
@@ -54,10 +55,6 @@ RUN apt-get update \
54
55
  && unzip -q /tmp/awscliv2.zip -d /tmp \
55
56
  && /tmp/aws/install \
56
57
  && aws --version \
57
- # --- Put the PostgreSQL server binaries on PATH ---
58
- # On Debian initdb/pg_ctl/postgres/psql live under /usr/lib/postgresql/
59
- # <ver>/bin, not on PATH; symlink them into /usr/local/bin.
60
- && ln -s /usr/lib/postgresql/*/bin/* /usr/local/bin/ 2>/dev/null || true \
61
58
  # --- Drop the build-only packages (BY NAME, no autoremove) + caches ---
62
59
  && apt-get purge -y gnupg unzip \
63
60
  && rm -rf /tmp/aws /tmp/awscliv2.zip /var/lib/apt/lists/*
@@ -87,7 +84,7 @@ RUN curl -fsSL -o /tmp/litestream.tar.gz \
87
84
  # floating `opencode-ai@latest` install was implicated in unbounded runner
88
85
  # memory growth / OOM kills. Keep it in lockstep with the ECS runner image's.
89
86
  # EVIDENT_CLI_VERSION is DELIBERATELY the floating `dev` tag, matching the ECS
90
- # runner image (infrastructure/evident-runner/src/base-image.ts). This is a
87
+ # runner image. This is a
91
88
  # development environment: it should track the head of the CLI so changes are
92
89
  # testable without a version bump in every PR that touches a hook.
93
90
  # RUNNER_SYNCHRONISER_VERSION floats on `dev` for the identical reason.
@@ -95,46 +92,19 @@ ARG OPENCODE_VERSION=1.18.3
95
92
  ARG EVIDENT_CLI_VERSION=dev
96
93
  ARG CLAUDE_CODE_VERSION=latest
97
94
  ARG RUNNER_SYNCHRONISER_VERSION=dev
98
- #
99
- # @brave/brave-search-mcp-server is pinned (not a build arg — a single caller,
100
- # opencode.evident.jsonc, needs one version) and baked here for the same reason
101
- # as the ECS runner image: a cold `npx -y` install of it took ~38s in a fresh
102
- # MicroVM, over OpenCode's ~30s MCP connect timeout, so the server silently
103
- # failed to connect on every first turn after a boot. Baking it lets
104
- # opencode.evident.jsonc invoke the installed `brave-search-mcp-server` binary
105
- # directly instead of through `npx`.
106
95
  RUN npm install -g \
107
96
  "opencode-ai@${OPENCODE_VERSION}" \
108
97
  "@evident-ai/cli@${EVIDENT_CLI_VERSION}" \
109
98
  "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
110
99
  "@evident-ai/runner-synchroniser@${RUNNER_SYNCHRONISER_VERSION}" \
111
- "@brave/brave-search-mcp-server@2.1.0" \
112
100
  && opencode --version \
113
101
  && evident --version \
114
- && brave-search-mcp-server --help >/dev/null \
115
102
  && npm cache clean --force
116
103
 
117
- # Playwright chromium + OS deps for E2E, baked at build time to avoid a slow
118
- # first-boot download.
119
- #
120
- # CRITICAL: this version MUST match the Playwright version the repo resolves for
121
- # `@playwright/test` (pnpm-lock.yaml → 1.58.0). The browser build is coupled to
122
- # the package version; if it drifts, a boot-time `playwright install`
123
- # re-downloads a different chromium. Bump in lockstep with @playwright/test.
124
- #
125
- # Browsers go to a SHARED PLAYWRIGHT_BROWSERS_PATH readable by the runtime
126
- # runner user (uid 10001), not root's ~/.cache.
127
- ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
128
- RUN npx playwright@1.58.0 install --with-deps chromium \
129
- && chown -R 10001:10001 /opt/ms-playwright \
130
- # Assert the browsers landed in the shared path (guard against a silent failure).
131
- && test -n "$(ls -A /opt/ms-playwright 2>/dev/null)" \
132
- || { echo "FATAL: Playwright browsers dir /opt/ms-playwright missing or empty after install" >&2; exit 1; }
133
-
134
104
  # Assert every REQUIRED runtime binary survived every layer above (fail loud).
135
105
  # Placed after the last install so it covers the apt purge, the litestream
136
106
  # extraction and the global npm installs in one pass.
137
- RUN for bin in ps pgrep pkill curl aws gh git jq rg psql pg_ctl initdb direnv litestream runner-synchroniser opencode evident timeout; do \
107
+ RUN for bin in ps pgrep pkill curl aws gh git jq rg litestream runner-synchroniser opencode evident timeout; do \
138
108
  command -v "$bin" >/dev/null 2>&1 || { echo "FATAL: required binary '$bin' missing from image" >&2; exit 1; }; \
139
109
  done \
140
110
  && curl -fsS -o /dev/null https://cli.github.com \
@@ -172,8 +142,6 @@ COPY hooks /etc/evident/hooks
172
142
  # per-VM, so the agent is what brings this checkout up to date.
173
143
  ENV WORKSPACE=/workspace
174
144
  ENV HOME=/home/runner
175
- # Runner-owned PGDATA parent for the local dev Postgres cluster (initdb +
176
- # pg_ctl at boot, no sudo).
177
145
  #
178
146
  # The machine-id files are created EMPTY and runner-owned so that /run can
179
147
  # actually rewrite them: the hooks run as uid 10001, and neither `/etc` nor
@@ -181,10 +149,18 @@ ENV HOME=/home/runner
181
149
  # permanent no-op and every VM from this snapshot shares one machine id. Empty
182
150
  # is the correct unset state — it is the absence of an identity, so nothing
183
151
  # per-VM-unique enters the shared snapshot.
184
- RUN mkdir -p /var/lib/runner-pg /var/lib/dbus /home/runner/.local/state/evident \
152
+ RUN mkdir -p /var/lib/dbus /home/runner/.local/state/evident \
185
153
  && install -o runner -g runner -m 0644 /dev/null /etc/machine-id \
186
154
  && install -o runner -g runner -m 0644 /dev/null /var/lib/dbus/machine-id \
187
- && chown -R runner:runner /var/lib/runner-pg /home/runner
155
+ && chown -R runner:runner /home/runner
156
+
157
+ # Deployment additions are always present because staging writes no-op defaults
158
+ # when no overlay is supplied.
159
+ COPY overlay /etc/evident/overlay
160
+ # HOME is already /home/runner, so root-warmed npx/npm state must be runner-owned
161
+ # or the runner's first npm/pnpm call would fail with EACCES.
162
+ RUN /etc/evident/overlay/setup-root \
163
+ && chown -R runner:runner /home/runner
188
164
  COPY --chown=10001:10001 repo ${WORKSPACE}
189
165
  WORKDIR ${WORKSPACE}
190
166
  USER runner
@@ -195,8 +171,8 @@ USER runner
195
171
  # `runner` (not root) so the rebuilt index is runner-owned and writable later.
196
172
  RUN git reset --quiet
197
173
 
198
- # Dependencies and the shared builds everything else depends on. As the runner
199
- # user, so node_modules is writable by the agent's own later installs.
174
+ # Dependencies for the baked workspace. Install as the runner user so
175
+ # node_modules is writable by the agent's own later installs.
200
176
  #
201
177
  # ONE layer on purpose: pnpm hard-links node_modules into its content-addressed
202
178
  # store (which it puts at ${WORKSPACE}/.pnpm-store, gitignored), and hard links
@@ -209,22 +185,18 @@ RUN git reset --quiet
209
185
  # at a repository that is not a pnpm workspace at all: it is baked uninstalled
210
186
  # instead of failing the build, and the agent installs on first use.
211
187
  #
212
- # Nothing environment-specific is built: `vite build` inlines VITE_* at build
213
- # time and the migrations need a running Postgres, so the app builds and the dev
214
- # database stay per-VM (see entrypoint.sh's pre-warm in evident-runner).
188
+ # Anything beyond dependency installation is deployment-specific and belongs in
189
+ # `overlay/setup-workspace`.
215
190
  RUN if [ -f pnpm-lock.yaml ]; then \
216
- pnpm install --frozen-lockfile \
217
- && pnpm run build --filter='./packages/*' \
218
- --filter='./aws/runner-cdk' \
219
- --filter='./aws/lambda-microvm-cdk' \
220
- --filter='./aws/lambda-microvm-runtime'; \
191
+ pnpm install --frozen-lockfile; \
221
192
  else \
222
- echo "[workspace-prep] no pnpm-lock.yaml in the baked repository — skipping the dependency install and the workspace build; the agent installs on first use."; \
193
+ echo "[workspace-prep] no pnpm-lock.yaml in the baked repository — skipping the dependency install; the agent installs on first use."; \
223
194
  fi
224
195
 
225
- # Data dir for the local dev Postgres cluster; listens on 5433 to match this
226
- # repo's .envrc DSN postgres://postgres:postgres@localhost:5433/evident.
227
- ENV PGDATA=/var/lib/runner-pg/data
196
+ # Runs as `runner` in `${WORKSPACE}`, after the install, so a project's build
197
+ # output is writable by the agent.
198
+ RUN /etc/evident/overlay/setup-workspace
199
+
228
200
  # Port AWS calls the MicroVM hooks on.
229
201
  ENV HOOKS_PORT=8080
230
202
  # OpenCode loopback port the CLI tunnels to (matches `evident run` default).
@@ -541,7 +541,7 @@ flush_session_db() {
541
541
  # --- flush_session_db (end) --------------------------------------------------
542
542
 
543
543
  # How long the guest CLI runs with no activity before it exits itself
544
- # (`evident run --idle-timeout`, apps/cli/src/commands/run.ts), which is what
544
+ # (`evident run --idle-timeout`), which is what
545
545
  # turns a truly-abandoned VM into the clean-offline POST that lets Evident
546
546
  # suspend it (#732). Sized from the measured cost of guessing wrong rather than
547
547
  # the saving: suspend reaches SUSPENDED in ~7 s and a resume is RUNNING in
@@ -551,8 +551,8 @@ flush_session_db() {
551
551
  # to a 16 GB / 8 vCPU peak (~$1.06/h at sustained full load—a worst-case
552
552
  # ceiling, not an expectation); that is cheap enough that the balance
553
553
  # sits far nearer the floor than the ceiling. Not AT the floor, though: the
554
- # CLI's idle detector needs 2 clear poll cycles (`run.ts`'s `idlePolls >= 2`,
555
- # ≥4 s of real time), so a value near that would spend more time
554
+ # CLI's idle detector needs 2 clear poll cycles (≥4 s of real time), so a value
555
+ # near that would spend more time
556
556
  # suspending/resuming than idle.
557
557
  # ECS's waker uses 900 s instead only because *its* cold start is far slower
558
558
  # than this VM's ~2 s resume — not evidence this default should match it.
@@ -647,14 +647,14 @@ start_tunnel() {
647
647
  }
648
648
 
649
649
  # The worst case `evident run` can take to shut down gracefully on SIGTERM, in
650
- # whole seconds: 25 s in-flight drain (SHUTDOWN_DRAIN_TIMEOUT_MS,
651
- # apps/cli/src/commands/run.ts) + 2 s offline POST (notifyAgentDisconnected,
652
- # apps/cli/src/commands/agent-lookup.ts) + 5 s telemetry flush
653
- # (TELEMETRY_SHUTDOWN_TIMEOUT_MS, run.ts) + 8.5 s pre-drain credential flush
650
+ # whole seconds: 25 s in-flight drain (SHUTDOWN_DRAIN_TIMEOUT_MS) + 2 s offline
651
+ # POST (notifyAgentDisconnected) + 5 s telemetry flush
652
+ # (TELEMETRY_SHUTDOWN_TIMEOUT_MS) + 8.5 s pre-drain credential flush
654
653
  # + 8.5 s post-drain credential flush. Each phase is bounded there, so this is
655
654
  # a ceiling rather than a typical cost — an idle suspend finishes in a couple
656
655
  # of seconds. This is the ONE place the hand-maintained budget is written down;
657
- # the CLI points back here when its bounds change.
656
+ # a guard in the source repository keeps this number in step with the CLI's
657
+ # declared bounds.
658
658
  #
659
659
  # DOCUMENTATION ONLY — nothing is derived from this value. It is the hand-maintained
660
660
  # sum of the five bounded phases above (25 + 2 + 5 + 8.5 + 8.5); update it here if
@@ -820,7 +820,7 @@ stop_runner_and_flush_credentials() {
820
820
  # Answers exactly one question before opencode/the tunnel start spending this
821
821
  # boot's SIGTERM budget on a key that cannot work: "does the runner key in this
822
822
  # payload authenticate against Evident?" Delegates entirely to the CLI's own
823
- # `evident status --json` (apps/cli/src/commands/status.ts) rather than
823
+ # `evident status --json` rather than
824
824
  # reimplementing its auth logic here — that command's `reason` field is the
825
825
  # published contract this function reads, and its own header states the
826
826
  # absent-vs-contrary distinction this function must honour.
@@ -73,9 +73,8 @@ load_state_config || warn "could not resolve durable-state config; the session D
73
73
  # list, so a later edit cannot accidentally foreground the check again.
74
74
  ( check_runner_key "${runner_key}" "${api_url}" || true ) &
75
75
 
76
- # Not waited on: the CLI backs off and keeps retrying its own dial with no
77
- # attempt cap (apps/cli/src/lib/tunnel/connection.ts), and a control-plane
78
- # reclaim pass (the api-worker lifecycle cron) is what decides whether a
76
+ # Not waited on: the Evident CLI backs off and keeps retrying its own dial with no
77
+ # attempt cap, and Evident's control-plane reclaim pass is what decides whether a
79
78
  # runner that never reconnects gets suspended.
80
79
  start_tunnel "${runner_key}" "${api_url}" "${tunnel_url}"
81
80
 
@@ -87,9 +87,8 @@ check_runner_key "${runner_key}" "${api_url}" || exit 1
87
87
  >"${CONTEXT_FILE}"
88
88
  )
89
89
 
90
- # Not waited on: the CLI backs off and keeps retrying its own dial with no
91
- # attempt cap (apps/cli/src/lib/tunnel/connection.ts), and a control-plane
92
- # reclaim pass (the api-worker lifecycle cron) is what decides whether a
90
+ # Not waited on: the Evident CLI backs off and keeps retrying its own dial with no
91
+ # attempt cap, and Evident's control-plane reclaim pass is what decides whether a
93
92
  # runner that never connects gets suspended.
94
93
  start_tunnel "${runner_key}" "${api_url}" "${tunnel_url}" true true
95
94
 
@@ -60,8 +60,7 @@ class EvidentWaker extends constructs_1.Construct {
60
60
  // dist/waker-lambda/handler.js, so neither this package's published npm
61
61
  // artifact (which ships dist/ already built) nor a consuming app needs
62
62
  // that private workspace package, or esbuild, on synth's PATH. Requires
63
- // a build before synth when consuming this package via `workspace:*` —
64
- // see infrastructure/evident-runner's README "local gate".
63
+ // a build before synth when consuming this package via `workspace:*`.
65
64
  //
66
65
  // Resolved from the PACKAGE ROOT (../.. ), not `__dirname` directly:
67
66
  // `__dirname` is `dist/waker` at runtime (compiled) but `src/waker` when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evident-ai/runner-cdk",
3
- "version": "3.4.1-dev.f80aa1c",
3
+ "version": "3.4.1-dev.fb92ede",
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",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "pnpm run build-bundled-deps && ts-node scripts/build.ts",
18
- "//build-bundled-deps": "scripts/build.ts esbuild-bundles runner/docker-images/microvm/hook-server.ts, which imports @evident-ai/lambda-microvm-runtime by its built `main`. @evident-ai/lambda-microvm-cdk is a workspace:* devDependency whose types resolve through its own dist/, so tsc --project tsconfig.build.json cannot compile src/microvm/{shapes,construct}.ts without it. Chained into `build` rather than left to turbo's `^build` because publish-runner-cdk.yaml and infrastructure/evident-runner's build-runner-cdk-dep invoke `pnpm --filter @evident-ai/runner-cdk build` directly, which bypasses turbo entirely.",
18
+ "//build-bundled-deps": "scripts/build.ts esbuild-bundles runner/docker-images/microvm/hook-server.ts, which imports @evident-ai/lambda-microvm-runtime by its built `main`. @evident-ai/lambda-microvm-cdk is a workspace:* devDependency whose types resolve through its own dist/, so tsc --project tsconfig.build.json cannot compile src/microvm/{shapes,construct}.ts without it. Chained into `build` rather than left to turbo's `^build` because callers outside turbo invoke `pnpm --filter @evident-ai/runner-cdk build` directly, which bypasses turbo entirely.",
19
19
  "build-bundled-deps": "pnpm --filter @evident-ai/lambda-microvm-cdk build && pnpm --filter @evident-ai/lambda-microvm-runtime build",
20
20
  "typecheck": "tsc --noEmit",
21
21
  "test": "node --test --require ts-node/register 'src/**/*.test.ts'",