@nookplot/runtime 0.5.100 → 0.5.106

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 (126) hide show
  1. package/dist/__tests__/autonomous.guardrails.test.d.ts +2 -0
  2. package/dist/__tests__/autonomous.guardrails.test.d.ts.map +1 -0
  3. package/dist/__tests__/autonomous.guardrails.test.js +215 -0
  4. package/dist/__tests__/autonomous.guardrails.test.js.map +1 -0
  5. package/dist/__tests__/autonomous.hooks.test.d.ts +2 -0
  6. package/dist/__tests__/autonomous.hooks.test.d.ts.map +1 -0
  7. package/dist/__tests__/autonomous.hooks.test.js +107 -0
  8. package/dist/__tests__/autonomous.hooks.test.js.map +1 -0
  9. package/dist/__tests__/chatEngine.episodicHook.test.d.ts +2 -0
  10. package/dist/__tests__/chatEngine.episodicHook.test.d.ts.map +1 -0
  11. package/dist/__tests__/chatEngine.episodicHook.test.js +160 -0
  12. package/dist/__tests__/chatEngine.episodicHook.test.js.map +1 -0
  13. package/dist/__tests__/chatEngine.test.d.ts +2 -0
  14. package/dist/__tests__/chatEngine.test.d.ts.map +1 -0
  15. package/dist/__tests__/chatEngine.test.js +482 -0
  16. package/dist/__tests__/chatEngine.test.js.map +1 -0
  17. package/dist/__tests__/conversation/compactionMemory.test.d.ts +2 -0
  18. package/dist/__tests__/conversation/compactionMemory.test.d.ts.map +1 -0
  19. package/dist/__tests__/conversation/compactionMemory.test.js +447 -0
  20. package/dist/__tests__/conversation/compactionMemory.test.js.map +1 -0
  21. package/dist/__tests__/conversation/modelThresholdsParity.test.d.ts +2 -0
  22. package/dist/__tests__/conversation/modelThresholdsParity.test.d.ts.map +1 -0
  23. package/dist/__tests__/conversation/modelThresholdsParity.test.js +79 -0
  24. package/dist/__tests__/conversation/modelThresholdsParity.test.js.map +1 -0
  25. package/dist/__tests__/guardrails.test.d.ts +2 -0
  26. package/dist/__tests__/guardrails.test.d.ts.map +1 -0
  27. package/dist/__tests__/guardrails.test.js +236 -0
  28. package/dist/__tests__/guardrails.test.js.map +1 -0
  29. package/dist/__tests__/hooks.test.d.ts +9 -0
  30. package/dist/__tests__/hooks.test.d.ts.map +1 -0
  31. package/dist/__tests__/hooks.test.js +188 -0
  32. package/dist/__tests__/hooks.test.js.map +1 -0
  33. package/dist/__tests__/querySegmentation.test.d.ts +2 -0
  34. package/dist/__tests__/querySegmentation.test.d.ts.map +1 -0
  35. package/dist/__tests__/querySegmentation.test.js +187 -0
  36. package/dist/__tests__/querySegmentation.test.js.map +1 -0
  37. package/dist/__tests__/sandbox.test.d.ts +13 -0
  38. package/dist/__tests__/sandbox.test.d.ts.map +1 -0
  39. package/dist/__tests__/sandbox.test.js +413 -0
  40. package/dist/__tests__/sandbox.test.js.map +1 -0
  41. package/dist/__tests__/wakeUpStack.test.d.ts +2 -0
  42. package/dist/__tests__/wakeUpStack.test.d.ts.map +1 -0
  43. package/dist/__tests__/wakeUpStack.test.js +239 -0
  44. package/dist/__tests__/wakeUpStack.test.js.map +1 -0
  45. package/dist/actionCatalog.generated.d.ts +1 -1
  46. package/dist/actionCatalog.generated.d.ts.map +1 -1
  47. package/dist/actionCatalog.generated.js +125 -21
  48. package/dist/actionCatalog.generated.js.map +1 -1
  49. package/dist/autonomous.d.ts +3 -0
  50. package/dist/autonomous.d.ts.map +1 -1
  51. package/dist/autonomous.js +108 -7
  52. package/dist/autonomous.js.map +1 -1
  53. package/dist/chat/chatEngine.d.ts +15 -0
  54. package/dist/chat/chatEngine.d.ts.map +1 -1
  55. package/dist/chat/chatEngine.js +59 -34
  56. package/dist/chat/chatEngine.js.map +1 -1
  57. package/dist/connection.d.ts.map +1 -1
  58. package/dist/connection.js +0 -1
  59. package/dist/connection.js.map +1 -1
  60. package/dist/conversation/compactionMemory.d.ts +124 -0
  61. package/dist/conversation/compactionMemory.d.ts.map +1 -0
  62. package/dist/conversation/compactionMemory.js +379 -0
  63. package/dist/conversation/compactionMemory.js.map +1 -0
  64. package/dist/conversation/conversationLogStore.d.ts +111 -0
  65. package/dist/conversation/conversationLogStore.d.ts.map +1 -0
  66. package/dist/conversation/conversationLogStore.js +248 -0
  67. package/dist/conversation/conversationLogStore.js.map +1 -0
  68. package/dist/conversation/conversationMemory.d.ts +59 -0
  69. package/dist/conversation/conversationMemory.d.ts.map +1 -0
  70. package/dist/conversation/conversationMemory.js +32 -0
  71. package/dist/conversation/conversationMemory.js.map +1 -0
  72. package/dist/conversation/index.d.ts +16 -0
  73. package/dist/conversation/index.d.ts.map +1 -0
  74. package/dist/conversation/index.js +5 -0
  75. package/dist/conversation/index.js.map +1 -0
  76. package/dist/conversation/modelLimits.d.ts +43 -0
  77. package/dist/conversation/modelLimits.d.ts.map +1 -0
  78. package/dist/conversation/modelLimits.js +67 -0
  79. package/dist/conversation/modelLimits.js.map +1 -0
  80. package/dist/defaultGuardrails.d.ts +21 -0
  81. package/dist/defaultGuardrails.d.ts.map +1 -0
  82. package/dist/defaultGuardrails.js +90 -0
  83. package/dist/defaultGuardrails.js.map +1 -0
  84. package/dist/episodicMemoryHook.d.ts +39 -0
  85. package/dist/episodicMemoryHook.d.ts.map +1 -0
  86. package/dist/episodicMemoryHook.js +58 -0
  87. package/dist/episodicMemoryHook.js.map +1 -0
  88. package/dist/guardrails.d.ts +182 -0
  89. package/dist/guardrails.d.ts.map +1 -0
  90. package/dist/guardrails.js +277 -0
  91. package/dist/guardrails.js.map +1 -0
  92. package/dist/hooks.d.ts +162 -0
  93. package/dist/hooks.d.ts.map +1 -0
  94. package/dist/hooks.js +91 -0
  95. package/dist/hooks.js.map +1 -0
  96. package/dist/index.d.ts +38 -3
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +51 -3
  99. package/dist/index.js.map +1 -1
  100. package/dist/knowledgeContext.d.ts +15 -1
  101. package/dist/knowledgeContext.d.ts.map +1 -1
  102. package/dist/knowledgeContext.js +26 -3
  103. package/dist/knowledgeContext.js.map +1 -1
  104. package/dist/memory.d.ts +15 -0
  105. package/dist/memory.d.ts.map +1 -1
  106. package/dist/memory.js +14 -0
  107. package/dist/memory.js.map +1 -1
  108. package/dist/querySegmentation.d.ts +54 -0
  109. package/dist/querySegmentation.d.ts.map +1 -0
  110. package/dist/querySegmentation.js +80 -0
  111. package/dist/querySegmentation.js.map +1 -0
  112. package/dist/sandbox.d.ts +156 -0
  113. package/dist/sandbox.d.ts.map +1 -0
  114. package/dist/sandbox.js +425 -0
  115. package/dist/sandbox.js.map +1 -0
  116. package/dist/signalActionMap.d.ts +19 -0
  117. package/dist/signalActionMap.d.ts.map +1 -1
  118. package/dist/signalActionMap.js +33 -0
  119. package/dist/signalActionMap.js.map +1 -1
  120. package/dist/types.d.ts +23 -1
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/wakeUpStack.d.ts +94 -0
  123. package/dist/wakeUpStack.d.ts.map +1 -0
  124. package/dist/wakeUpStack.js +215 -0
  125. package/dist/wakeUpStack.js.map +1 -0
  126. package/package.json +1 -1
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Tests for query segmentation — multi-granularity query expansion for
3
+ * WakeUpStack L2 retrieval (Change 2 of latent-briefing spec).
4
+ *
5
+ * segmentQuery is a pure string function — no I/O, no mocks needed. Each test
6
+ * asserts a specific invariant of the segmentation contract that the gateway's
7
+ * multi-query search path depends on.
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import { segmentQuery, queriesFromSegmented, segmentTaskForQuery, } from "../querySegmentation.js";
11
+ describe("segmentQuery", () => {
12
+ it("preserves the full trimmed task as .full", () => {
13
+ const s = segmentQuery(" review the audit findings ");
14
+ expect(s.full).toBe("review the audit findings");
15
+ });
16
+ it("emits no sliding-window segments for short tasks (< 10 words)", () => {
17
+ const s = segmentQuery("review this code for security issues please");
18
+ expect(s.segments).toEqual([]);
19
+ });
20
+ it("emits sliding-window segments for long tasks (≥ 10 words)", () => {
21
+ // 15 words — produces at least one window of 15 words
22
+ const task = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen";
23
+ const s = segmentQuery(task);
24
+ expect(s.segments.length).toBeGreaterThanOrEqual(1);
25
+ // First segment should contain the first 15 words
26
+ expect(s.segments[0]).toBe(task);
27
+ });
28
+ it("caps sliding-window segments at maxSegments", () => {
29
+ // 40 words with stride 8 + window 15 → up to 4 windows, capped at 3 by default
30
+ const words = Array.from({ length: 40 }, (_, i) => `word${i}`).join(" ");
31
+ const s = segmentQuery(words);
32
+ expect(s.segments.length).toBeLessThanOrEqual(3);
33
+ });
34
+ it("caps at custom maxSegments", () => {
35
+ const words = Array.from({ length: 40 }, (_, i) => `word${i}`).join(" ");
36
+ const s = segmentQuery(words, 2);
37
+ expect(s.segments.length).toBeLessThanOrEqual(2);
38
+ });
39
+ it("extracts hashtags as entities", () => {
40
+ const s = segmentQuery("discussing #latentspace and #mining topics");
41
+ expect(s.entities).toContain("#latentspace");
42
+ expect(s.entities).toContain("#mining");
43
+ });
44
+ it("extracts @mentions as entities", () => {
45
+ const s = segmentQuery("responding to @basedmd about the spec");
46
+ expect(s.entities).toContain("@basedmd");
47
+ });
48
+ it("extracts capitalized multi-word phrases as entities", () => {
49
+ const s = segmentQuery("Latent Briefing cites MemPalace in the comparison");
50
+ expect(s.entities).toContain("Latent Briefing");
51
+ expect(s.entities).not.toContain("MemPalace"); // single word — not captured
52
+ });
53
+ it("caps entities at 2", () => {
54
+ const s = segmentQuery("#a #b #c #d Latent Space Embedding Exchange");
55
+ expect(s.entities.length).toBeLessThanOrEqual(2);
56
+ });
57
+ it("extracts 'what is X' question spans", () => {
58
+ const s = segmentQuery("what is a knowledge bundle in Nookplot");
59
+ expect(s.questions.length).toBeGreaterThanOrEqual(1);
60
+ // The extracted phrase drops the "what is" prefix
61
+ expect(s.questions[0]).toContain("knowledge bundle");
62
+ });
63
+ it("extracts 'how to X' question spans", () => {
64
+ const s = segmentQuery("how to claim a mining reward safely");
65
+ expect(s.questions.length).toBeGreaterThanOrEqual(1);
66
+ });
67
+ it("emits no questions for non-question queries", () => {
68
+ const s = segmentQuery("refactor the authentication middleware");
69
+ expect(s.questions).toEqual([]);
70
+ });
71
+ it("handles empty input without throwing", () => {
72
+ const s = segmentQuery("");
73
+ expect(s.full).toBe("");
74
+ expect(s.segments).toEqual([]);
75
+ expect(s.entities).toEqual([]);
76
+ expect(s.questions).toEqual([]);
77
+ });
78
+ it("handles whitespace-only input without throwing", () => {
79
+ const s = segmentQuery(" \n\t ");
80
+ expect(s.full).toBe("");
81
+ expect(s.segments).toEqual([]);
82
+ });
83
+ });
84
+ describe("queriesFromSegmented", () => {
85
+ it("always places full query first (preserves single-query baseline)", () => {
86
+ const s = segmentQuery("Latent Briefing explains attention");
87
+ const q = queriesFromSegmented(s);
88
+ expect(q[0]).toBe("Latent Briefing explains attention");
89
+ });
90
+ it("deduplicates repeated strings", () => {
91
+ const s = {
92
+ full: "same query",
93
+ segments: ["same query"],
94
+ entities: ["same query"],
95
+ questions: [],
96
+ };
97
+ const q = queriesFromSegmented(s);
98
+ expect(q.length).toBe(1);
99
+ expect(q[0]).toBe("same query");
100
+ });
101
+ it("drops strings shorter than 2 chars", () => {
102
+ const s = {
103
+ full: "real query",
104
+ segments: [],
105
+ entities: ["a", "x"],
106
+ questions: [],
107
+ };
108
+ const q = queriesFromSegmented(s);
109
+ expect(q).toEqual(["real query"]);
110
+ });
111
+ it("enforces hard cap at max (default 4)", () => {
112
+ const s = {
113
+ full: "full",
114
+ segments: ["seg1", "seg2", "seg3"],
115
+ entities: ["#one", "#two"],
116
+ questions: ["q1"],
117
+ };
118
+ const q = queriesFromSegmented(s);
119
+ expect(q.length).toBeLessThanOrEqual(4);
120
+ expect(q[0]).toBe("full");
121
+ });
122
+ it("respects custom max", () => {
123
+ const s = {
124
+ full: "full",
125
+ segments: ["seg1", "seg2"],
126
+ entities: ["#one"],
127
+ questions: ["q1"],
128
+ };
129
+ const q = queriesFromSegmented(s, 2);
130
+ expect(q.length).toBe(2);
131
+ });
132
+ it("returns only full when everything else is empty", () => {
133
+ const s = { full: "just the task", segments: [], entities: [], questions: [] };
134
+ expect(queriesFromSegmented(s)).toEqual(["just the task"]);
135
+ });
136
+ it("preserves order: full, segments, entities, questions", () => {
137
+ const s = {
138
+ full: "full task",
139
+ segments: ["seg-one", "seg-two"],
140
+ entities: ["#ent"],
141
+ questions: ["q1"],
142
+ };
143
+ const q = queriesFromSegmented(s);
144
+ // Default max=4 → questions["q1"] gets truncated
145
+ expect(q).toEqual(["full task", "seg-one", "seg-two", "#ent"]);
146
+ });
147
+ it("includes question when under cap", () => {
148
+ const s = {
149
+ full: "full task",
150
+ segments: [],
151
+ entities: [],
152
+ questions: ["question phrase"],
153
+ };
154
+ const q = queriesFromSegmented(s);
155
+ expect(q).toEqual(["full task", "question phrase"]);
156
+ });
157
+ });
158
+ describe("segmentTaskForQuery", () => {
159
+ it("collapses to a single-element array for short/simple tasks", () => {
160
+ const q = segmentTaskForQuery("review the PR");
161
+ expect(q).toEqual(["review the PR"]);
162
+ });
163
+ it("expands multi-segment for long queries with entities", () => {
164
+ const task = "review the Latent Briefing spec changes against our current MemPalace implementation carefully";
165
+ const q = segmentTaskForQuery(task);
166
+ // Must have at least the full query; likely picks up "Latent Briefing" as entity
167
+ expect(q[0]).toBe(task);
168
+ expect(q.length).toBeGreaterThanOrEqual(1);
169
+ expect(q.length).toBeLessThanOrEqual(4);
170
+ });
171
+ it("enforces 4-query hard cap matching gateway", () => {
172
+ const words = Array.from({ length: 60 }, (_, i) => `word${i}`).join(" ");
173
+ const task = `${words} #tag1 #tag2 what is a Knowledge Bundle thing`;
174
+ const q = segmentTaskForQuery(task);
175
+ expect(q.length).toBeLessThanOrEqual(4);
176
+ });
177
+ it("returns [] for an empty string (guarded by length >= 2 filter)", () => {
178
+ // full is "" → length 0 → dropped by the >= 2 filter → empty result
179
+ const q = segmentTaskForQuery("");
180
+ expect(q).toEqual([]);
181
+ });
182
+ it("returns [] for whitespace-only input", () => {
183
+ const q = segmentTaskForQuery(" ");
184
+ expect(q).toEqual([]);
185
+ });
186
+ });
187
+ //# sourceMappingURL=querySegmentation.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"querySegmentation.test.js","sourceRoot":"","sources":["../../src/__tests__/querySegmentation.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Sandbox — unit tests.
3
+ *
4
+ * Covers:
5
+ * - LocalSandbox: exec, python, sh, put/get round-trip, close idempotency.
6
+ * - Sandbox.asTools: tool definitions route to python/sh.
7
+ * - DockerSandbox.buildRunArgs: --network none, --cpus, --memory defaults +
8
+ * port / mount / env serialization. Pure-function tests, no docker required.
9
+ * - DockerSandbox.create: mount validation rejects paths outside cwd
10
+ * before docker is invoked.
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=sandbox.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sandbox.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/sandbox.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Sandbox — unit tests.
3
+ *
4
+ * Covers:
5
+ * - LocalSandbox: exec, python, sh, put/get round-trip, close idempotency.
6
+ * - Sandbox.asTools: tool definitions route to python/sh.
7
+ * - DockerSandbox.buildRunArgs: --network none, --cpus, --memory defaults +
8
+ * port / mount / env serialization. Pure-function tests, no docker required.
9
+ * - DockerSandbox.create: mount validation rejects paths outside cwd
10
+ * before docker is invoked.
11
+ */
12
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { promises as fs } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import path from "node:path";
16
+ import { DEFAULT_SANDBOX_IMAGE, DockerSandbox, LocalSandbox, Sandbox, isSandbox, } from "../sandbox.js";
17
+ describe("LocalSandbox", () => {
18
+ it("exec(['echo', 'hi']) returns 'hi\\n'", async () => {
19
+ const sandbox = await LocalSandbox.create();
20
+ const out = await sandbox.exec(["echo", "hi"]);
21
+ expect(out).toBe("hi\n");
22
+ await sandbox.close();
23
+ });
24
+ it("python(script) executes via stdin", async () => {
25
+ const sandbox = await LocalSandbox.create();
26
+ const out = await sandbox.python("print('hello from python')");
27
+ expect(out).toBe("hello from python\n");
28
+ await sandbox.close();
29
+ });
30
+ it("sh(command) runs through sh -c", async () => {
31
+ const sandbox = await LocalSandbox.create();
32
+ const out = await sandbox.sh("echo $((2 + 2))");
33
+ expect(out).toBe("4\n");
34
+ await sandbox.close();
35
+ });
36
+ it("propagates env vars to exec", async () => {
37
+ const sandbox = await LocalSandbox.create({ env: { NK_TEST_VAR: "from-sandbox" } });
38
+ const out = await sandbox.sh("printf %s \"$NK_TEST_VAR\"");
39
+ expect(out).toBe("from-sandbox");
40
+ await sandbox.close();
41
+ });
42
+ it("put + get round-trip a file", async () => {
43
+ const dir = await fs.mkdtemp(path.join(tmpdir(), "sandbox-test-"));
44
+ const src = path.join(dir, "src.txt");
45
+ const inSandbox = path.join(dir, "in-sandbox.txt");
46
+ const out = path.join(dir, "out.txt");
47
+ await fs.writeFile(src, "hello");
48
+ const sandbox = await LocalSandbox.create();
49
+ await sandbox.put(src, inSandbox);
50
+ expect(await fs.readFile(inSandbox, "utf8")).toBe("hello");
51
+ const written = await sandbox.get(inSandbox, out);
52
+ expect(written).toBe(out);
53
+ expect(await fs.readFile(out, "utf8")).toBe("hello");
54
+ await sandbox.close();
55
+ await fs.rm(dir, { recursive: true, force: true });
56
+ });
57
+ it("close() is idempotent", async () => {
58
+ const sandbox = await LocalSandbox.create();
59
+ await sandbox.close();
60
+ await expect(sandbox.close()).resolves.toBeUndefined();
61
+ });
62
+ it("exec() after close throws", async () => {
63
+ const sandbox = await LocalSandbox.create();
64
+ await sandbox.close();
65
+ await expect(sandbox.exec(["echo", "x"])).rejects.toThrow(/closed/);
66
+ });
67
+ it("endpoint() throws (no port mapping)", async () => {
68
+ const sandbox = await LocalSandbox.create();
69
+ expect(() => sandbox.endpoint(8080)).toThrow(/unsupported/);
70
+ await sandbox.close();
71
+ });
72
+ it("non-zero exit throws with stderr in message", async () => {
73
+ const sandbox = await LocalSandbox.create();
74
+ await expect(sandbox.sh("echo boom 1>&2; exit 7")).rejects.toThrow(/exited 7.*boom/);
75
+ await sandbox.close();
76
+ });
77
+ });
78
+ describe("Sandbox.asTools", () => {
79
+ it("exposes python + sh", async () => {
80
+ const sandbox = await LocalSandbox.create();
81
+ const tools = sandbox.asTools();
82
+ expect(tools.map((t) => t.name)).toEqual(["python", "sh"]);
83
+ await sandbox.close();
84
+ });
85
+ it("python tool dispatches to .python()", async () => {
86
+ const sandbox = await LocalSandbox.create();
87
+ const [python] = sandbox.asTools();
88
+ const out = await python.invoke({ script: "print(40 + 2)" });
89
+ expect(out).toBe("42\n");
90
+ await sandbox.close();
91
+ });
92
+ it("sh tool dispatches to .sh()", async () => {
93
+ const sandbox = await LocalSandbox.create();
94
+ const [, sh] = sandbox.asTools();
95
+ const out = await sh.invoke({ command: "echo wired" });
96
+ expect(out).toBe("wired\n");
97
+ await sandbox.close();
98
+ });
99
+ it("tool definitions carry JSON schema", async () => {
100
+ const sandbox = await LocalSandbox.create();
101
+ const tools = sandbox.asTools();
102
+ for (const tool of tools) {
103
+ expect(tool.parameters).toMatchObject({ type: "object", properties: expect.any(Object) });
104
+ }
105
+ await sandbox.close();
106
+ });
107
+ });
108
+ describe("isSandbox", () => {
109
+ it("recognizes Sandbox instances", async () => {
110
+ const sandbox = await LocalSandbox.create();
111
+ expect(isSandbox(sandbox)).toBe(true);
112
+ expect(sandbox).toBeInstanceOf(Sandbox);
113
+ await sandbox.close();
114
+ });
115
+ it("rejects plain objects", () => {
116
+ expect(isSandbox({ exec: () => { } })).toBe(false);
117
+ expect(isSandbox(null)).toBe(false);
118
+ });
119
+ });
120
+ describe("DockerSandbox default image", () => {
121
+ it("DEFAULT_SANDBOX_IMAGE is a pinned patch + slim variant", () => {
122
+ // Keep this test literal — if the default is bumped, this needs to
123
+ // move in lockstep. Pinning prevents silent supply-chain drift.
124
+ expect(DEFAULT_SANDBOX_IMAGE).toBe("python:3.12.7-slim");
125
+ });
126
+ it("default image passes argv validator (leading alphanumeric)", () => {
127
+ // Guards against accidentally regressing the default to something that
128
+ // begins with `-` and would be mis-parsed as a docker flag.
129
+ const args = DockerSandbox.buildRunArgs({
130
+ image: DEFAULT_SANDBOX_IMAGE,
131
+ cpus: 1,
132
+ memory: "512m",
133
+ allowNetwork: false,
134
+ });
135
+ expect(args.slice(-3)).toEqual([DEFAULT_SANDBOX_IMAGE, "sleep", "infinity"]);
136
+ });
137
+ });
138
+ describe("DockerSandbox.buildRunArgs (security defaults)", () => {
139
+ it("defaults to --network none, --cpus 1, --memory 512m", () => {
140
+ const args = DockerSandbox.buildRunArgs({
141
+ image: "python:3.12",
142
+ cpus: 1,
143
+ memory: "512m",
144
+ allowNetwork: false,
145
+ });
146
+ expect(args).toEqual(expect.arrayContaining(["--network", "none"]));
147
+ expect(args).toEqual(expect.arrayContaining(["--cpus", "1"]));
148
+ expect(args).toEqual(expect.arrayContaining(["--memory", "512m"]));
149
+ // Image + idle process at the tail.
150
+ expect(args.slice(-3)).toEqual(["python:3.12", "sleep", "infinity"]);
151
+ // -d --rm at the head ensures background + auto-remove.
152
+ expect(args.slice(0, 4)).toEqual(["run", "-d", "--rm", "--cpus"]);
153
+ });
154
+ it("allowNetwork: true drops --network none", () => {
155
+ const args = DockerSandbox.buildRunArgs({
156
+ image: "python:3.12",
157
+ cpus: 1,
158
+ memory: "512m",
159
+ allowNetwork: true,
160
+ });
161
+ expect(args).not.toContain("--network");
162
+ });
163
+ it("custom cpus / memory pass through", () => {
164
+ const args = DockerSandbox.buildRunArgs({
165
+ image: "node:20",
166
+ cpus: 2.5,
167
+ memory: "2g",
168
+ allowNetwork: false,
169
+ });
170
+ expect(args).toEqual(expect.arrayContaining(["--cpus", "2.5"]));
171
+ expect(args).toEqual(expect.arrayContaining(["--memory", "2g"]));
172
+ });
173
+ it("port mapping: null = random, number = fixed", () => {
174
+ const args = DockerSandbox.buildRunArgs({
175
+ image: "python:3.12",
176
+ cpus: 1,
177
+ memory: "512m",
178
+ ports: { 8000: null, 9090: 49090 },
179
+ allowNetwork: false,
180
+ });
181
+ expect(args).toEqual(expect.arrayContaining(["-p", "8000"]));
182
+ expect(args).toEqual(expect.arrayContaining(["-p", "49090:9090"]));
183
+ });
184
+ it("mounts default to read-only (writes require explicit readonly: false)", () => {
185
+ const args = DockerSandbox.buildRunArgs({
186
+ image: "python:3.12",
187
+ cpus: 1,
188
+ memory: "512m",
189
+ mounts: [
190
+ { host: "./data", container: "/data" }, // default → ro
191
+ { host: "./conf", container: "/conf", readonly: true }, // explicit → ro
192
+ { host: "./work", container: "/work", readonly: false }, // explicit opt-in → rw
193
+ ],
194
+ allowNetwork: false,
195
+ });
196
+ const dataMount = args.find((a) => a.endsWith(":/data:ro"));
197
+ const confMount = args.find((a) => a.endsWith(":/conf:ro"));
198
+ const workMount = args.find((a) => a.endsWith(":/work:rw"));
199
+ expect(dataMount).toBeDefined();
200
+ expect(confMount).toBeDefined();
201
+ expect(workMount).toBeDefined();
202
+ // Sanity: no `:rw` flag leaked into the default/explicit-ro mounts.
203
+ expect(args.find((a) => a.endsWith(":/data:rw"))).toBeUndefined();
204
+ expect(args.find((a) => a.endsWith(":/conf:rw"))).toBeUndefined();
205
+ });
206
+ it("env vars serialize to KEY=VALUE", () => {
207
+ const args = DockerSandbox.buildRunArgs({
208
+ image: "python:3.12",
209
+ cpus: 1,
210
+ memory: "512m",
211
+ env: { FOO: "bar", NUM: "42" },
212
+ allowNetwork: false,
213
+ });
214
+ expect(args).toEqual(expect.arrayContaining(["-e", "FOO=bar"]));
215
+ expect(args).toEqual(expect.arrayContaining(["-e", "NUM=42"]));
216
+ });
217
+ });
218
+ describe("DockerSandbox mount validation (no docker required)", () => {
219
+ // We point dockerBin at a path that won't exist so create() never reaches
220
+ // the spawn — mount validation throws before docker is invoked.
221
+ const NEVER_EXEC = "/nonexistent/docker-bin-does-not-exist";
222
+ it("rejects mounts outside cwd by default", async () => {
223
+ await expect(DockerSandbox.create({
224
+ dockerBin: NEVER_EXEC,
225
+ mounts: [{ host: "/etc", container: "/etc" }],
226
+ })).rejects.toThrow(/outside cwd/);
227
+ });
228
+ it("allowAnyMount: true skips validation", async () => {
229
+ // With validation off, we expect to fail on the docker spawn instead.
230
+ await expect(DockerSandbox.create({
231
+ dockerBin: NEVER_EXEC,
232
+ mounts: [{ host: "/etc", container: "/etc" }],
233
+ allowAnyMount: true,
234
+ })).rejects.toThrow(/(ENOENT|not.*found|nonexistent)/i);
235
+ });
236
+ it("allows mounts under cwd", async () => {
237
+ await expect(DockerSandbox.create({
238
+ dockerBin: NEVER_EXEC,
239
+ mounts: [{ host: ".", container: "/work" }],
240
+ })).rejects.toThrow(/(ENOENT|not.*found|nonexistent)/i);
241
+ });
242
+ it("rejects symlink inside cwd that escapes to outside cwd", async () => {
243
+ // Place a symlink under cwd that points at /etc. Without realpath resolution
244
+ // the lexical check would let it through; with realpath, it's caught.
245
+ const linkName = `.sandbox-escape-${Date.now()}`;
246
+ const linkPath = path.join(process.cwd(), linkName);
247
+ await fs.symlink("/etc", linkPath);
248
+ try {
249
+ await expect(DockerSandbox.create({
250
+ dockerBin: NEVER_EXEC,
251
+ mounts: [{ host: linkName, container: "/etc" }],
252
+ })).rejects.toThrow(/outside cwd/);
253
+ }
254
+ finally {
255
+ await fs.unlink(linkPath).catch(() => { });
256
+ }
257
+ });
258
+ });
259
+ describe("DockerSandbox argv validators", () => {
260
+ const base = { image: "python:3.12", cpus: 1, memory: "512m", allowNetwork: false };
261
+ it("rejects image names that would be parsed as flags", () => {
262
+ expect(() => DockerSandbox.buildRunArgs({ ...base, image: "--privileged" })).toThrow(/invalid image/);
263
+ expect(() => DockerSandbox.buildRunArgs({ ...base, image: "-rm" })).toThrow(/invalid image/);
264
+ });
265
+ it("rejects image names with shell metacharacters", () => {
266
+ expect(() => DockerSandbox.buildRunArgs({ ...base, image: "python:3.12; rm -rf /" })).toThrow(/invalid image/);
267
+ expect(() => DockerSandbox.buildRunArgs({ ...base, image: "python:3.12 --cap-add=SYS_ADMIN" })).toThrow(/invalid image/);
268
+ });
269
+ it("accepts well-formed image references", () => {
270
+ expect(() => DockerSandbox.buildRunArgs({ ...base, image: "python:3.12" })).not.toThrow();
271
+ expect(() => DockerSandbox.buildRunArgs({
272
+ ...base,
273
+ image: "registry.example.com:5000/org/repo:v1.2.3@sha256:abc",
274
+ })).not.toThrow();
275
+ });
276
+ it("rejects env keys that are not POSIX identifiers", () => {
277
+ expect(() => DockerSandbox.buildRunArgs({ ...base, env: { "bad key": "x" } })).toThrow(/invalid env key/);
278
+ expect(() => DockerSandbox.buildRunArgs({ ...base, env: { "1LEADING": "x" } })).toThrow(/invalid env key/);
279
+ expect(() => DockerSandbox.buildRunArgs({ ...base, env: { "K=Y": "x" } })).toThrow(/invalid env key/);
280
+ });
281
+ it("rejects env values containing newline or NUL", () => {
282
+ expect(() => DockerSandbox.buildRunArgs({ ...base, env: { FOO: "bar\nBAZ=malicious" } })).toThrow(/newline or NUL/);
283
+ expect(() => DockerSandbox.buildRunArgs({ ...base, env: { FOO: "bar\x00baz" } })).toThrow(/newline or NUL/);
284
+ });
285
+ });
286
+ // ── DockerSandbox via fake docker shim ──────────────────────
287
+ // Exercises the create -> exec -> close lifecycle using a tiny shell shim
288
+ // that mimics just enough of the docker CLI to keep DockerSandbox honest.
289
+ describe("DockerSandbox lifecycle (via fake docker shim)", () => {
290
+ let shimDir = "";
291
+ let shimBin = "";
292
+ beforeAll(async () => {
293
+ shimDir = await fs.mkdtemp(path.join(tmpdir(), "docker-shim-"));
294
+ shimBin = path.join(shimDir, "docker");
295
+ // The shim:
296
+ // `run ...` -> echo a fake container id
297
+ // `port id N` -> echo "0.0.0.0:5<N padded>"
298
+ // `inspect ...` -> echo "true"
299
+ // `exec id sh -c CMD` -> exec sh -c CMD
300
+ // `cp SRC DST` -> cp SRC DST
301
+ // `rm -f id` -> exit 0
302
+ const script = `#!/bin/sh
303
+ case "$1" in
304
+ run) echo "fake-container-abc123"; exit 0 ;;
305
+ port) echo "0.0.0.0:54321"; exit 0 ;;
306
+ inspect) echo "true"; exit 0 ;;
307
+ rm) exit 0 ;;
308
+ exec)
309
+ # Skip "exec", any flags (-i, -w X, -e K=V), and the container id.
310
+ shift
311
+ while [ "$1" = "-i" ] || [ "$1" = "-w" ] || [ "$1" = "-e" ]; do
312
+ if [ "$1" = "-w" ] || [ "$1" = "-e" ]; then shift; fi
313
+ shift
314
+ done
315
+ shift # container id
316
+ exec "$@"
317
+ ;;
318
+ cp)
319
+ shift
320
+ src=\${1#*:}
321
+ dst=\${2#*:}
322
+ cp "$src" "$dst"
323
+ ;;
324
+ *) echo "shim: unknown $1" 1>&2; exit 2 ;;
325
+ esac
326
+ `;
327
+ await fs.writeFile(shimBin, script, { mode: 0o755 });
328
+ });
329
+ afterAll(async () => {
330
+ if (shimDir)
331
+ await fs.rm(shimDir, { recursive: true, force: true });
332
+ });
333
+ it("create + sh + close round-trip", async () => {
334
+ const sandbox = await DockerSandbox.create({ dockerBin: shimBin });
335
+ expect(sandbox.containerId).toBe("fake-container-abc123");
336
+ const out = await sandbox.sh("echo via-fake-docker");
337
+ expect(out).toBe("via-fake-docker\n");
338
+ await sandbox.close();
339
+ });
340
+ it("close() is idempotent", async () => {
341
+ const sandbox = await DockerSandbox.create({ dockerBin: shimBin });
342
+ await sandbox.close();
343
+ await expect(sandbox.close()).resolves.toBeUndefined();
344
+ });
345
+ it("exec after close throws", async () => {
346
+ const sandbox = await DockerSandbox.create({ dockerBin: shimBin });
347
+ await sandbox.close();
348
+ await expect(sandbox.exec(["echo", "x"])).rejects.toThrow(/closed/);
349
+ });
350
+ it("endpoint() returns localhost:port for mapped ports", async () => {
351
+ const sandbox = await DockerSandbox.create({
352
+ dockerBin: shimBin,
353
+ ports: { 8000: null },
354
+ });
355
+ expect(sandbox.endpoint(8000)).toBe("localhost:54321");
356
+ await sandbox.close();
357
+ });
358
+ it("endpoint() throws for unmapped ports", async () => {
359
+ const sandbox = await DockerSandbox.create({ dockerBin: shimBin });
360
+ expect(() => sandbox.endpoint(9999)).toThrow(/not mapped/);
361
+ await sandbox.close();
362
+ });
363
+ it("connect() reattaches to a running container", async () => {
364
+ const sandbox = await DockerSandbox.connect("fake-existing-id", { dockerBin: shimBin });
365
+ expect(sandbox.containerId).toBe("fake-existing-id");
366
+ await sandbox.close();
367
+ });
368
+ });
369
+ describe("LocalSandbox dev-only warning", () => {
370
+ // Vitest sets NODE_ENV=test by default, which silences the warning. These
371
+ // tests flip it to exercise both branches, then restore.
372
+ const originalNodeEnv = process.env.NODE_ENV;
373
+ const originalSilence = process.env.NOOKPLOT_LOCAL_SANDBOX_SILENCE;
374
+ let warnSpy;
375
+ beforeEach(() => {
376
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
377
+ });
378
+ afterEach(async () => {
379
+ warnSpy.mockRestore();
380
+ if (originalNodeEnv === undefined)
381
+ delete process.env.NODE_ENV;
382
+ else
383
+ process.env.NODE_ENV = originalNodeEnv;
384
+ if (originalSilence === undefined)
385
+ delete process.env.NOOKPLOT_LOCAL_SANDBOX_SILENCE;
386
+ else
387
+ process.env.NOOKPLOT_LOCAL_SANDBOX_SILENCE = originalSilence;
388
+ });
389
+ it("does NOT warn under NODE_ENV=test", async () => {
390
+ process.env.NODE_ENV = "test";
391
+ const sandbox = await LocalSandbox.create();
392
+ expect(warnSpy).not.toHaveBeenCalled();
393
+ await sandbox.close();
394
+ });
395
+ it("warns when NODE_ENV is not test (e.g. production / undefined)", async () => {
396
+ process.env.NODE_ENV = "production";
397
+ delete process.env.NOOKPLOT_LOCAL_SANDBOX_SILENCE;
398
+ const sandbox = await LocalSandbox.create();
399
+ expect(warnSpy).toHaveBeenCalledTimes(1);
400
+ const msg = String(warnSpy.mock.calls[0]?.[0] ?? "");
401
+ expect(msg).toContain("LocalSandbox");
402
+ expect(msg).toContain("DockerSandbox");
403
+ await sandbox.close();
404
+ });
405
+ it("silence env var suppresses the warning even outside test", async () => {
406
+ process.env.NODE_ENV = "production";
407
+ process.env.NOOKPLOT_LOCAL_SANDBOX_SILENCE = "1";
408
+ const sandbox = await LocalSandbox.create();
409
+ expect(warnSpy).not.toHaveBeenCalled();
410
+ await sandbox.close();
411
+ });
412
+ });
413
+ //# sourceMappingURL=sandbox.test.js.map