@crewhaus/sandbox-image-r 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +43 -0
- package/src/index.test.ts +165 -0
- package/src/index.ts +41 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/sandbox-image-r",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "R 4.x polyglot sandbox image: registry registration + Dockerfile + T2/T7/T8 contract tests (Section 36)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0",
|
|
16
|
+
"@crewhaus/sandbox": "0.0.0",
|
|
17
|
+
"@crewhaus/sandbox-image-registry": "0.0.0"
|
|
18
|
+
},
|
|
19
|
+
"license": "Apache-2.0",
|
|
20
|
+
"author": {
|
|
21
|
+
"name": "Max Meier",
|
|
22
|
+
"email": "max@studiomax.io",
|
|
23
|
+
"url": "https://studiomax.io"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
28
|
+
"directory": "packages/sandbox-image-r"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/sandbox-image-r#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "restricted"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"NOTICE"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSandbox } from "@crewhaus/sandbox";
|
|
3
|
+
import {
|
|
4
|
+
ImageRegistrationError,
|
|
5
|
+
_resetSandboxImageRegistry,
|
|
6
|
+
hasSandboxImage,
|
|
7
|
+
listAllowedImageRefs,
|
|
8
|
+
lookupSandboxImage,
|
|
9
|
+
registerSandboxImage,
|
|
10
|
+
} from "@crewhaus/sandbox-image-registry";
|
|
11
|
+
import {
|
|
12
|
+
R_COLD_START_BUDGET_MS,
|
|
13
|
+
R_DEFAULT_ENTRYPOINT,
|
|
14
|
+
R_HEALTHCHECK_ARGV,
|
|
15
|
+
R_IMAGE_ID,
|
|
16
|
+
R_IMAGE_REF,
|
|
17
|
+
registerRSandboxImage,
|
|
18
|
+
} from "./index";
|
|
19
|
+
|
|
20
|
+
describe("registerRSandboxImage (T1 + T2)", () => {
|
|
21
|
+
beforeEach(() => _resetSandboxImageRegistry());
|
|
22
|
+
afterEach(() => _resetSandboxImageRegistry());
|
|
23
|
+
|
|
24
|
+
test("constants match the kickoff prompt's spec", () => {
|
|
25
|
+
expect(R_IMAGE_ID).toBe("r");
|
|
26
|
+
expect(R_IMAGE_REF).toBe("rocker/r-base:4.4");
|
|
27
|
+
expect(R_DEFAULT_ENTRYPOINT).toEqual(["Rscript", "-e"]);
|
|
28
|
+
expect(R_HEALTHCHECK_ARGV).toEqual(["Rscript", "-e", "cat(R.version.string)"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("registerRSandboxImage() registers an image with the right shape", () => {
|
|
32
|
+
const entry = registerRSandboxImage();
|
|
33
|
+
expect(entry.id).toBe("r");
|
|
34
|
+
expect(entry.image).toBe("rocker/r-base:4.4");
|
|
35
|
+
expect(entry.defaultEntrypoint).toEqual(["Rscript", "-e"]);
|
|
36
|
+
expect(entry.healthcheck.command).toEqual(["Rscript", "-e", "cat(R.version.string)"]);
|
|
37
|
+
expect(entry.healthcheck.expectedExitCode).toBe(0);
|
|
38
|
+
expect(entry.healthcheck.timeoutMs).toBe(R_COLD_START_BUDGET_MS);
|
|
39
|
+
expect(entry.description).toMatch(/R 4/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("lookupSandboxImage('r') returns the registered entry", () => {
|
|
43
|
+
registerRSandboxImage();
|
|
44
|
+
expect(hasSandboxImage("r")).toBe(true);
|
|
45
|
+
expect(lookupSandboxImage("r").image).toBe("rocker/r-base:4.4");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("listAllowedImageRefs includes rocker/r-base:4.4", () => {
|
|
49
|
+
registerRSandboxImage();
|
|
50
|
+
const refs = listAllowedImageRefs();
|
|
51
|
+
expect(refs).toContain("rocker/r-base:4.4");
|
|
52
|
+
expect(refs).toContain("python:3.13-slim");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("R-shape T2 contract — round-trip via noop sandbox", () => {
|
|
57
|
+
beforeEach(() => _resetSandboxImageRegistry());
|
|
58
|
+
afterEach(() => _resetSandboxImageRegistry());
|
|
59
|
+
|
|
60
|
+
test("noop sandbox accepts rocker/r-base:4.4 when registered", async () => {
|
|
61
|
+
registerRSandboxImage();
|
|
62
|
+
const sandbox = createSandbox({
|
|
63
|
+
backend: "noop",
|
|
64
|
+
allowedImages: listAllowedImageRefs(),
|
|
65
|
+
});
|
|
66
|
+
const result = await sandbox.exec({
|
|
67
|
+
image: R_IMAGE_REF,
|
|
68
|
+
argv: ["printf", "hello-from-r"],
|
|
69
|
+
});
|
|
70
|
+
expect(result.exitCode).toBe(0);
|
|
71
|
+
expect(result.stdout).toBe("hello-from-r");
|
|
72
|
+
await sandbox.close();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("noop sandbox refuses rocker/r-base:4.4 when NOT registered", async () => {
|
|
76
|
+
const trioRefs = ["python:3.13-slim", "node:22-alpine", "alpine:3.19"];
|
|
77
|
+
const sandbox = createSandbox({ backend: "noop", allowedImages: trioRefs });
|
|
78
|
+
await expect(sandbox.exec({ image: R_IMAGE_REF, argv: ["printf", "x"] })).rejects.toThrow(
|
|
79
|
+
/not on the allowlist/,
|
|
80
|
+
);
|
|
81
|
+
await sandbox.close();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("R-shape T7 — cold-start budget", () => {
|
|
86
|
+
beforeEach(() => _resetSandboxImageRegistry());
|
|
87
|
+
afterEach(() => _resetSandboxImageRegistry());
|
|
88
|
+
|
|
89
|
+
test("healthcheck timeoutMs is within the compiled-language budget (≤2s)", () => {
|
|
90
|
+
const entry = registerRSandboxImage();
|
|
91
|
+
expect(entry.healthcheck.timeoutMs).toBeLessThanOrEqual(2_000);
|
|
92
|
+
expect(entry.healthcheck.timeoutMs).toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("R-shape T8 — escape-attempt suite (reuses §18 corpus shape)", () => {
|
|
97
|
+
beforeEach(() => _resetSandboxImageRegistry());
|
|
98
|
+
afterEach(() => _resetSandboxImageRegistry());
|
|
99
|
+
|
|
100
|
+
test("R image registration is idempotent-then-rejected", () => {
|
|
101
|
+
registerRSandboxImage();
|
|
102
|
+
expect(() => registerRSandboxImage()).toThrow(/already registered/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("R entry's image string passes registry validation", () => {
|
|
106
|
+
expect(R_IMAGE_REF.startsWith("-")).toBe(false);
|
|
107
|
+
expect(/\s/.test(R_IMAGE_REF)).toBe(false);
|
|
108
|
+
expect(R_IMAGE_REF.includes("\n")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("R defaultEntrypoint contains no shell-meta", () => {
|
|
112
|
+
for (const arg of R_DEFAULT_ENTRYPOINT) {
|
|
113
|
+
expect(/[;&|<>$`(){}]/.test(arg)).toBe(false);
|
|
114
|
+
expect(arg.includes("\n")).toBe(false);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("R healthcheck argv R.version.string call is well-formed", () => {
|
|
119
|
+
// R healthcheck is `Rscript -e cat(R.version.string)` — the 3rd
|
|
120
|
+
// argv element legitimately contains parens, but it's passed as
|
|
121
|
+
// a single non-shell argv to Bun.spawn so shell-meta is moot. We
|
|
122
|
+
// assert the first two argv elements are shell-meta-free, and the
|
|
123
|
+
// third has no embedded newlines.
|
|
124
|
+
expect(R_HEALTHCHECK_ARGV[0]).toBe("Rscript");
|
|
125
|
+
expect(R_HEALTHCHECK_ARGV[1]).toBe("-e");
|
|
126
|
+
expect(/[;&|<>$`{}]/.test(R_HEALTHCHECK_ARGV[0] ?? "")).toBe(false);
|
|
127
|
+
expect(/[;&|<>$`{}]/.test(R_HEALTHCHECK_ARGV[1] ?? "")).toBe(false);
|
|
128
|
+
for (const arg of R_HEALTHCHECK_ARGV) {
|
|
129
|
+
expect(arg.includes("\n")).toBe(false);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("CLI-flag-injection registration via R id is refused", () => {
|
|
134
|
+
expect(() =>
|
|
135
|
+
registerSandboxImage({
|
|
136
|
+
id: "r",
|
|
137
|
+
image: "--privileged",
|
|
138
|
+
defaultEntrypoint: ["Rscript", "-e"],
|
|
139
|
+
healthcheck: { command: ["Rscript", "--version"], expectedExitCode: 0 },
|
|
140
|
+
}),
|
|
141
|
+
).toThrow(ImageRegistrationError);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("whitespace-tampered R image is refused", () => {
|
|
145
|
+
expect(() =>
|
|
146
|
+
registerSandboxImage({
|
|
147
|
+
id: "r",
|
|
148
|
+
image: "rocker/r-base:4.4 --privileged",
|
|
149
|
+
defaultEntrypoint: ["Rscript", "-e"],
|
|
150
|
+
healthcheck: { command: ["Rscript", "--version"], expectedExitCode: 0 },
|
|
151
|
+
}),
|
|
152
|
+
).toThrow(/whitespace/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("shell-meta-tagged R image is refused", () => {
|
|
156
|
+
expect(() =>
|
|
157
|
+
registerSandboxImage({
|
|
158
|
+
id: "r",
|
|
159
|
+
image: "rocker/r-base:$(id)",
|
|
160
|
+
defaultEntrypoint: ["Rscript", "-e"],
|
|
161
|
+
healthcheck: { command: ["Rscript", "--version"], expectedExitCode: 0 },
|
|
162
|
+
}),
|
|
163
|
+
).toThrow(/valid registry/);
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type SandboxImageEntry, registerSandboxImage } from "@crewhaus/sandbox-image-registry";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Catalog R8 `sandbox-image-r` — Section 36 polyglot R sandbox image.
|
|
5
|
+
*
|
|
6
|
+
* Snippet mode (default entrypoint): `Rscript -e <code>` reads the
|
|
7
|
+
* snippet from the next argv slot. Analyst-style packages (tidyverse,
|
|
8
|
+
* data.table) are baked in so common stats workloads run without
|
|
9
|
+
* needing a network-aware package install (the sandbox enforces
|
|
10
|
+
* `--network=none`).
|
|
11
|
+
*
|
|
12
|
+
* Cold-start budget: ≤2s for R 4.x — `Rscript -e cat(R.version.string)`
|
|
13
|
+
* is around 600ms warm because R has to bootstrap its base packages
|
|
14
|
+
* even for a one-line snippet. The kickoff prompt's compiled-language
|
|
15
|
+
* 2s budget covers this; we do not use the tighter shell-shape budget.
|
|
16
|
+
*
|
|
17
|
+
* Layer R8.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const R_IMAGE_ID = "r";
|
|
21
|
+
export const R_IMAGE_REF = "rocker/r-base:4.4";
|
|
22
|
+
export const R_DEFAULT_ENTRYPOINT: ReadonlyArray<string> = ["Rscript", "-e"];
|
|
23
|
+
export const R_HEALTHCHECK_ARGV: ReadonlyArray<string> = ["Rscript", "-e", "cat(R.version.string)"];
|
|
24
|
+
|
|
25
|
+
/** Cold-start budget for the warm pool (ms). T7 layer asserts this. */
|
|
26
|
+
export const R_COLD_START_BUDGET_MS = 2_000;
|
|
27
|
+
|
|
28
|
+
export function registerRSandboxImage(): SandboxImageEntry {
|
|
29
|
+
return registerSandboxImage({
|
|
30
|
+
id: R_IMAGE_ID,
|
|
31
|
+
image: R_IMAGE_REF,
|
|
32
|
+
defaultEntrypoint: R_DEFAULT_ENTRYPOINT,
|
|
33
|
+
healthcheck: {
|
|
34
|
+
command: R_HEALTHCHECK_ARGV,
|
|
35
|
+
expectedExitCode: 0,
|
|
36
|
+
timeoutMs: R_COLD_START_BUDGET_MS,
|
|
37
|
+
},
|
|
38
|
+
description:
|
|
39
|
+
"R 4.x (rocker/r-base) — snippet mode via `Rscript -e`; tidyverse + data.table preinstalled for analyst workloads.",
|
|
40
|
+
});
|
|
41
|
+
}
|