@stigmer/runner 3.12.7 → 3.12.9

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 (79) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/fetch-interceptor.js +29 -16
  3. package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.d.ts +14 -0
  5. package/dist/activities/execute-cursor/index.js +76 -5
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/message-translator.d.ts +15 -9
  8. package/dist/activities/execute-cursor/message-translator.js +15 -9
  9. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  10. package/dist/activities/execute-deep-agent/index.js +9 -0
  11. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  12. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +2 -0
  13. package/dist/activities/execute-deep-agent/mcp-gate.js +2 -1
  14. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  15. package/dist/activities/execute-deep-agent/setup.d.ts +10 -1
  16. package/dist/activities/execute-deep-agent/setup.js +45 -3
  17. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  18. package/dist/payload-codecs.d.ts +1 -1
  19. package/dist/payload-codecs.js +6 -4
  20. package/dist/payload-codecs.js.map +1 -1
  21. package/dist/shared/memory-attachment.d.ts +97 -0
  22. package/dist/shared/memory-attachment.js +136 -0
  23. package/dist/shared/memory-attachment.js.map +1 -0
  24. package/dist/shared/memory-retrieval.d.ts +126 -0
  25. package/dist/shared/memory-retrieval.js +293 -0
  26. package/dist/shared/memory-retrieval.js.map +1 -0
  27. package/dist/shared/runner-credential-keys.js +2 -1
  28. package/dist/shared/runner-credential-keys.js.map +1 -1
  29. package/dist/shared/tool-kind.js +9 -0
  30. package/dist/shared/tool-kind.js.map +1 -1
  31. package/package.json +3 -2
  32. package/src/__tests__/history-encryption-e2e.test.ts +1 -1
  33. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -1
  34. package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +49 -15
  35. package/src/activities/execute-cursor/fetch-interceptor.ts +37 -16
  36. package/src/activities/execute-cursor/index.ts +86 -5
  37. package/src/activities/execute-cursor/message-translator.ts +15 -9
  38. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +7 -0
  39. package/src/activities/execute-deep-agent/index.ts +10 -0
  40. package/src/activities/execute-deep-agent/mcp-gate.ts +4 -1
  41. package/src/activities/execute-deep-agent/setup.ts +70 -6
  42. package/src/payload-codecs.ts +12 -9
  43. package/src/shared/__tests__/memory-attachment.test.ts +167 -0
  44. package/src/shared/__tests__/memory-retrieval.test.ts +310 -0
  45. package/src/shared/memory-attachment.ts +167 -0
  46. package/src/shared/memory-retrieval.ts +383 -0
  47. package/src/shared/runner-credential-keys.ts +2 -1
  48. package/src/shared/tool-kind.ts +10 -0
  49. package/dist/claimcheck/compressor.d.ts +0 -2
  50. package/dist/claimcheck/compressor.js +0 -8
  51. package/dist/claimcheck/compressor.js.map +0 -1
  52. package/dist/claimcheck/config.d.ts +0 -7
  53. package/dist/claimcheck/config.js +0 -10
  54. package/dist/claimcheck/config.js.map +0 -1
  55. package/dist/claimcheck/index.d.ts +0 -3
  56. package/dist/claimcheck/index.js +0 -4
  57. package/dist/claimcheck/index.js.map +0 -1
  58. package/dist/claimcheck/payload-codec.d.ts +0 -23
  59. package/dist/claimcheck/payload-codec.js +0 -105
  60. package/dist/claimcheck/payload-codec.js.map +0 -1
  61. package/dist/encryption/config.d.ts +0 -64
  62. package/dist/encryption/config.js +0 -113
  63. package/dist/encryption/config.js.map +0 -1
  64. package/dist/encryption/index.d.ts +0 -3
  65. package/dist/encryption/index.js +0 -3
  66. package/dist/encryption/index.js.map +0 -1
  67. package/dist/encryption/payload-codec.d.ts +0 -41
  68. package/dist/encryption/payload-codec.js +0 -130
  69. package/dist/encryption/payload-codec.js.map +0 -1
  70. package/src/__tests__/claimcheck-codec.test.ts +0 -256
  71. package/src/__tests__/encryption-codec.test.ts +0 -287
  72. package/src/__tests__/fixtures/encrypted-payload-fixture.json +0 -15
  73. package/src/claimcheck/compressor.ts +0 -9
  74. package/src/claimcheck/config.ts +0 -20
  75. package/src/claimcheck/index.ts +0 -3
  76. package/src/claimcheck/payload-codec.ts +0 -139
  77. package/src/encryption/config.ts +0 -161
  78. package/src/encryption/index.ts +0 -3
  79. package/src/encryption/payload-codec.ts +0 -152
@@ -1,287 +0,0 @@
1
- /**
2
- * Unit tests for the payload-encryption codec (stigmer-cloud#227).
3
- *
4
- * Covers the codec in isolation, its composition with the claim-check
5
- * codec (order is load-bearing: encrypt before relocate, so object
6
- * storage only ever sees ciphertext), the config loader's fail-fast
7
- * contract, and the committed cross-language conformance fixture that
8
- * pins the envelope format the Java decode-only codec (stigmer-cloud
9
- * temporal-starter) must match.
10
- */
11
-
12
- import { describe, it, expect, afterEach } from "vitest";
13
- import { randomBytes } from "node:crypto";
14
- import { readFileSync } from "node:fs";
15
- import { join, dirname } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
- import type { Payload } from "@temporalio/common";
18
- import { EncryptionPayloadCodec } from "../encryption/payload-codec.js";
19
- import { loadPayloadEncryptionConfig } from "../encryption/config.js";
20
- import type { PayloadEncryptionConfig } from "../encryption/config.js";
21
- import { ClaimcheckPayloadCodec } from "../claimcheck/payload-codec.js";
22
- import { makeInMemoryArtifactStorage } from "../__test-utils__/fake-artifact-storage.js";
23
-
24
- const __dirname = dirname(fileURLToPath(import.meta.url));
25
-
26
- function makeKeyConfig(overrides: Partial<PayloadEncryptionConfig> = {}): PayloadEncryptionConfig {
27
- return {
28
- primary: { keyId: "test-key-1", key: randomBytes(32) },
29
- ...overrides,
30
- };
31
- }
32
-
33
- function makeJsonPayload(value: unknown): Payload {
34
- return {
35
- metadata: { encoding: Buffer.from("json/plain") },
36
- data: Buffer.from(JSON.stringify(value)),
37
- };
38
- }
39
-
40
- function metadataString(payload: Payload, key: string): string | undefined {
41
- const bytes = payload.metadata?.[key];
42
- return bytes ? Buffer.from(bytes).toString("utf-8") : undefined;
43
- }
44
-
45
- describe("EncryptionPayloadCodec", () => {
46
- it("round-trips a payload, restoring metadata and data exactly", async () => {
47
- const codec = new EncryptionPayloadCodec(makeKeyConfig());
48
- const original = makeJsonPayload({ secret: "s3cret-value" });
49
-
50
- const [encoded] = await codec.encode([original]);
51
- expect(metadataString(encoded, "encoding")).toBe("binary/encrypted");
52
- expect(metadataString(encoded, "encryption-key-id")).toBe("test-key-1");
53
- expect(Buffer.from(encoded.data!).includes("s3cret-value")).toBe(false);
54
-
55
- const [decoded] = await codec.decode([encoded]);
56
- expect(metadataString(decoded, "encoding")).toBe("json/plain");
57
- expect(Buffer.from(decoded.data!)).toEqual(original.data);
58
- });
59
-
60
- it("passes through payloads it did not encode", async () => {
61
- const codec = new EncryptionPayloadCodec(makeKeyConfig());
62
- const plaintext = makeJsonPayload({ from: "java-orchestrator" });
63
- const [decoded] = await codec.decode([plaintext]);
64
- expect(decoded).toBe(plaintext);
65
- });
66
-
67
- it("skips data-less payloads on encode (binary/null void results)", async () => {
68
- const codec = new EncryptionPayloadCodec(makeKeyConfig());
69
- const nullPayload: Payload = {
70
- metadata: { encoding: Buffer.from("binary/null") },
71
- data: undefined,
72
- };
73
- const [encoded] = await codec.encode([nullPayload]);
74
- expect(encoded).toBe(nullPayload);
75
- });
76
-
77
- it("fails closed on tampered ciphertext", async () => {
78
- const codec = new EncryptionPayloadCodec(makeKeyConfig());
79
- const [encoded] = await codec.encode([makeJsonPayload({ a: 1 })]);
80
-
81
- const tampered = Buffer.from(encoded.data!);
82
- tampered[tampered.length - 1] ^= 0xff;
83
-
84
- await expect(
85
- codec.decode([{ metadata: encoded.metadata, data: tampered }]),
86
- ).rejects.toThrow(/corrupt or the configured key does not match/);
87
- });
88
-
89
- it("fails closed on an unknown key id", async () => {
90
- const writer = new EncryptionPayloadCodec(makeKeyConfig());
91
- const [encoded] = await writer.encode([makeJsonPayload({ a: 1 })]);
92
-
93
- const reader = new EncryptionPayloadCodec(
94
- makeKeyConfig({ primary: { keyId: "other-key", key: randomBytes(32) } }),
95
- );
96
- await expect(reader.decode([encoded])).rejects.toThrow(
97
- /unknown key id 'test-key-1'/,
98
- );
99
- });
100
-
101
- it("fails closed when the key id metadata is missing", async () => {
102
- const codec = new EncryptionPayloadCodec(makeKeyConfig());
103
- const [encoded] = await codec.encode([makeJsonPayload({ a: 1 })]);
104
-
105
- const stripped: Payload = {
106
- metadata: { encoding: Buffer.from("binary/encrypted") },
107
- data: encoded.data,
108
- };
109
- await expect(codec.decode([stripped])).rejects.toThrow(
110
- /missing its encryption-key-id/,
111
- );
112
- });
113
-
114
- it("decodes payloads written under the secondary key (rotation window)", async () => {
115
- const oldKey = { keyId: "2026-01", key: randomBytes(32) };
116
- const writer = new EncryptionPayloadCodec({ primary: oldKey });
117
- const [encoded] = await writer.encode([makeJsonPayload({ rotated: true })]);
118
-
119
- const rotatedReader = new EncryptionPayloadCodec({
120
- primary: { keyId: "2026-08", key: randomBytes(32) },
121
- secondary: oldKey,
122
- });
123
- const [decoded] = await rotatedReader.decode([encoded]);
124
- expect(JSON.parse(Buffer.from(decoded.data!).toString())).toEqual({ rotated: true });
125
- });
126
- });
127
-
128
- describe("composition with claim-check (encrypt, then relocate)", () => {
129
- it("stores only ciphertext in object storage and round-trips exactly", async () => {
130
- const { storage, blobs } = makeInMemoryArtifactStorage();
131
- const encryption = new EncryptionPayloadCodec(makeKeyConfig());
132
- const claimcheck = new ClaimcheckPayloadCodec(storage, {
133
- enabled: true,
134
- thresholdBytes: 128,
135
- compressionEnabled: false,
136
- keyPrefix: "claimcheck/",
137
- });
138
-
139
- const secret = "very-large-and-very-secret-".repeat(32);
140
- const original = makeJsonPayload({ secret });
141
-
142
- // Temporal applies codec arrays in order on encode, reverse on decode.
143
- const [encrypted] = await encryption.encode([original]);
144
- const [relocated] = await claimcheck.encode([encrypted]);
145
- expect(metadataString(relocated, "encoding")).toBe("binary/claimcheck");
146
-
147
- expect(blobs.size).toBe(1);
148
- const blob: Buffer = blobs.values().next().value!;
149
- expect(blob.includes("very-large-and-very-secret-")).toBe(false);
150
-
151
- const [restored] = await claimcheck.decode([relocated]);
152
- const [decrypted] = await encryption.decode([restored]);
153
- expect(metadataString(decrypted, "encoding")).toBe("json/plain");
154
- expect(Buffer.from(decrypted.data!)).toEqual(original.data);
155
- });
156
- });
157
-
158
- describe("loadPayloadEncryptionConfig", () => {
159
- const ENV_VARS = [
160
- "STIGMER_PAYLOAD_ENCRYPTION_KEY",
161
- "STIGMER_PAYLOAD_ENCRYPTION_KEY_ID",
162
- "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY",
163
- "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY_ID",
164
- ];
165
-
166
- afterEach(() => {
167
- for (const name of ENV_VARS) delete process.env[name];
168
- });
169
-
170
- it("returns undefined when no key is configured", () => {
171
- expect(loadPayloadEncryptionConfig()).toBeUndefined();
172
- });
173
-
174
- it("loads primary and secondary keys", () => {
175
- process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(32).toString("base64");
176
- process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY_ID = "k2";
177
- process.env.STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY = randomBytes(32).toString("base64");
178
- process.env.STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY_ID = "k1";
179
-
180
- const config = loadPayloadEncryptionConfig();
181
- expect(config?.primary.keyId).toBe("k2");
182
- expect(config?.primary.key.length).toBe(32);
183
- expect(config?.secondary?.keyId).toBe("k1");
184
- });
185
-
186
- it("fails the boot when a key is set without an id", () => {
187
- process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(32).toString("base64");
188
- expect(() => loadPayloadEncryptionConfig()).toThrow(
189
- /STIGMER_PAYLOAD_ENCRYPTION_KEY_ID is required/,
190
- );
191
- });
192
-
193
- it("fails the boot on a key of the wrong length", () => {
194
- process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(16).toString("base64");
195
- process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY_ID = "k1";
196
- expect(() => loadPayloadEncryptionConfig()).toThrow(/must decode to 32 bytes/);
197
- });
198
-
199
- // Server-managed key material from getRunnerBootstrapConfig (stigmer#398):
200
- // desktop-class runners receive a per-identity key at bootstrap instead of
201
- // env config. The env key is the operator's explicit choice and must win.
202
- describe("bootstrap-delivered keys", () => {
203
- const bootstrapKeys = () => ({
204
- key: randomBytes(32).toString("base64"),
205
- keyId: "identity-key-v1",
206
- secondaryKey: randomBytes(32).toString("base64"),
207
- secondaryKeyId: "identity-key-v0",
208
- });
209
-
210
- it("enables encryption from bootstrap material when no env key is set", () => {
211
- const config = loadPayloadEncryptionConfig(bootstrapKeys());
212
- expect(config?.primary.keyId).toBe("identity-key-v1");
213
- expect(config?.primary.key.length).toBe(32);
214
- expect(config?.secondary?.keyId).toBe("identity-key-v0");
215
- });
216
-
217
- it("env-configured key wins over bootstrap material", () => {
218
- process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY = randomBytes(32).toString("base64");
219
- process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY_ID = "env-key";
220
-
221
- const config = loadPayloadEncryptionConfig(bootstrapKeys());
222
- expect(config?.primary.keyId).toBe("env-key");
223
- expect(config?.secondary).toBeUndefined();
224
- });
225
-
226
- it("fails the boot on a bootstrap key without its id (server contract violation)", () => {
227
- expect(() =>
228
- loadPayloadEncryptionConfig({ key: randomBytes(32).toString("base64") }),
229
- ).toThrow(/payload_encryption_key_id/);
230
- });
231
-
232
- it("fails the boot on a malformed bootstrap key rather than running plaintext", () => {
233
- expect(() =>
234
- loadPayloadEncryptionConfig({
235
- key: randomBytes(16).toString("base64"),
236
- keyId: "identity-key-v1",
237
- }),
238
- ).toThrow(/must decode to 32 bytes/);
239
- });
240
-
241
- it("fails the boot on a bootstrap secondary key without its id", () => {
242
- expect(() =>
243
- loadPayloadEncryptionConfig({
244
- key: randomBytes(32).toString("base64"),
245
- keyId: "identity-key-v1",
246
- secondaryKey: randomBytes(32).toString("base64"),
247
- }),
248
- ).toThrow(/payload_encryption_secondary_key_id/);
249
- });
250
- });
251
- });
252
-
253
- describe("cross-language conformance fixture", () => {
254
- // The committed fixture pins the envelope as a wire contract. A copy
255
- // lives in stigmer-cloud (temporal-starter test resources) where the
256
- // Java decode-only codec must decrypt it to the same payload. Never
257
- // regenerate it casually: changing the fixture means changing the
258
- // cross-SDK envelope, which requires both implementations to move in
259
- // lockstep.
260
- it("decrypts the committed fixture to the expected payload", async () => {
261
- const fixture = JSON.parse(
262
- readFileSync(join(__dirname, "fixtures", "encrypted-payload-fixture.json"), "utf-8"),
263
- );
264
-
265
- const codec = new EncryptionPayloadCodec({
266
- primary: {
267
- keyId: fixture.keyId,
268
- key: Buffer.from(fixture.keyBase64, "base64"),
269
- },
270
- });
271
-
272
- const encrypted: Payload = {
273
- metadata: Object.fromEntries(
274
- Object.entries(fixture.encrypted.metadataBase64 as Record<string, string>).map(
275
- ([k, v]) => [k, Buffer.from(v, "base64")],
276
- ),
277
- ),
278
- data: Buffer.from(fixture.encrypted.dataBase64, "base64"),
279
- };
280
-
281
- const [decoded] = await codec.decode([encrypted]);
282
- expect(metadataString(decoded, "encoding")).toBe("json/plain");
283
- expect(Buffer.from(decoded.data!).toString("utf-8")).toBe(
284
- fixture.original.dataJson,
285
- );
286
- });
287
- });
@@ -1,15 +0,0 @@
1
- {
2
- "description": "Cross-language conformance fixture for the Temporal payload-encryption envelope (stigmer-cloud#227). data = iv(12) || AES-256-GCM ciphertext || tag(16); plaintext is the serialized original Payload proto. The TS codec (stigmer OSS runner) and the Java decode-only codec (stigmer-cloud temporal-starter) must both decrypt this to original.dataJson with encoding json/plain. The key is TEST-ONLY. Do not regenerate without moving both implementations in lockstep.",
3
- "keyId": "conformance-2026-08-11",
4
- "keyBase64": "LwN0jDzOwm0aPH8lyRRqsTMali/7RAgKKB9CY6psNFw=",
5
- "encrypted": {
6
- "metadataBase64": {
7
- "encoding": "YmluYXJ5L2VuY3J5cHRlZA==",
8
- "encryption-key-id": "Y29uZm9ybWFuY2UtMjAyNi0wOC0xMQ=="
9
- },
10
- "dataBase64": "CzL44tAoZwoawET5ApaDKGLYcJO2P8eTPS4hZnoEOUvZ7jkD5mkCnD9krgY9etYWO+06y779i8xRCs7QrnOKo5xo74HtlY0bwxYjr2dOra9TGRbtv3PlrW+DcfmEFAQ3Efem"
11
- },
12
- "original": {
13
- "dataJson": "{\"secret\":\"cross-language-conformance-value\"}"
14
- }
15
- }
@@ -1,9 +0,0 @@
1
- import { gzipSync, gunzipSync } from "node:zlib";
2
-
3
- export function compress(data: Buffer): Buffer<ArrayBuffer> {
4
- return gzipSync(data) as Buffer<ArrayBuffer>;
5
- }
6
-
7
- export function decompress(data: Buffer): Buffer<ArrayBuffer> {
8
- return gunzipSync(data) as Buffer<ArrayBuffer>;
9
- }
@@ -1,20 +0,0 @@
1
- export interface ClaimcheckConfig {
2
- readonly enabled: boolean;
3
- readonly thresholdBytes: number;
4
- readonly compressionEnabled: boolean;
5
- readonly keyPrefix: string;
6
- }
7
-
8
- const DEFAULT_THRESHOLD_BYTES = 128 * 1024; // 128KB
9
-
10
- export function loadClaimcheckConfig(): ClaimcheckConfig {
11
- return {
12
- enabled: process.env.CLAIMCHECK_ENABLED === "true",
13
- thresholdBytes: parseInt(
14
- process.env.CLAIMCHECK_THRESHOLD_BYTES ?? String(DEFAULT_THRESHOLD_BYTES),
15
- 10,
16
- ),
17
- compressionEnabled: process.env.CLAIMCHECK_COMPRESSION_ENABLED !== "false",
18
- keyPrefix: process.env.CLAIMCHECK_KEY_PREFIX ?? "claimcheck/",
19
- };
20
- }
@@ -1,3 +0,0 @@
1
- export { ClaimcheckPayloadCodec } from "./payload-codec.js";
2
- export { loadClaimcheckConfig, type ClaimcheckConfig } from "./config.js";
3
- export { compress, decompress } from "./compressor.js";
@@ -1,139 +0,0 @@
1
- /**
2
- * Temporal PayloadCodec that transparently offloads large payloads to
3
- * external storage (ArtifactStorage). Payloads below the threshold pass
4
- * through unchanged. Payloads at or above the threshold are compressed
5
- * (optional), uploaded, and replaced with a small reference marker.
6
- *
7
- * On decode, markers are detected, the original payload is downloaded
8
- * and decompressed, and the original bytes are restored — transparent
9
- * to workflow/activity code.
10
- */
11
-
12
- import { randomUUID } from "node:crypto";
13
- import type { Payload, PayloadCodec } from "@temporalio/common";
14
- import type { ArtifactStorage } from "../shared/artifact-storage.js";
15
- import type { ClaimcheckConfig } from "./config.js";
16
- import { compress, decompress } from "./compressor.js";
17
-
18
- const MARKER_METADATA_KEY = "encoding";
19
- const MARKER_ENCODING_VALUE = "binary/claimcheck";
20
-
21
- interface ClaimcheckMarker {
22
- key: string;
23
- size: number;
24
- compressed: boolean;
25
- /**
26
- * The relocated payload's original metadata, base64-encoded per value.
27
- * Without it the restored payload would carry the marker's own
28
- * "binary/claimcheck" encoding and no payload converter could interpret
29
- * it. Absent on markers written before this field existed; those decode
30
- * with the legacy (metadata-less) behavior.
31
- */
32
- metadata?: Record<string, string>;
33
- }
34
-
35
- export class ClaimcheckPayloadCodec implements PayloadCodec {
36
- constructor(
37
- private readonly storage: ArtifactStorage,
38
- private readonly config: ClaimcheckConfig,
39
- ) {}
40
-
41
- async encode(payloads: Payload[]): Promise<Payload[]> {
42
- return Promise.all(payloads.map((p) => this.encodePayload(p)));
43
- }
44
-
45
- async decode(payloads: Payload[]): Promise<Payload[]> {
46
- return Promise.all(payloads.map((p) => this.decodePayload(p)));
47
- }
48
-
49
- private async encodePayload(payload: Payload): Promise<Payload> {
50
- const data = payload.data;
51
- if (!data || data.length < this.config.thresholdBytes) {
52
- return payload;
53
- }
54
-
55
- const originalBuf = Buffer.from(data);
56
- let uploadBuf = originalBuf;
57
- let compressed = false;
58
-
59
- if (this.config.compressionEnabled) {
60
- const compressedBuf = compress(originalBuf);
61
- if (compressedBuf.length < originalBuf.length) {
62
- uploadBuf = compressedBuf;
63
- compressed = true;
64
- }
65
- }
66
-
67
- const key = `${this.config.keyPrefix}${randomUUID()}`;
68
- await this.storage.upload(key, uploadBuf, "application/octet-stream");
69
-
70
- const marker: ClaimcheckMarker = {
71
- key,
72
- size: data.length,
73
- compressed,
74
- metadata: serializeMetadata(payload.metadata),
75
- };
76
-
77
- return {
78
- metadata: {
79
- [MARKER_METADATA_KEY]: Buffer.from(MARKER_ENCODING_VALUE),
80
- },
81
- data: Buffer.from(JSON.stringify(marker)),
82
- };
83
- }
84
-
85
- private async decodePayload(payload: Payload): Promise<Payload> {
86
- if (!this.isClaimcheckPayload(payload)) {
87
- return payload;
88
- }
89
-
90
- const marker: ClaimcheckMarker = JSON.parse(
91
- Buffer.from(payload.data!).toString("utf-8"),
92
- );
93
-
94
- let rawBuf: Buffer;
95
- try {
96
- rawBuf = await this.storage.download(marker.key);
97
- } catch (err) {
98
- // Preserve the claimcheck-scoped error contract; the shared download error
99
- // already carries the HTTP status (proxy) or the miss (local) as the cause.
100
- const cause = err instanceof Error ? err.message : String(err);
101
- throw new Error(`Claimcheck retrieve failed for key ${marker.key}: ${cause}`);
102
- }
103
- const dataBuf = marker.compressed ? decompress(rawBuf) : rawBuf;
104
-
105
- return {
106
- metadata: marker.metadata
107
- ? deserializeMetadata(marker.metadata)
108
- : payload.metadata,
109
- data: dataBuf,
110
- };
111
- }
112
-
113
- private isClaimcheckPayload(payload: Payload): boolean {
114
- const encoding = payload.metadata?.[MARKER_METADATA_KEY];
115
- if (!encoding) return false;
116
- return Buffer.from(encoding).toString("utf-8") === MARKER_ENCODING_VALUE;
117
- }
118
- }
119
-
120
- function serializeMetadata(
121
- metadata: Payload["metadata"],
122
- ): Record<string, string> | undefined {
123
- if (!metadata) return undefined;
124
- const out: Record<string, string> = {};
125
- for (const [key, value] of Object.entries(metadata)) {
126
- if (value) out[key] = Buffer.from(value).toString("base64");
127
- }
128
- return out;
129
- }
130
-
131
- function deserializeMetadata(
132
- metadata: Record<string, string>,
133
- ): Record<string, Uint8Array> {
134
- const out: Record<string, Uint8Array> = {};
135
- for (const [key, value] of Object.entries(metadata)) {
136
- out[key] = Buffer.from(value, "base64");
137
- }
138
- return out;
139
- }
@@ -1,161 +0,0 @@
1
- /**
2
- * Payload-encryption configuration (stigmer-cloud#227, stigmer#398).
3
- *
4
- * Two key sources, strict precedence:
5
- *
6
- * 1. Environment (STIGMER_PAYLOAD_ENCRYPTION_KEY(_ID)) — the operator's
7
- * explicit choice: self-hosted deployments sharing one key with their
8
- * server, and cloud sandboxes injected with the platform key. When the
9
- * env key is set, bootstrap-delivered material is ignored entirely.
10
- * 2. Bootstrap-delivered — server-managed per-identity keys handed to
11
- * desktop-class runners by getRunnerBootstrapConfig. Held in memory
12
- * only; persistence lives server-side, which is what makes Temporal
13
- * replay work across runner restarts (every boot re-fetches the SAME
14
- * key).
15
- *
16
- * Encryption is enabled iff a key is present from either source — the same
17
- * enabled-iff-configured pattern as the claim-check codec. A malformed key
18
- * fails the boot rather than silently running plaintext: an operator who set
19
- * the key (or a server that minted one) intended history to be encrypted.
20
- *
21
- * Key rotation: payloads carry the id of the key that encrypted them.
22
- * During a rotation window the previous key stays readable via the
23
- * secondary pair while new payloads are written under the primary key.
24
- * Workers capture keys at construction, so a rotated bootstrap key lands
25
- * on the next runner boot — there is no live re-key.
26
- */
27
-
28
- import { getRunnerSecret } from "../shared/runner-credential-store.js";
29
-
30
- export interface EncryptionKey {
31
- readonly keyId: string;
32
- /** 32-byte AES-256 key. */
33
- readonly key: Buffer;
34
- }
35
-
36
- export interface PayloadEncryptionConfig {
37
- /** Key used to encrypt outgoing payloads (and decrypt its own). */
38
- readonly primary: EncryptionKey;
39
- /** Decrypt-only key accepted during rotation windows. */
40
- readonly secondary?: EncryptionKey;
41
- }
42
-
43
- /**
44
- * Server-managed key material delivered by getRunnerBootstrapConfig.
45
- * Structurally mirrors {@link BootstrapPayloadEncryptionKeys} in
46
- * stigmer-client.ts — declared here so this leaf module stays free of
47
- * client imports.
48
- */
49
- export interface BootstrapKeyMaterial {
50
- readonly key: string;
51
- readonly keyId?: string;
52
- readonly secondaryKey?: string;
53
- readonly secondaryKeyId?: string;
54
- }
55
-
56
- const KEY_ENV = "STIGMER_PAYLOAD_ENCRYPTION_KEY";
57
- const KEY_ID_ENV = "STIGMER_PAYLOAD_ENCRYPTION_KEY_ID";
58
- const SECONDARY_KEY_ENV = "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY";
59
- const SECONDARY_KEY_ID_ENV = "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY_ID";
60
-
61
- const AES_256_KEY_BYTES = 32;
62
-
63
- /**
64
- * Returns the encryption config, or undefined when encryption is not
65
- * configured (the codec is then simply not installed).
66
- *
67
- * Source precedence: the env key wins outright; bootstrap-delivered
68
- * material applies only when no env key is set (see the module doc).
69
- *
70
- * @throws when a key is present but malformed, or a key id is missing —
71
- * key misconfiguration must stop the boot, not degrade to plaintext.
72
- * This applies equally to bootstrap material: a server that hands out
73
- * a bad key or omits its id has broken the protocol contract, and
74
- * running plaintext against a server that manages keys would silently
75
- * defeat the feature.
76
- */
77
- export function loadPayloadEncryptionConfig(
78
- bootstrap?: BootstrapKeyMaterial,
79
- ): PayloadEncryptionConfig | undefined {
80
- // Key VALUES resolve through the credential store (the #508 boot capture
81
- // moves them out of process.env — agent shells must not read them); the
82
- // *_KEY_ID companions are rotation bookkeeping, not secrets, and stay
83
- // plain env reads.
84
- const rawKey = getRunnerSecret(KEY_ENV);
85
- if (rawKey) {
86
- const primary: EncryptionKey = {
87
- keyId: requireKeyId(KEY_ID_ENV),
88
- key: parseKey(rawKey, KEY_ENV),
89
- };
90
-
91
- const rawSecondary = getRunnerSecret(SECONDARY_KEY_ENV);
92
- const secondary: EncryptionKey | undefined = rawSecondary
93
- ? {
94
- keyId: requireKeyId(SECONDARY_KEY_ID_ENV),
95
- key: parseKey(rawSecondary, SECONDARY_KEY_ENV),
96
- }
97
- : undefined;
98
-
99
- return { primary, secondary };
100
- }
101
-
102
- if (bootstrap?.key) {
103
- const primary: EncryptionKey = {
104
- keyId: requireBootstrapKeyId(bootstrap.keyId, "payload_encryption_key_id"),
105
- key: parseKey(bootstrap.key, "bootstrap payload_encryption_key"),
106
- };
107
-
108
- const secondary: EncryptionKey | undefined = bootstrap.secondaryKey
109
- ? {
110
- keyId: requireBootstrapKeyId(
111
- bootstrap.secondaryKeyId,
112
- "payload_encryption_secondary_key_id",
113
- ),
114
- key: parseKey(bootstrap.secondaryKey, "bootstrap payload_encryption_secondary_key"),
115
- }
116
- : undefined;
117
-
118
- return { primary, secondary };
119
- }
120
-
121
- return undefined;
122
- }
123
-
124
- function requireKeyId(envName: string): string {
125
- const keyId = process.env[envName];
126
- // An explicit id is required (no default): during rotation two keys
127
- // coexist, and payloads must name which one encrypted them.
128
- if (!keyId) {
129
- throw new Error(
130
- `Payload encryption misconfigured: ${envName} is required when the ` +
131
- `corresponding key is set`,
132
- );
133
- }
134
- return keyId;
135
- }
136
-
137
- function requireBootstrapKeyId(keyId: string | undefined, fieldName: string): string {
138
- if (!keyId) {
139
- throw new Error(
140
- `Runner bootstrap returned a payload encryption key without its ${fieldName} — ` +
141
- `refusing to encrypt under an unidentified key (server contract violation)`,
142
- );
143
- }
144
- return keyId;
145
- }
146
-
147
- function parseKey(rawBase64: string, envName: string): Buffer {
148
- let key: Buffer;
149
- try {
150
- key = Buffer.from(rawBase64, "base64");
151
- } catch {
152
- throw new Error(`Payload encryption misconfigured: ${envName} is not valid base64`);
153
- }
154
- if (key.length !== AES_256_KEY_BYTES) {
155
- throw new Error(
156
- `Payload encryption misconfigured: ${envName} must decode to ` +
157
- `${AES_256_KEY_BYTES} bytes (AES-256), got ${key.length}`,
158
- );
159
- }
160
- return key;
161
- }
@@ -1,3 +0,0 @@
1
- export { EncryptionPayloadCodec } from "./payload-codec.js";
2
- export { loadPayloadEncryptionConfig } from "./config.js";
3
- export type { PayloadEncryptionConfig, EncryptionKey } from "./config.js";