@evident-ai/runner-cdk 0.1.1-dev.da70cd4 → 3.4.1-dev.0ef5061

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +78 -20
  2. package/dist/controller-lambda/handler.js +50529 -0
  3. package/dist/evident-scale-to-zero-construct.d.ts +16 -4
  4. package/dist/evident-scale-to-zero-construct.js +19 -14
  5. package/dist/image-version-reporter-lambda/handler.js +129 -0
  6. package/dist/index.d.ts +4 -0
  7. package/dist/index.js +14 -1
  8. package/dist/microvm/constants.d.ts +7 -0
  9. package/dist/microvm/constants.js +34 -0
  10. package/dist/microvm/construct.d.ts +105 -0
  11. package/dist/microvm/construct.js +283 -0
  12. package/dist/microvm/controller/doorbell.d.ts +73 -0
  13. package/dist/microvm/controller/doorbell.js +107 -0
  14. package/dist/microvm/controller/handle-doorbell.d.ts +27 -0
  15. package/dist/microvm/controller/handle-doorbell.js +483 -0
  16. package/dist/microvm/controller/microvm-client.d.ts +81 -0
  17. package/dist/microvm/controller/microvm-client.js +7 -0
  18. package/dist/microvm/controller/shape-catalogue.d.ts +64 -0
  19. package/dist/microvm/controller/shape-catalogue.js +108 -0
  20. package/dist/microvm/controller/throttle-retry.d.ts +11 -0
  21. package/dist/microvm/controller/throttle-retry.js +27 -0
  22. package/dist/microvm/image/stage-context.d.ts +33 -0
  23. package/dist/microvm/image/stage-context.js +148 -0
  24. package/dist/microvm/image-version-reporter/construct.d.ts +35 -0
  25. package/dist/microvm/image-version-reporter/construct.js +91 -0
  26. package/dist/microvm/image-version-reporter/handler.d.ts +26 -0
  27. package/dist/microvm/image-version-reporter/handler.js +104 -0
  28. package/dist/microvm/shapes.d.ts +72 -0
  29. package/dist/microvm/shapes.js +93 -0
  30. package/dist/microvm-image-context/Dockerfile +227 -0
  31. package/dist/microvm-image-context/hook-server.js +286 -0
  32. package/dist/microvm-image-context/hooks/common.sh +957 -0
  33. package/dist/microvm-image-context/hooks/resume +78 -0
  34. package/dist/microvm-image-context/hooks/run +94 -0
  35. package/dist/microvm-image-context/hooks/suspend +22 -0
  36. package/dist/microvm-image-context/hooks/terminate +37 -0
  37. package/dist/waker/construct.js +1 -1
  38. package/package.json +15 -7
@@ -0,0 +1,283 @@
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.EvidentMicrovmConstruct = void 0;
37
+ const path = __importStar(require("path"));
38
+ const cdk = __importStar(require("aws-cdk-lib"));
39
+ const constructs_1 = require("constructs");
40
+ const lambda = __importStar(require("aws-cdk-lib/aws-lambda"));
41
+ const s3 = __importStar(require("aws-cdk-lib/aws-s3"));
42
+ const lambda_microvm_cdk_1 = require("@evident-ai/lambda-microvm-cdk");
43
+ const shapes_1 = require("./shapes");
44
+ const construct_1 = require("./image-version-reporter/construct");
45
+ // Image hooks build the snapshot, so they get minutes where the runtime hooks
46
+ // get AWS's 60 s.
47
+ const IMAGE_HOOK_TIMEOUT_SECONDS = 600;
48
+ // The field inside the doorbell secret, shared by this construct (which
49
+ // names it for the controller) and whatever produced `props.doorbellSecret`.
50
+ const DOORBELL_SECRET_KEY = 'DOORBELL_SECRET';
51
+ /**
52
+ * The customer-account half of the per-session runner (#558): the MicroVM
53
+ * image and the roles it builds and runs as, plus the stateless controller
54
+ * Lambda that turns a signed doorbell into RunMicrovm / ResumeMicrovm /
55
+ * SuspendMicrovm.
56
+ */
57
+ class EvidentMicrovmConstruct extends constructs_1.Construct {
58
+ /** POST target for Evident's signed doorbell (HMAC-verified in the handler). */
59
+ functionUrl;
60
+ /** The shapes the controller can launch, with the image ARN each RunMicrovm uses. */
61
+ shapeCatalogueJson;
62
+ /** Bucket a runner restores model credentials from: seed `<state_prefix>/claude/credentials.json` here. */
63
+ durableStateBucket;
64
+ constructor(scope, id, props) {
65
+ super(scope, id);
66
+ // Synth-time failure, before any construct exists.
67
+ const shapes = (0, shapes_1.validateShapes)(props.shapes ?? shapes_1.MICROVM_SHAPES);
68
+ const [defaultShape, ...additionalShapes] = shapes;
69
+ // D8's "both required together" is a synth-time contract, not just a
70
+ // README sentence: a caller supplying exactly one silently gets NO
71
+ // reporter and no warning (the `&&` check below just skips it), which
72
+ // reads as "configured" when it is not. Fail loudly instead.
73
+ if (Boolean(props.evidentApiUrl) !== Boolean(props.evidentRunnerId)) {
74
+ throw new Error('EvidentMicrovmConstruct: evidentApiUrl and evidentRunnerId must be supplied ' +
75
+ 'together or not at all — supplying only one silently disables the image ' +
76
+ 'version reporter rather than reporting a misconfiguration.');
77
+ }
78
+ // The model credentials a runner restores at /run, and the litestream
79
+ // session replica (it is `LITESTREAM_BUCKET` below), share one per-runner
80
+ // prefix chosen at /run — synth can't name it, and lifecycle filters take
81
+ // a literal prefix with no wildcards, so any rule here is bucket-wide.
82
+ // RETAIN: deleting the stack must not cost hand-bootstrapped logins.
83
+ // Versioned, plus the 7-day noncurrent-version expiry below: a clobbered
84
+ // credential is fixed by the user re-authenticating their provider, not a
85
+ // version restore, so the versions carry no credential-recovery duty —
86
+ // the window instead covers the operator accident-net for a manually
87
+ // deleted corrupt replica (`--on-unusable-replica=leave` never self-heals
88
+ // one; see README). No current-object expiration, deliberately — that
89
+ // would destroy both outright. Count-based retention
90
+ // (`noncurrentVersionsToRetain`) would reap nothing here instead: it's
91
+ // ANDed with the age condition and counts newer noncurrent versions of
92
+ // the SAME key, but litestream's LTX keys are txid-unique and churn by
93
+ // DELETE, so a churned key has exactly one noncurrent version and zero
94
+ // newer ones. `expiredObjectDeleteMarker` reaps the delete marker itself once
95
+ // its noncurrent version is gone — without it, the version index grows
96
+ // unboundedly, one marker per churned LTX key.
97
+ const durableState = new s3.Bucket(this, 'DurableState', {
98
+ encryption: s3.BucketEncryption.S3_MANAGED,
99
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
100
+ versioned: true,
101
+ enforceSSL: true,
102
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
103
+ lifecycleRules: [
104
+ {
105
+ // Litestream uploads multipart; an abandoned upload is billed and is
106
+ // invisible to ListObjectsV2, so nothing else would ever reap it.
107
+ id: 'AbortIncompleteUploads',
108
+ abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
109
+ },
110
+ {
111
+ id: 'ExpireNoncurrentVersions',
112
+ noncurrentVersionExpiration: cdk.Duration.days(7),
113
+ expiredObjectDeleteMarker: true,
114
+ },
115
+ ],
116
+ });
117
+ // The environment shared by every shape's image...
118
+ const imageEnvironment = {
119
+ HOOKS_PORT: String(props.hooksPort),
120
+ // The bucket is identical for every VM from this image version, so it
121
+ // may be baked. The PREFIX inside it may NOT: it selects one runner's
122
+ // credential store, so baking it would point every VM at the same one.
123
+ // It arrives per-VM in the /run payload — `hook-scripts.test.ts` and
124
+ // this construct's test both hold that line.
125
+ LITESTREAM_BUCKET: durableState.bucketName,
126
+ // No AWS_REGION here: the service rejects it as a reserved key
127
+ // ("Environment variable key 'AWS_REGION' is reserved", 400), which is
128
+ // also why the synchroniser's S3 client does not need it set — the
129
+ // MicroVM runtime supplies the region itself.
130
+ // Read by `evident run` itself (not the hooks), the same 24h window ECS
131
+ // already bakes in for its own runners (evident-scale-to-zero-construct.ts:191).
132
+ // Safe to bake: identical for every VM from this image version.
133
+ EVIDENT_SESSION_CLEANUP_MAX_AGE: '24h',
134
+ ...(props.runnerOpencodeConfigPath
135
+ ? { RUNNER_OPENCODE_CONFIG: props.runnerOpencodeConfigPath }
136
+ : {}),
137
+ ...(props.runnerSecret
138
+ ? {
139
+ RUNNER_SECRET_ARN: props.runnerSecret.secretArn,
140
+ }
141
+ : {}),
142
+ ...(props.gitUserName ? { GIT_USER_NAME: props.gitUserName } : {}),
143
+ ...(props.gitUserEmail ? { GIT_USER_EMAIL: props.gitUserEmail } : {}),
144
+ };
145
+ // ...and the hooks configuration every shape's image is built with.
146
+ const imageHooks = {
147
+ port: props.hooksPort,
148
+ lifecycleTimeout: cdk.Duration.seconds(props.hookTimeoutSeconds),
149
+ buildTimeout: cdk.Duration.seconds(IMAGE_HOOK_TIMEOUT_SECONDS),
150
+ };
151
+ // Built first and with no `executionRole` prop, exactly as before this
152
+ // shape catalogue existed: same construct id (`Image`, frozen on the
153
+ // shape record — see shapes.ts), same `imageName`, same description. The
154
+ // construct therefore creates `Image/ExecutionRole` at its current path
155
+ // and current logical id — zero CloudFormation churn on the live,
156
+ // hand-deployed stack (D3/D9). Every additional shape below borrows this
157
+ // role rather than getting one of its own, which is why `validateShapes`
158
+ // requires the default shape to be `shapes[0]`: it must exist before any
159
+ // other shape can reference its execution role.
160
+ const defaultImage = new lambda_microvm_cdk_1.MicrovmImage(this, defaultShape.constructId, {
161
+ imageName: defaultShape.imageName,
162
+ description: defaultShape.description,
163
+ // A local directory, so the zip is uploaded by `cdk deploy` itself and the
164
+ // image ARN is a stack reference rather than a deploy-time input.
165
+ source: props.imageSource,
166
+ baseImageArn: props.baseImageArn,
167
+ baseImageVersion: props.baseImageVersion,
168
+ memoryMiB: defaultShape.memoryMiB,
169
+ environment: imageEnvironment,
170
+ hooks: imageHooks,
171
+ });
172
+ // WART, accepted deliberately: every additional shape's image is built
173
+ // with `defaultImage.executionRole`, so this role's construct path
174
+ // (`Image/ExecutionRole`) reads as if it belongs to just the default
175
+ // shape's image, when in fact it is shared by every shape. That is
176
+ // intentional — see D3 in the plan. Do NOT "fix" this with
177
+ // `overrideLogicalId`: renaming the logical id IS the CloudFormation
178
+ // churn this design exists to avoid (a delete-and-recreate of the role a
179
+ // running MicroVM has live session credentials from). The wart is one
180
+ // comment; the override would undo the point of D3.
181
+ const images = new Map([[defaultShape.name, defaultImage]]);
182
+ for (const shape of additionalShapes) {
183
+ images.set(shape.name, new lambda_microvm_cdk_1.MicrovmImage(this, shape.constructId, {
184
+ imageName: shape.imageName,
185
+ description: shape.description,
186
+ source: props.imageSource,
187
+ baseImageArn: props.baseImageArn,
188
+ baseImageVersion: props.baseImageVersion,
189
+ memoryMiB: shape.memoryMiB,
190
+ executionRole: defaultImage.executionRole,
191
+ environment: imageEnvironment,
192
+ hooks: imageHooks,
193
+ }));
194
+ }
195
+ // Bucket-wide rather than per-prefix (as the ECS runner's task role is):
196
+ // the prefix arrives at /run, so it cannot be named at synth, and one
197
+ // execution role is shared by every VM of every shape (D3). So a VM can
198
+ // read another session's credential store — acceptable within a single
199
+ // customer account, and what Phase 4 (#263) has to tighten when this goes
200
+ // multi-tenant.
201
+ durableState.grantReadWrite(defaultImage.executionRole);
202
+ // The image receives only an ARN; `/run` reads the secret value at runtime.
203
+ props.runnerSecret?.grantRead(defaultImage.executionRole);
204
+ // The shape catalogue the controller resolves a doorbell's `shape` field
205
+ // against, and advertises over the describe channel (#723). One JSON
206
+ // array, built with `Stack.toJsonString` — not `JSON.stringify` — because
207
+ // each `image_arn` is an unresolved `Fn::GetAtt` token at synth time, and
208
+ // `toJsonString` is the API that resolves tokens embedded in a JSON
209
+ // string (it emits an `Fn::Join`). `Stack.of(this)`, not `this`, because
210
+ // this class is a `Construct`, not a `Stack` — `toJsonString` is Stack-only.
211
+ const shapeCatalogueJson = cdk.Stack.of(this).toJsonString(shapes.map((shape) => ({
212
+ name: shape.name,
213
+ title: shape.title,
214
+ description: shape.description,
215
+ memory_mib: shape.memoryMiB,
216
+ image_arn: images.get(shape.name).imageArn,
217
+ is_default: shape.isDefault,
218
+ })));
219
+ const controller = new lambda.Function(this, 'Controller', {
220
+ runtime: lambda.Runtime.NODEJS_22_X,
221
+ // Pre-bundled at PACKAGE build time (`pnpm --filter @evident-ai/runner-cdk
222
+ // build`, see scripts/build.ts), not at synth time — see the waker's
223
+ // `construct.ts` for why (`NodejsFunction` can't ship in a published
224
+ // construct: its entry file isn't in the tarball, and bundling would
225
+ // need esbuild on the consumer's synth machine). Resolved from the
226
+ // PACKAGE ROOT, two directories below `__dirname` here either compiled
227
+ // (`dist/microvm`) or via ts-node (`src/microvm`) — same as the waker.
228
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', 'dist', 'controller-lambda')),
229
+ handler: 'handler.handler',
230
+ // 128 MB is a slow cold start for a bundled SDK client on a path with a
231
+ // hard client deadline.
232
+ memorySize: 256,
233
+ // Deliberately BELOW Evident's WEBHOOK_DELIVERY_TIMEOUT_MS (10 s): the
234
+ // SUSPENDING poll can take ~2 s, and the 409 it produces is only useful if
235
+ // it reaches Evident before the client aborts. Do not "align" this to 10 s.
236
+ timeout: cdk.Duration.seconds(8),
237
+ environment: {
238
+ // The ARN and field name, never the value: the controller reads it from
239
+ // Secrets Manager at runtime, so the key is not exposed to anyone with
240
+ // lambda:GetFunctionConfiguration.
241
+ DOORBELL_SECRET_ARN: props.doorbellSecret.secretArn,
242
+ DOORBELL_SECRET_KEY: DOORBELL_SECRET_KEY,
243
+ MICROVM_SHAPES: shapeCatalogueJson,
244
+ // The role every shape's MicroVM shares (D3) — still one role, still
245
+ // this same expression, now documented as shared rather than singular.
246
+ MICROVM_EXECUTION_ROLE_ARN: defaultImage.executionRole.roleArn,
247
+ },
248
+ });
249
+ // The four control actions are granted on '*', the PassNetworkConnector
250
+ // resource is a partition/region wildcard, and PassRole names the
251
+ // execution role every shape now shares (D3) — so nothing in this grant
252
+ // is image-specific (V4), and one call on the default shape's image
253
+ // authorises the controller for every shape's MicroVM. N calls would
254
+ // emit N near-identical policy statements and break the "exactly one
255
+ // statement granting X" guarantees this construct's test encodes.
256
+ defaultImage.grantRun(controller);
257
+ props.doorbellSecret.grantRead(controller);
258
+ const functionUrl = controller.addFunctionUrl({
259
+ // HMAC verification of the doorbell body IS the auth, as for the ECS waker.
260
+ authType: lambda.FunctionUrlAuthType.NONE,
261
+ });
262
+ // Opt-in only (D8): both values present is what creates the reporter, so an
263
+ // adopter who supplies neither gets a template with no additional Lambda and
264
+ // no custom resource — this branch must not run for them.
265
+ if (props.evidentApiUrl && props.evidentRunnerId) {
266
+ new construct_1.ImageVersionReporter(this, 'ImageVersionReporter', {
267
+ doorbellSecret: props.doorbellSecret,
268
+ evidentApiUrl: props.evidentApiUrl,
269
+ evidentRunnerId: props.evidentRunnerId,
270
+ imageVersions: Object.fromEntries(shapes.map((shape) => [shape.name, images.get(shape.name).latestActiveImageVersion])),
271
+ // `buildInputs` already covers every property that can cause AWS
272
+ // to publish a new version for THIS shape's image (source, base
273
+ // image, memory, hooks, environment, description, build role —
274
+ // see its docstring); no additional encoding needed here.
275
+ buildTriggers: Object.fromEntries(shapes.map((shape) => [shape.name, images.get(shape.name).buildInputs])),
276
+ });
277
+ }
278
+ this.functionUrl = functionUrl.url;
279
+ this.shapeCatalogueJson = shapeCatalogueJson;
280
+ this.durableStateBucket = durableState;
281
+ }
282
+ }
283
+ exports.EvidentMicrovmConstruct = EvidentMicrovmConstruct;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The doorbell Evident POSTs to the controller's Function URL. `run_payload` is
3
+ * OPAQUE: it carries the runner key and whatever else the MicroVM's `/run` hook
4
+ * needs, and the controller only ever re-serialises it into `runHookPayload`. It
5
+ * is never parsed, never logged and never echoed — that is what makes the
6
+ * controller credential-free.
7
+ *
8
+ * `occurred_at` is required because it is the per-event half of the `RunMicrovm`
9
+ * `clientToken` (see `clientTokenFor`): a token derived from `runner_id` alone
10
+ * would be stable for the runner's whole life, so a post-termination wake would
11
+ * replay the dead VM's cached response and start nothing.
12
+ *
13
+ * A third arm, `runner.shapes_requested`, is a describe request rather than an
14
+ * event: it has no runner and no event instant, so it is parsed BEFORE the
15
+ * `runner_id`/`occurred_at` guards below. Its staleness is zero by construction
16
+ * — the response renders from the same deploy-time environment variable
17
+ * CloudFormation writes when it builds the images, so there is no second source
18
+ * to drift from. Replay is deliberately undefended: it is a pure read with no
19
+ * side effect, and its response carries only deploy-time metadata (shape names,
20
+ * titles, descriptions, memory) that is already public to the operator, so a
21
+ * replayed request yields nothing to gain. It is not defenceless — no response
22
+ * is produced without a valid HMAC over the exact body. ⚠️ Any future
23
+ * STATE-CHANGING request type added to this channel needs real replay defence
24
+ * (a timestamp window or a nonce); this reasoning does not extend to one.
25
+ */
26
+ declare const WAKE_EVENT_TYPES: readonly ["agent.message_queued", "agent.wake_requested"];
27
+ declare const SUSPEND_EVENT_TYPE = "runner.suspend_requested";
28
+ export declare const SHAPES_EVENT_TYPE = "runner.shapes_requested";
29
+ type WakeEventType = (typeof WAKE_EVENT_TYPES)[number];
30
+ type DoorbellFields = {
31
+ runnerId: string;
32
+ occurredAt: string;
33
+ microvmId?: string;
34
+ };
35
+ export type WakeDoorbell = DoorbellFields & {
36
+ type: WakeEventType;
37
+ runHookPayload: string;
38
+ /**
39
+ * The shape a doorbell asks for, by name. `undefined` means "the default
40
+ * shape" — the controller resolves it against the shape catalogue.
41
+ */
42
+ shape?: string;
43
+ };
44
+ export type Doorbell = WakeDoorbell | (DoorbellFields & {
45
+ type: typeof SUSPEND_EVENT_TYPE;
46
+ }) | {
47
+ type: typeof SHAPES_EVENT_TYPE;
48
+ };
49
+ /**
50
+ * Rejection reasons. Every one is a fixed constant: it goes into both the
51
+ * response body (which Evident persists) and the log line, so it must never
52
+ * contain anything derived from the request.
53
+ */
54
+ export declare const DOORBELL_REJECTION: {
55
+ readonly invalidJson: "invalid_json";
56
+ readonly missingRunnerId: "missing_runner_id";
57
+ readonly missingOccurredAt: "missing_occurred_at";
58
+ readonly unsupportedType: "unsupported_event_type";
59
+ readonly invalidMicrovmId: "invalid_microvm_id";
60
+ readonly invalidShape: "invalid_shape";
61
+ readonly missingRunPayload: "missing_run_payload";
62
+ readonly runPayloadTooLarge: "run_payload_too_large";
63
+ };
64
+ export type DoorbellRejection = (typeof DOORBELL_REJECTION)[keyof typeof DOORBELL_REJECTION];
65
+ export type ParsedDoorbell = {
66
+ ok: true;
67
+ doorbell: Doorbell;
68
+ } | {
69
+ ok: false;
70
+ reason: DoorbellRejection;
71
+ };
72
+ export declare function parseDoorbell(rawBody: string): ParsedDoorbell;
73
+ export {};
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DOORBELL_REJECTION = exports.SHAPES_EVENT_TYPE = void 0;
4
+ exports.parseDoorbell = parseDoorbell;
5
+ const constants_1 = require("../constants");
6
+ /**
7
+ * The doorbell Evident POSTs to the controller's Function URL. `run_payload` is
8
+ * OPAQUE: it carries the runner key and whatever else the MicroVM's `/run` hook
9
+ * needs, and the controller only ever re-serialises it into `runHookPayload`. It
10
+ * is never parsed, never logged and never echoed — that is what makes the
11
+ * controller credential-free.
12
+ *
13
+ * `occurred_at` is required because it is the per-event half of the `RunMicrovm`
14
+ * `clientToken` (see `clientTokenFor`): a token derived from `runner_id` alone
15
+ * would be stable for the runner's whole life, so a post-termination wake would
16
+ * replay the dead VM's cached response and start nothing.
17
+ *
18
+ * A third arm, `runner.shapes_requested`, is a describe request rather than an
19
+ * event: it has no runner and no event instant, so it is parsed BEFORE the
20
+ * `runner_id`/`occurred_at` guards below. Its staleness is zero by construction
21
+ * — the response renders from the same deploy-time environment variable
22
+ * CloudFormation writes when it builds the images, so there is no second source
23
+ * to drift from. Replay is deliberately undefended: it is a pure read with no
24
+ * side effect, and its response carries only deploy-time metadata (shape names,
25
+ * titles, descriptions, memory) that is already public to the operator, so a
26
+ * replayed request yields nothing to gain. It is not defenceless — no response
27
+ * is produced without a valid HMAC over the exact body. ⚠️ Any future
28
+ * STATE-CHANGING request type added to this channel needs real replay defence
29
+ * (a timestamp window or a nonce); this reasoning does not extend to one.
30
+ */
31
+ const WAKE_EVENT_TYPES = ['agent.message_queued', 'agent.wake_requested'];
32
+ const SUSPEND_EVENT_TYPE = 'runner.suspend_requested';
33
+ exports.SHAPES_EVENT_TYPE = 'runner.shapes_requested';
34
+ /**
35
+ * Rejection reasons. Every one is a fixed constant: it goes into both the
36
+ * response body (which Evident persists) and the log line, so it must never
37
+ * contain anything derived from the request.
38
+ */
39
+ exports.DOORBELL_REJECTION = {
40
+ invalidJson: 'invalid_json',
41
+ missingRunnerId: 'missing_runner_id',
42
+ missingOccurredAt: 'missing_occurred_at',
43
+ unsupportedType: 'unsupported_event_type',
44
+ invalidMicrovmId: 'invalid_microvm_id',
45
+ invalidShape: 'invalid_shape',
46
+ missingRunPayload: 'missing_run_payload',
47
+ runPayloadTooLarge: 'run_payload_too_large',
48
+ };
49
+ function nonBlankString(value) {
50
+ return typeof value === 'string' && value.trim().length > 0;
51
+ }
52
+ function isWakeType(value) {
53
+ return WAKE_EVENT_TYPES.includes(value);
54
+ }
55
+ function parseDoorbell(rawBody) {
56
+ let body;
57
+ try {
58
+ body = JSON.parse(rawBody);
59
+ }
60
+ catch {
61
+ return { ok: false, reason: exports.DOORBELL_REJECTION.invalidJson };
62
+ }
63
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
64
+ return { ok: false, reason: exports.DOORBELL_REJECTION.invalidJson };
65
+ }
66
+ const { type, runner_id: runnerId, occurred_at: occurredAt, microvm_id: microvmId, run_payload: runPayload, shape, } = body;
67
+ if (type !== SUSPEND_EVENT_TYPE && type !== exports.SHAPES_EVENT_TYPE && !isWakeType(type)) {
68
+ return { ok: false, reason: exports.DOORBELL_REJECTION.unsupportedType };
69
+ }
70
+ // A describe request has no runner and no event instant — resolve it here,
71
+ // before the runner_id/occurred_at guards below, or it could never satisfy them.
72
+ if (type === exports.SHAPES_EVENT_TYPE) {
73
+ return { ok: true, doorbell: { type } };
74
+ }
75
+ if (!nonBlankString(runnerId)) {
76
+ return { ok: false, reason: exports.DOORBELL_REJECTION.missingRunnerId };
77
+ }
78
+ if (!nonBlankString(occurredAt)) {
79
+ return { ok: false, reason: exports.DOORBELL_REJECTION.missingOccurredAt };
80
+ }
81
+ if (microvmId !== undefined && !nonBlankString(microvmId)) {
82
+ return { ok: false, reason: exports.DOORBELL_REJECTION.invalidMicrovmId };
83
+ }
84
+ const fields = { runnerId, occurredAt, microvmId };
85
+ if (type === SUSPEND_EVENT_TYPE) {
86
+ return { ok: true, doorbell: { ...fields, type } };
87
+ }
88
+ if (shape !== undefined && !nonBlankString(shape)) {
89
+ return { ok: false, reason: exports.DOORBELL_REJECTION.invalidShape };
90
+ }
91
+ if (runPayload === undefined || runPayload === null) {
92
+ return { ok: false, reason: exports.DOORBELL_REJECTION.missingRunPayload };
93
+ }
94
+ const runHookPayload = JSON.stringify(runPayload);
95
+ if (Buffer.byteLength(runHookPayload, 'utf8') > constants_1.RUN_HOOK_PAYLOAD_MAX_BYTES) {
96
+ return { ok: false, reason: exports.DOORBELL_REJECTION.runPayloadTooLarge };
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
+ };
107
+ }
@@ -0,0 +1,27 @@
1
+ import type { MicrovmClient } from './microvm-client';
2
+ import type { ShapeCatalogue } from './shape-catalogue';
3
+ import { type Timing } from './throttle-retry';
4
+ /**
5
+ * The controller's pure decision core: verify the HMAC over the raw body, parse
6
+ * the doorbell, then map the MicroVM's current state to exactly one lifecycle
7
+ * call. Everything with I/O is injected, so the whole tree is unit-testable.
8
+ *
9
+ * The response body NEVER contains anything derived from `run_payload` — Evident
10
+ * persists it as `response_body`.
11
+ */
12
+ type ActionKind = 'none' | 'run' | 'resume' | 'suspend' | 'rejected' | 'describe';
13
+ export type DoorbellResult = {
14
+ statusCode: number;
15
+ body: string;
16
+ action: {
17
+ kind: ActionKind;
18
+ };
19
+ };
20
+ export declare function handleDoorbell({ rawBody, signatureHeader, doorbellSecret, shapes, microvm, sleep, random, }: {
21
+ rawBody: string;
22
+ signatureHeader: string | undefined;
23
+ doorbellSecret: string;
24
+ shapes: ShapeCatalogue;
25
+ microvm: MicrovmClient;
26
+ } & Timing): Promise<DoorbellResult>;
27
+ export {};