@evident-ai/runner-cdk 0.1.1-dev.d96ef8a → 0.1.1-dev.da88f8e

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
@@ -9,9 +9,9 @@ Reusable AWS CDK constructs for running an [Evident](https://evident.run) agent
9
9
  `desiredCount 0`, and an HMAC-authenticated waker Lambda scales it back up on the next
10
10
  message. You pay for the time the agent is actually working.
11
11
  - **`EvidentMicrovmConstruct`** — a per-session AWS Lambda MicroVM that boots on demand
12
- and suspends between messages. It is not yet at Fargate's install-only parity here:
13
- building its runner image still means cloning the `sroze/evident` repo (tracked in
14
- [#1528](https://github.com/sroze/evident/issues/1528)). See
12
+ and suspends between messages. Installing this package is all you need: the image build
13
+ context ships inside it (see [The MicroVM image](#the-microvm-image) below), so there is
14
+ no checkout of `sroze/evident` 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
 
@@ -113,6 +113,49 @@ This construct never builds an image. Pass any `ecs.ContainerImage` — from a r
113
113
  built from a `Dockerfile` you control. The generic runner image lives separately in
114
114
  `packages/runner-image`, so you can adopt the image, the construct, or both.
115
115
 
116
+ ## The MicroVM image
117
+
118
+ `EvidentMicrovmConstruct`'s `imageSource` is a **local directory** holding the image build
119
+ context, which `cdk deploy` zips and uploads. The Lambda MicroVM service then builds the
120
+ image in *your* account, from a base image ARN you discover there — so, unlike the Fargate
121
+ strategy, there is no registry image to pull.
122
+
123
+ The context has two halves, and this package ships the half that is ours: the Dockerfile,
124
+ the per-phase hook scripts and the bundled hook server, published inside the tarball at
125
+ `dist/microvm-image-context/`. The other half is **your** repository, which is baked in as
126
+ the agent's workspace. `stageMicrovmImageContext()` puts the two together:
127
+
128
+ ```ts
129
+ import {
130
+ EvidentMicrovmConstruct,
131
+ HOOKS_PORT,
132
+ HOOK_TIMEOUT_SECONDS,
133
+ stageMicrovmImageContext,
134
+ } from '@evident-ai/runner-cdk';
135
+
136
+ new EvidentMicrovmConstruct(this, 'Runner', {
137
+ imageSource: stageMicrovmImageContext({
138
+ // A full (non-shallow) clone: the agent branches, commits and opens PRs.
139
+ repositoryPath: '/path/to/your/checkout',
140
+ // Credential-free — this string ships inside the shared snapshot. The
141
+ // token the agent pushes with arrives per-session, never baked in.
142
+ originUrl: 'https://github.com/acme/widgets.git',
143
+ destination: path.join(__dirname, '..', 'build', 'image'),
144
+ }),
145
+ // Must match the published image these were baked into, so export them
146
+ // rather than restating the numbers.
147
+ hooksPort: HOOKS_PORT,
148
+ hookTimeoutSeconds: HOOK_TIMEOUT_SECONDS,
149
+ baseImageArn,
150
+ baseImageVersion,
151
+ doorbellSecret,
152
+ });
153
+ ```
154
+
155
+ Your repo does not have to be a pnpm workspace. If it commits a `pnpm-lock.yaml` the image
156
+ pre-installs dependencies at build time (a faster first boot); if not, that step is skipped
157
+ and the agent installs on first use.
158
+
116
159
  ## Status / limitations
117
160
 
118
161
  - **Published to npm** as `@evident-ai/runner-cdk` (MIT) — see
@@ -50464,7 +50464,7 @@ function createRuntimeMicrovm(executionRoleArn) {
50464
50464
  // Claude-agents guidance recommending `maxIdleDurationSeconds: 120`
50465
50465
  // targets VMs reached inbound and does not apply here. Idle is instead
50466
50466
  // decided IN the VM by `evident run --idle-timeout`
50467
- // (docker/hooks/common.sh's `IDLE_TIMEOUT_SECONDS`), whose
50467
+ // (packages/runner-cdk/microvm-image/hooks/common.sh's `IDLE_TIMEOUT_SECONDS`), whose
50468
50468
  // clean exit is what drives the controller's own `suspend()` below via
50469
50469
  // the `runner.suspend_requested` doorbell. See `idle-policy.test.ts`
50470
50470
  // in this directory, which scans this file's source for the key and
package/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export { EvidentScaleToZeroConstruct, type EvidentScaleToZeroConstructProps, } f
2
2
  export { EvidentWaker, type EvidentWakerProps } from './waker/construct';
3
3
  export { EvidentMicrovmConstruct, type EvidentMicrovmConstructProps } from './microvm/construct';
4
4
  export { MICROVM_SHAPES, validateShapes, type MicrovmShape } from './microvm/shapes';
5
- export { MICROVM_MAX_RUN_SECONDS } from './microvm/constants';
5
+ export { MICROVM_MAX_RUN_SECONDS, HOOKS_PORT, HOOK_TIMEOUT_SECONDS, } from './microvm/constants';
6
+ export { stageMicrovmImageContext, type StageMicrovmImageContextOptions, } from './microvm/image/stage-context';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MICROVM_MAX_RUN_SECONDS = exports.validateShapes = exports.MICROVM_SHAPES = exports.EvidentMicrovmConstruct = exports.EvidentWaker = exports.EvidentScaleToZeroConstruct = void 0;
3
+ exports.stageMicrovmImageContext = exports.HOOK_TIMEOUT_SECONDS = exports.HOOKS_PORT = exports.MICROVM_MAX_RUN_SECONDS = exports.validateShapes = exports.MICROVM_SHAPES = exports.EvidentMicrovmConstruct = exports.EvidentWaker = exports.EvidentScaleToZeroConstruct = void 0;
4
4
  var evident_scale_to_zero_construct_1 = require("./evident-scale-to-zero-construct");
5
5
  Object.defineProperty(exports, "EvidentScaleToZeroConstruct", { enumerable: true, get: function () { return evident_scale_to_zero_construct_1.EvidentScaleToZeroConstruct; } });
6
6
  var construct_1 = require("./waker/construct");
@@ -12,3 +12,9 @@ Object.defineProperty(exports, "MICROVM_SHAPES", { enumerable: true, get: functi
12
12
  Object.defineProperty(exports, "validateShapes", { enumerable: true, get: function () { return shapes_1.validateShapes; } });
13
13
  var constants_1 = require("./microvm/constants");
14
14
  Object.defineProperty(exports, "MICROVM_MAX_RUN_SECONDS", { enumerable: true, get: function () { return constants_1.MICROVM_MAX_RUN_SECONDS; } });
15
+ // The two values a consumer MUST pass to `EvidentMicrovmConstruct` for them
16
+ // to match the published image they were baked into (#1528).
17
+ Object.defineProperty(exports, "HOOKS_PORT", { enumerable: true, get: function () { return constants_1.HOOKS_PORT; } });
18
+ Object.defineProperty(exports, "HOOK_TIMEOUT_SECONDS", { enumerable: true, get: function () { return constants_1.HOOK_TIMEOUT_SECONDS; } });
19
+ var stage_context_1 = require("./microvm/image/stage-context");
20
+ Object.defineProperty(exports, "stageMicrovmImageContext", { enumerable: true, get: function () { return stage_context_1.stageMicrovmImageContext; } });
@@ -1,3 +1,6 @@
1
+ export declare const HOOKS_PORT = 8080;
2
+ export declare const HOOKS_DIR = "/etc/evident/hooks";
3
+ export declare const HOOK_TIMEOUT_SECONDS = 60;
1
4
  export declare const RUN_HOOK_PAYLOAD_MAX_BYTES = 16384;
2
5
  export declare const SUSPENDING_POLL_ATTEMPTS = 4;
3
6
  export declare const SUSPENDING_POLL_INTERVAL_MS = 500;
@@ -1,14 +1,25 @@
1
1
  "use strict";
2
- // Shared literals for the MicroVM controller. Every controller module imports
3
- // from here rather than restating a value that must match across them.
4
- //
5
- // Split from `infrastructure/evident-microvm/src/constants.ts` (#1476): the
6
- // hook/image values (`HOOKS_DIR`, `HOOKS_PORT`, `HOOK_TIMEOUT_SECONDS`) stay
7
- // there because `image/hook-server.ts` — bundled into the Docker image by a
8
- // different pipeline — imports them too and cannot take an npm dependency on
9
- // this construct package.
2
+ // Shared literals for the MicroVM controller and for the image it launches.
3
+ // Every module imports from here rather than restating a value that must match
4
+ // across them — including `microvm/image/hook-server.ts`, which runs inside the
5
+ // image and is esbuild-bundled into the published build context by
6
+ // `scripts/build.ts`, so these values reach the image as inlined constants.
10
7
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.MICROVM_MAX_RUN_SECONDS = exports.SUSPENDING_POLL_INTERVAL_MS = exports.SUSPENDING_POLL_ATTEMPTS = exports.RUN_HOOK_PAYLOAD_MAX_BYTES = void 0;
8
+ exports.MICROVM_MAX_RUN_SECONDS = exports.SUSPENDING_POLL_INTERVAL_MS = exports.SUSPENDING_POLL_ATTEMPTS = exports.RUN_HOOK_PAYLOAD_MAX_BYTES = exports.HOOK_TIMEOUT_SECONDS = exports.HOOKS_DIR = exports.HOOKS_PORT = void 0;
9
+ // The port the image's hook server binds, baked into the published Dockerfile
10
+ // (`ENV HOOKS_PORT`) and passed to the construct as `hooksPort`. Exported from
11
+ // the package index because a consumer building the published image context
12
+ // MUST pass the value it was baked with — the construct's own docstring warns
13
+ // that a mismatch means AWS probes a port nothing binds.
14
+ exports.HOOKS_PORT = 8080;
15
+ // One executable per lifecycle phase. A phase with no script is a no-op, which
16
+ // is why the image ships none for `ready` or `validate`: nothing Evident-specific
17
+ // may run before the snapshot, so there is nothing for them to do.
18
+ exports.HOOKS_DIR = '/etc/evident/hooks';
19
+ // Seconds AWS allows a Run/Resume/Suspend/Terminate hook, passed to the
20
+ // construct as `hookTimeoutSeconds`. Exported for the same reason as
21
+ // `HOOKS_PORT`: the hooks are built against it.
22
+ exports.HOOK_TIMEOUT_SECONDS = 60;
12
23
  // AWS caps `runHookPayload` at 16 KiB; anything larger is rejected at its API,
13
24
  // so the controller checks it before spending a call.
14
25
  exports.RUN_HOOK_PAYLOAD_MAX_BYTES = 16384;
@@ -81,7 +81,9 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
81
81
  // ANDed with the age condition and counts newer noncurrent versions of
82
82
  // the SAME key, but litestream's LTX keys are txid-unique and churn by
83
83
  // DELETE, so a churned key has exactly one noncurrent version and zero
84
- // newer ones.
84
+ // newer ones. `expiredObjectDeleteMarker` reaps the delete marker itself once
85
+ // its noncurrent version is gone — without it, the version index grows
86
+ // unboundedly, one marker per churned LTX key.
85
87
  const durableState = new s3.Bucket(this, 'DurableState', {
86
88
  encryption: s3.BucketEncryption.S3_MANAGED,
87
89
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
@@ -98,6 +100,7 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
98
100
  {
99
101
  id: 'ExpireNoncurrentVersions',
100
102
  noncurrentVersionExpiration: cdk.Duration.days(7),
103
+ expiredObjectDeleteMarker: true,
101
104
  },
102
105
  ],
103
106
  });
@@ -49,7 +49,7 @@ export interface MicrovmClient {
49
49
  * (docs/spikes/lambda-microvms-phase0/README.md). AWS's own Claude-agents
50
50
  * guidance recommending `maxIdleDurationSeconds: 120` targets VMs reached
51
51
  * inbound and does not apply here. Idle is instead decided IN the VM by
52
- * `evident run --idle-timeout` (docker/hooks/common.sh's
52
+ * `evident run --idle-timeout` (packages/runner-cdk/microvm-image/hooks/common.sh's
53
53
  * `IDLE_TIMEOUT_SECONDS`), whose clean exit drives this
54
54
  * client's own `suspend()` via the `runner.suspend_requested` doorbell.
55
55
  */
@@ -0,0 +1,33 @@
1
+ export interface StageMicrovmImageContextOptions {
2
+ /**
3
+ * The repository baked into the image as the agent's workspace. This is the
4
+ * CONSUMER's repository, not Evident's — the image is a dev box for whatever
5
+ * codebase the agent works on. Must be a full (non-shallow) clone.
6
+ */
7
+ readonly repositoryPath: string;
8
+ /**
9
+ * `origin` of the baked repository, as the VM will see it. Must be a
10
+ * credential-free URL (typically `https://github.com/<owner>/<repo>.git`):
11
+ * this string ships inside the shared snapshot, so a token in it would be
12
+ * baked into every VM. The credential arrives per-VM instead.
13
+ */
14
+ readonly originUrl: string;
15
+ /** Directory to write the build context to. Removed and recreated. */
16
+ readonly destination: string;
17
+ /**
18
+ * The published template to copy from. Defaults to the one inside this
19
+ * package; named directly by the tests, which assert against a template they
20
+ * built themselves rather than whichever `dist/` happens to be present.
21
+ */
22
+ readonly templateDir?: string;
23
+ }
24
+ /**
25
+ * 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.
28
+ *
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.
32
+ */
33
+ export declare function stageMicrovmImageContext(options: StageMicrovmImageContextOptions): string;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.stageMicrovmImageContext = stageMicrovmImageContext;
37
+ const node_child_process_1 = require("node:child_process");
38
+ const node_fs_1 = require("node:fs");
39
+ const path = __importStar(require("node:path"));
40
+ /**
41
+ * The image build context template published inside this package.
42
+ *
43
+ * Resolved from the PACKAGE ROOT, THREE directories above `__dirname` — either
44
+ * compiled (`dist/microvm/image`) or via ts-node (`src/microvm/image`) — the
45
+ * same way `microvm/construct.ts` resolves the controller Lambda asset, but one
46
+ * `..` deeper because this file sits a directory below it. It is
47
+ * written by `scripts/build.ts` at PACKAGE build time, not at stage time, so a
48
+ * consumer needs no esbuild and no checkout of this repo (#1528).
49
+ */
50
+ const TEMPLATE_DIR = path.resolve(__dirname, '..', '..', '..', 'dist', 'microvm-image-context');
51
+ function git(args, cwd) {
52
+ return (0, node_child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
53
+ }
54
+ /**
55
+ * `git rev-parse --is-shallow-repository` throws an opaque `execFileSync`
56
+ * error (e.g. "not a git repository") when `repositoryPath` isn't usable at
57
+ * all, so that failure is rethrown naming the path.
58
+ */
59
+ function isShallowRepository(repositoryPath) {
60
+ try {
61
+ return git(['rev-parse', '--is-shallow-repository'], repositoryPath) === 'true';
62
+ }
63
+ catch (err) {
64
+ throw new Error(`${repositoryPath} is not a usable git repository: ` +
65
+ `${err instanceof Error ? err.message : String(err)}`);
66
+ }
67
+ }
68
+ /**
69
+ * Clones the caller's repository into the build context, so the Dockerfile can
70
+ * ship the workspace already installed.
71
+ *
72
+ * `file://` rather than a plain path: a path triggers git's local-clone
73
+ * optimisation, which copies the whole object store — every branch and worktree
74
+ * ref on the staging machine (50 MB here). The file transport negotiates like a
75
+ * network fetch and packs only what the checked-out branch reaches (9 MB), with
76
+ * one branch and one remote-tracking ref. Ignored files, `node_modules` first
77
+ * among them, are not part of a clone at all.
78
+ */
79
+ function stageRepository(repositoryPath, destination, originUrl) {
80
+ if (isShallowRepository(repositoryPath)) {
81
+ throw new Error(`refusing to stage a shallow checkout (${repositoryPath}): the agent branches, ` +
82
+ 'commits and opens PRs, so the image needs real history. Run `git fetch --unshallow`.');
83
+ }
84
+ // Absolute on both sides: `file://` needs an absolute path to mean anything,
85
+ // and the clone's cwd is the destination's parent (the only directory both
86
+ // are guaranteed to resolve against).
87
+ git(['clone', '--quiet', '--single-branch', `file://${repositoryPath}`, destination], path.dirname(destination));
88
+ // The clone points `origin` at this machine's filesystem; the VM's origin is
89
+ // the caller's own remote over HTTPS, with no credential in it.
90
+ git(['remote', 'set-url', 'origin', originUrl], destination);
91
+ // The asset hash of the resulting image must be a pure function of the commit,
92
+ // or every deploy publishes a new `AWS::Lambda::MicrovmImage` version and wedges
93
+ // the stack at its per-image version ceiling. Two things break that purity:
94
+ // - `pack-objects`' delta search is multithreaded (`pack.threads` defaults to
95
+ // the CPU count) and its output varies run to run; `-f`/`-F`
96
+ // (`--no-reuse-delta`/`--no-reuse-object`) additionally stop it from reusing
97
+ // whatever packing the source checkout happened to have. All three together
98
+ // make the pack a pure function of the object set, independent of the source.
99
+ // - `.git/index` carries stat data (inode/mtime/size) and `.git/logs/**` carries
100
+ // wall-clock reflog timestamps; neither is byte-stable for a fixed commit.
101
+ git(['-c', 'pack.threads=1', 'repack', '-adfqF'], destination);
102
+ (0, node_fs_1.rmSync)(path.join(destination, '.git', 'index'), { force: true });
103
+ (0, node_fs_1.rmSync)(path.join(destination, '.git', 'logs'), { recursive: true, force: true });
104
+ console.log(`[stage] repo ${git(['rev-parse', 'HEAD'], destination)}`);
105
+ if (!(0, node_fs_1.existsSync)(path.join(destination, 'pnpm-lock.yaml'))) {
106
+ console.log(`[stage] ${repositoryPath} has no pnpm-lock.yaml — the image build will skip dependency installation`);
107
+ }
108
+ }
109
+ /**
110
+ * 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.
113
+ *
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.
117
+ */
118
+ function stageMicrovmImageContext(options) {
119
+ const { originUrl, templateDir = TEMPLATE_DIR } = options;
120
+ // `file://` and the clone below only mean anything against absolute paths,
121
+ // and a caller may reasonably pass either.
122
+ const repositoryPath = path.resolve(options.repositoryPath);
123
+ const destination = path.resolve(options.destination);
124
+ if (!(0, node_fs_1.existsSync)(templateDir)) {
125
+ throw new Error(`the MicroVM image context template is missing from ${templateDir}. It is written by ` +
126
+ "this package's build (`pnpm --filter @evident-ai/runner-cdk build`) and ships in the " +
127
+ 'published tarball, so an installed copy should always have it.');
128
+ }
129
+ (0, node_fs_1.rmSync)(destination, { recursive: true, force: true });
130
+ (0, node_fs_1.mkdirSync)(destination, { recursive: true });
131
+ (0, node_fs_1.copyFileSync)(path.join(templateDir, 'Dockerfile'), path.join(destination, 'Dockerfile'));
132
+ (0, node_fs_1.copyFileSync)(path.join(templateDir, 'hook-server.js'), path.join(destination, 'hook-server.js'));
133
+ const hooksSource = path.join(templateDir, 'hooks');
134
+ 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);
145
+ }
146
+ stageRepository(repositoryPath, path.join(destination, 'repo'), originUrl);
147
+ return destination;
148
+ }
@@ -0,0 +1,224 @@
1
+ # Evident per-session MicroVM image (#558, epic #556)
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.
5
+ #
6
+ # THE BOOT SPLIT — the rule that governs everything below. AWS snapshots this
7
+ # image ONCE at build and every MicroVM resumes from that same snapshot, so
8
+ # anything unique or secret baked in here is shared by every VM launched from
9
+ # this image version. Therefore this image must NOT start the tunnel
10
+ # (`evident run`), clone the repo, or carry any credential, machine ID or
11
+ # generated secret. Identity arrives per-VM in the `/run` hook payload.
12
+ # `src/image/dockerfile.test.ts` fails the build if this regresses.
13
+ #
14
+ # No `--platform` on FROM: AWS builds this natively on Graviton. Build it
15
+ # locally with `docker build --platform linux/arm64`.
16
+ #
17
+ # Node major tracks `.nvmrc` / the root `engines.node` (22) so the image runs
18
+ # the same runtime as CI — `evident-runner/src/node-version.test.ts` fails if
19
+ # they drift. The Debian codename is pinned explicitly (`bookworm`, what
20
+ # `node:22-slim` resolves to today) so a future default bump to a newer Debian
21
+ # can't silently change the apt package set this image asserts on below.
22
+ FROM node:22-bookworm-slim
23
+
24
+ ENV PNPM_HOME="/pnpm"
25
+ ENV PATH="$PNPM_HOME:$PATH"
26
+ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
27
+
28
+ # System tooling + GitHub CLI + AWS CLI, in a single layer.
29
+ # - 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
+ # - gnupg, unzip: BUILD-ONLY (gh apt key / AWS CLI bundle); purged in this layer.
34
+ #
35
+ # Purge build-only packages BY NAME — do NOT use `apt-get autoremove`, which
36
+ # once cascaded into shared libs and swept `procps` out of the runner image. The
37
+ # binary assertion further down fails the build if any required binary is gone.
38
+ RUN apt-get update \
39
+ && apt-get install -y --no-install-recommends \
40
+ ca-certificates curl git jq less procps ripgrep \
41
+ postgresql postgresql-contrib direnv \
42
+ gnupg unzip \
43
+ # --- GitHub CLI (`gh`) from the official apt repo ---
44
+ && mkdir -p -m 755 /etc/apt/keyrings \
45
+ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
46
+ -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
47
+ && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
48
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
49
+ > /etc/apt/sources.list.d/github-cli.list \
50
+ && apt-get update \
51
+ && apt-get install -y --no-install-recommends gh \
52
+ # --- AWS CLI v2 (aarch64 bundle; this image is arm64 only) ---
53
+ && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o /tmp/awscliv2.zip \
54
+ && unzip -q /tmp/awscliv2.zip -d /tmp \
55
+ && /tmp/aws/install \
56
+ && 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
+ # --- Drop the build-only packages (BY NAME, no autoremove) + caches ---
62
+ && apt-get purge -y gnupg unzip \
63
+ && rm -rf /tmp/aws /tmp/awscliv2.zip /var/lib/apt/lists/*
64
+
65
+ # Litestream — replicates the OpenCode SQLite session DB to S3 so history
66
+ # survives VM replacement. Pinned + checksum-verified: a wrong LITESTREAM_SHA256
67
+ # fails the build at `sha256sum -c`. The checksum is for the arm64 tarball and
68
+ # is NOT interchangeable with the ECS runner image's. Note the release tag keeps
69
+ # the `v` but the filename does not.
70
+ # `/run` restores opencode.db from S3 (#812 WI-2) — one bounded `litestream
71
+ # restore` attempt — then starts a backgrounded `litestream replicate` once
72
+ # opencode has opened the DB (WI-3); `/suspend`/`/resume`/`/terminate` flush,
73
+ # restart and stop it in turn (WI-4). Do not remove as dead code.
74
+ ARG LITESTREAM_VERSION=0.5.13
75
+ ARG LITESTREAM_SHA256=ef47997794ce8dd87a64b44622d556b3a693b135fd72e0cf47cc42ac2e979051
76
+ RUN curl -fsSL -o /tmp/litestream.tar.gz \
77
+ "https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-${LITESTREAM_VERSION}-linux-arm64.tar.gz" \
78
+ && echo "${LITESTREAM_SHA256} /tmp/litestream.tar.gz" | sha256sum -c - \
79
+ && tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz litestream \
80
+ && chmod +x /usr/local/bin/litestream \
81
+ && rm -f /tmp/litestream.tar.gz \
82
+ && litestream version
83
+
84
+ # The AI agent runtime, the Evident CLI and the Claude Code CLI, installed
85
+ # globally and pinned via build args so image versions are reproducible.
86
+ # OPENCODE_VERSION is a DELIBERATE PIN, not a floating tag — see #537: a
87
+ # floating `opencode-ai@latest` install was implicated in unbounded runner
88
+ # memory growth / OOM kills. Keep it in lockstep with the ECS runner image's.
89
+ # 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
91
+ # development environment: it should track the head of the CLI so changes are
92
+ # testable without a version bump in every PR that touches a hook.
93
+ # RUNNER_SYNCHRONISER_VERSION floats on `dev` for the identical reason.
94
+ ARG OPENCODE_VERSION=1.18.3
95
+ ARG EVIDENT_CLI_VERSION=dev
96
+ ARG CLAUDE_CODE_VERSION=latest
97
+ ARG RUNNER_SYNCHRONISER_VERSION=dev
98
+ RUN npm install -g \
99
+ "opencode-ai@${OPENCODE_VERSION}" \
100
+ "@evident-ai/cli@${EVIDENT_CLI_VERSION}" \
101
+ "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
102
+ "@evident-ai/runner-synchroniser@${RUNNER_SYNCHRONISER_VERSION}" \
103
+ && opencode --version \
104
+ && evident --version \
105
+ && npm cache clean --force
106
+
107
+ # Playwright chromium + OS deps for E2E, baked at build time to avoid a slow
108
+ # first-boot download.
109
+ #
110
+ # CRITICAL: this version MUST match the Playwright version the repo resolves for
111
+ # `@playwright/test` (pnpm-lock.yaml → 1.58.0). The browser build is coupled to
112
+ # the package version; if it drifts, a boot-time `playwright install`
113
+ # re-downloads a different chromium. Bump in lockstep with @playwright/test.
114
+ #
115
+ # Browsers go to a SHARED PLAYWRIGHT_BROWSERS_PATH readable by the runtime
116
+ # runner user (uid 10001), not root's ~/.cache.
117
+ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
118
+ RUN npx playwright@1.58.0 install --with-deps chromium \
119
+ && chown -R 10001:10001 /opt/ms-playwright \
120
+ # Assert the browsers landed in the shared path (guard against a silent failure).
121
+ && test -n "$(ls -A /opt/ms-playwright 2>/dev/null)" \
122
+ || { echo "FATAL: Playwright browsers dir /opt/ms-playwright missing or empty after install" >&2; exit 1; }
123
+
124
+ # Assert every REQUIRED runtime binary survived every layer above (fail loud).
125
+ # Placed after the last install so it covers the apt purge, the litestream
126
+ # extraction and the global npm installs in one pass.
127
+ 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 \
128
+ command -v "$bin" >/dev/null 2>&1 || { echo "FATAL: required binary '$bin' missing from image" >&2; exit 1; }; \
129
+ done \
130
+ && curl -fsS -o /dev/null https://cli.github.com \
131
+ || { echo "FATAL: curl TLS smoke test failed (broken TLS libs)" >&2; exit 1; }
132
+
133
+ # Non-root user that owns the workspace. uid/gid pinned to 10001 for stable file
134
+ # ownership (opencode.db, ~/.claude/.credentials.json) across rebuilds.
135
+ RUN groupadd --system --gid 10001 runner \
136
+ && useradd --system --uid 10001 --gid 10001 \
137
+ --create-home --home-dir /home/runner runner
138
+
139
+ # The hook server: the ONLY process this image starts, and it starts nothing
140
+ # itself until AWS calls a hook. It is an esbuild bundle of
141
+ # src/microvm/image/hook-server.ts, placed next to this Dockerfile by
142
+ # `stageMicrovmImageContext()` — which is why this file is not buildable
143
+ # straight from the source tree.
144
+ COPY hook-server.js /usr/local/lib/evident/hook-server.js
145
+
146
+ # One executable per lifecycle phase. There is deliberately no `ready` and no
147
+ # `validate` script: the snapshot is shared by every VM started from this image
148
+ # version, so nothing per-VM may run before /run — which leaves those two phases
149
+ # with nothing to do, and the runtime answers them 200 without running anything.
150
+ COPY hooks /etc/evident/hooks
151
+
152
+ # Workspace: the repository the agent works on, baked in. A resumed VM must be
153
+ # useful in milliseconds, and a clone + install costs minutes — so both happen
154
+ # here, and the image is deliberately large in exchange.
155
+ #
156
+ # This does not weaken the boot split: source, git history and node_modules are
157
+ # neither secret nor per-VM-unique, so sharing them across every VM from this
158
+ # snapshot is exactly right. No credential is involved either — the repo travels
159
+ # in the build context (cloned by `stageMicrovmImageContext()` from a local
160
+ # checkout), not through a `git clone` needing a token. `origin` is the plain
161
+ # HTTPS GitHub URL; the credential that makes it fetchable and pushable arrives
162
+ # per-VM, so the agent is what brings this checkout up to date.
163
+ ENV WORKSPACE=/workspace
164
+ ENV HOME=/home/runner
165
+ # Runner-owned PGDATA parent for the local dev Postgres cluster (initdb +
166
+ # pg_ctl at boot, no sudo).
167
+ #
168
+ # The machine-id files are created EMPTY and runner-owned so that /run can
169
+ # actually rewrite them: the hooks run as uid 10001, and neither `/etc` nor
170
+ # `/var/lib/dbus` is writable by it, so without this the regeneration is a
171
+ # permanent no-op and every VM from this snapshot shares one machine id. Empty
172
+ # is the correct unset state — it is the absence of an identity, so nothing
173
+ # per-VM-unique enters the shared snapshot.
174
+ RUN mkdir -p /var/lib/runner-pg /var/lib/dbus \
175
+ && install -o runner -g runner -m 0644 /dev/null /etc/machine-id \
176
+ && install -o runner -g runner -m 0644 /dev/null /var/lib/dbus/machine-id \
177
+ && chown -R runner:runner /var/lib/runner-pg /home/runner
178
+ COPY --chown=10001:10001 repo ${WORKSPACE}
179
+ WORKDIR ${WORKSPACE}
180
+ USER runner
181
+
182
+ # The staged context deliberately ships no `.git/index` (it is not byte-stable,
183
+ # so shipping it would re-roll the image version on every deploy) — without this
184
+ # reset the agent's workspace reports ~1400 files as staged deletions. Runs as
185
+ # `runner` (not root) so the rebuilt index is runner-owned and writable later.
186
+ RUN git reset --quiet
187
+
188
+ # Dependencies and the shared builds everything else depends on. As the runner
189
+ # user, so node_modules is writable by the agent's own later installs.
190
+ #
191
+ # ONE layer on purpose: pnpm hard-links node_modules into its content-addressed
192
+ # store (which it puts at ${WORKSPACE}/.pnpm-store, gitignored), and hard links
193
+ # only survive within a single layer — split in two and the image carries both
194
+ # copies. `--frozen-lockfile` because the committed lockfile is the whole reason
195
+ # this is bakeable at all: installing against anything else would bake a
196
+ # dependency set nobody committed.
197
+ #
198
+ # Guarded on a committed `pnpm-lock.yaml` so `microvm:repositoryPath` can point
199
+ # at a repository that is not a pnpm workspace at all: it is baked uninstalled
200
+ # instead of failing the build, and the agent installs on first use.
201
+ #
202
+ # Nothing environment-specific is built: `vite build` inlines VITE_* at build
203
+ # time and the migrations need a running Postgres, so the app builds and the dev
204
+ # database stay per-VM (see entrypoint.sh's pre-warm in evident-runner).
205
+ RUN if [ -f pnpm-lock.yaml ]; then \
206
+ pnpm install --frozen-lockfile \
207
+ && pnpm run build --filter='./packages/*'; \
208
+ else \
209
+ echo "[workspace-prep] no pnpm-lock.yaml in the baked repository — skipping the dependency install and the packages build; the agent installs on first use."; \
210
+ fi
211
+
212
+ # Data dir for the local dev Postgres cluster; listens on 5433 to match this
213
+ # repo's .envrc DSN postgres://postgres:postgres@localhost:5433/evident.
214
+ ENV PGDATA=/var/lib/runner-pg/data
215
+ # Port AWS calls the MicroVM hooks on.
216
+ ENV HOOKS_PORT=8080
217
+ # OpenCode loopback port the CLI tunnels to (matches `evident run` default).
218
+ ENV OPENCODE_PORT=4096
219
+
220
+ EXPOSE 8080
221
+
222
+ # No ENTRYPOINT shell: the hook server is started directly, and it must be the
223
+ # only thing running until AWS calls /run.
224
+ CMD ["node", "/usr/local/lib/evident/hook-server.js"]