Sitelet https://github.com/NodeOps-app/createos-sandbox-sdk/commit/6a2b941d31770bcc3edeccad5e3f13debcddc2d5
Skip to content

Commit 6a2b941

Browse files
authored
fix(examples): unbreak the 21 failing examples in the nightly matrix (#47)
* fix(examples): unbreak CI-only example failures Nightly cron run 33487941064 was green while 21 of 51 examples failed (failures are advisory). Fixes the ones that are ours: - 49/50/51: `.env.ant` is gitignored and never exists in CI, so the loader threw ENOENT before doing anything. Read credentials from the process env first (under ANTHROPIC_ORG_API_KEY, a distinct name so the shared gateway's ANTHROPIC_API_KEY can never be mistaken for the organization key), and treat the dotenv as an optional fallback. - 41: MuPDF writes warnings to stdout, so `JSON.parse(stdout)` failed. The extractor now writes JSON to a file the host downloads. - 09: `npm install ... 2>&1` merged stderr into stdout, so the throw reported an empty stderr. Drop the redirect, report both streams. - 52: `resume()` returns before the sandbox has an IP; `files.download` then 503s with "sandbox has no ip". Wait for running + an IP. - workflows: wire ANCHOR_API_KEY / FIRECRAWL_API_KEY and the Managed Agents credentials into the job env, and stop passing S3_USE_PATH_STYLE as a secret — its value ("1") made GitHub mask every "1" in every log line. * fix(examples): unbreak 09 and 13 in CI - 09: the devbox rootfs already ships the claude CLI, so `npm install -g` died with EEXIST (visible only after the previous commit stopped swallowing npm's stderr). Install only when the binary is absent. - 13: when the pause/resume demo timed out, the recovery path swallowed both the resume and the wait failure and queried a still-paused sandbox, surfacing as an opaque 409 "sandbox is paused; resume first". Retry resume until running, then fail with a clear message. * fix(examples): unbreak 09 and 55 in CI - 09: the rootfs ships `claude` outside /usr/local/bin, so the previous `command -v` guard skipped the install and the non-root task — which only sees /usr/local/bin — died with exit 127. Guard on the path. - 55: the desktop stack boots after the sandbox, so the first `computer.screen` call 409s with `desktop_unavailable`. Retry until it answers, bounded at 120s. * fix(examples): give 09's non-root user a reachable node The claude shim resolves node through `env`, but the rootfs keeps node under /root (mode 700), so dropping to sandboxuser produced a bare exit 127. Copy node onto /usr/local/bin, and stop merging stderr into stdout so a future failure reports something. * fix(examples): run 09's task through the real claude binary `claude` on PATH is an asdf shim that re-execs `asdf`, and the asdf tree lives under /root (mode 700) — so once the example drops to a non-root user (required by --dangerously-skip-permissions) the shim died with a bare 127. Open /root for traversal only and link the real binary.
1 parent 0f2dafb commit 6a2b941

13 files changed

Lines changed: 182 additions & 35 deletions

File tree

‎.github/workflows/example-run.yml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,22 @@ jobs:
5151
ANTHROPIC_AUTH_TOKEN: ${{ secrets.ANTHROPIC_AUTH_TOKEN }}
5252
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
5353
ANTHROPIC_MODEL: ${{ secrets.ANTHROPIC_MODEL }}
54+
# Managed Agents (examples 49-51) talk to api.anthropic.com directly, not
55+
# the gateway above — hence the separate org key and environment creds.
56+
ANTHROPIC_ORG_API_KEY: ${{ secrets.ANTHROPIC_ORG_API_KEY }}
57+
ANTHROPIC_ENVIRONMENT_ID: ${{ secrets.ANTHROPIC_ENVIRONMENT_ID }}
58+
ANTHROPIC_ENVIRONMENT_KEY: ${{ secrets.ANTHROPIC_ENVIRONMENT_KEY }}
59+
# Optional third-party example keys (16, 53) — examples degrade if unset.
60+
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
61+
ANCHOR_API_KEY: ${{ secrets.ANCHOR_API_KEY }}
5462
# S3-compatible bucket (examples 26, 38, 48)
5563
S3_BUCKET: ${{ secrets.S3_BUCKET }}
5664
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
5765
S3_REGION: ${{ secrets.S3_REGION }}
5866
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
5967
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
60-
S3_USE_PATH_STYLE: ${{ secrets.S3_USE_PATH_STYLE }}
68+
# Not a secret: as a secret its value ("1") masked every "1" in the logs.
69+
S3_USE_PATH_STYLE: "true"
6170
steps:
6271
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
6372

‎.github/workflows/examples.yml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,22 @@ jobs:
122122
ANTHROPIC_AUTH_TOKEN: ${{ secrets.ANTHROPIC_AUTH_TOKEN }}
123123
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
124124
ANTHROPIC_MODEL: ${{ secrets.ANTHROPIC_MODEL }}
125+
# Managed Agents (examples 49-51) talk to api.anthropic.com directly, not
126+
# the gateway above — hence the separate org key and environment creds.
127+
ANTHROPIC_ORG_API_KEY: ${{ secrets.ANTHROPIC_ORG_API_KEY }}
128+
ANTHROPIC_ENVIRONMENT_ID: ${{ secrets.ANTHROPIC_ENVIRONMENT_ID }}
129+
ANTHROPIC_ENVIRONMENT_KEY: ${{ secrets.ANTHROPIC_ENVIRONMENT_KEY }}
130+
# Optional third-party example keys (16, 53) — examples degrade if unset.
131+
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
132+
ANCHOR_API_KEY: ${{ secrets.ANCHOR_API_KEY }}
125133
# S3-compatible bucket (example 38)
126134
S3_BUCKET: ${{ secrets.S3_BUCKET }}
127135
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
128136
S3_REGION: ${{ secrets.S3_REGION }}
129137
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
130138
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
131-
S3_USE_PATH_STYLE: ${{ secrets.S3_USE_PATH_STYLE }}
139+
# Not a secret: as a secret its value ("1") masked every "1" in the logs.
140+
S3_USE_PATH_STYLE: "true"
132141
steps:
133142
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
134143

‎examples/09-mcp-claude-code/index.ts‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,43 @@ try {
5353
console.log("[2/4] installing @anthropic-ai/claude-code...");
5454
const install = await sandbox.runCommand(
5555
"sh",
56-
["-lc", "npm install -g @anthropic-ai/claude-code --prefix /usr/local 2>&1"],
56+
[
57+
"-lc",
58+
// The rootfs may already ship the CLI at this exact path; installing
59+
// over it fails EEXIST. Anything elsewhere on PATH is not visible to the
60+
// non-root user that runs the task below, so only this path counts.
61+
"[ -x /usr/local/bin/claude ] || npm install -g @anthropic-ai/claude-code --prefix /usr/local",
62+
],
5763
{ timeoutMs: 300_000 },
5864
);
5965
if (install.result.exit_code !== 0) {
60-
throw new Error(`npm install failed:\n${install.result.stderr}`);
66+
// npm splits its diagnostics across both streams; report each one.
67+
throw new Error(
68+
`npm install failed (exit ${install.result.exit_code}):\n` +
69+
`stdout:\n${install.result.stdout}\nstderr:\n${install.result.stderr}`,
70+
);
6171
}
6272
const ver = await sandbox.runCommand("/usr/local/bin/claude", ["--version"]);
6373
console.log(` ${ver.result.stdout.trim()}`);
6474

6575
// Claude Code blocks --dangerously-skip-permissions when the process runs
6676
// as root. Create a non-root user and su to it for the coding task.
6777
console.log("[3/4] creating non-root user...");
68-
await sandbox.runCommand("sh", ["-c", "useradd -m -s /bin/bash sandboxuser 2>/dev/null || true"]);
78+
// The CLI on PATH is an asdf shim that re-execs `asdf`, and the whole asdf
79+
// tree lives under /root (mode 700) — neither is reachable once we drop to a
80+
// non-root user, so the shim dies with a bare 127. Open /root for traversal
81+
// only (+x, not +r) and link the real binary onto the shared path.
82+
await sandbox.runCommand("sh", [
83+
"-c",
84+
[
85+
"useradd -m -s /bin/bash sandboxuser 2>/dev/null || true",
86+
"chmod o+x /root",
87+
"real=$(ls /usr/local/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe" +
88+
" /root/.asdf/installs/nodejs/*/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe" +
89+
" 2>/dev/null | head -1)",
90+
'[ -n "$real" ] && ln -sf "$real" /usr/local/bin/claude-real',
91+
].join(" && "),
92+
]);
6993

7094
console.log("[4/4] running coding task inside sandbox...");
7195
console.log(` prompt: "${TASK.slice(0, 80)}..."\n`);
@@ -84,7 +108,7 @@ try {
84108
[
85109
"export HOME=/home/sandboxuser",
86110
"export PATH=/usr/local/bin:$PATH",
87-
`echo ${JSON.stringify(TASK)} | claude -p --dangerously-skip-permissions --model ${JSON.stringify(anthropicModel)} 2>&1`,
111+
`echo ${JSON.stringify(TASK)} | claude-real -p --dangerously-skip-permissions --model ${JSON.stringify(anthropicModel)}`,
88112
].join(" && "),
89113
"sandboxuser",
90114
],
@@ -98,7 +122,9 @@ try {
98122
}
99123

100124
if (result.exit_code !== 0) {
101-
throw new Error(`claude exited ${result.exit_code}:\n${result.stderr}`);
125+
throw new Error(
126+
`claude exited ${result.exit_code}:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
127+
);
102128
}
103129
process.stdout.write(result.stdout);
104130
console.log("\n[done]");

‎examples/13-llamaindex-rag/index.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -217,17 +217,19 @@ try {
217217
console.warn(
218218
` ⚠ snapshot did not settle in time (${msg}); ensuring running and continuing`,
219219
);
220-
// The box may be paused (resume timed out) or mid-transition; nudge it back
221-
// to running. Both calls are best-effort — never fail the run over the demo.
222-
try {
223-
await sandbox.resume();
224-
} catch {
225-
// already running, or resume raced with a late state change — ignore
220+
// The box may be paused (resume timed out) or mid-transition. Keep nudging
221+
// it back to running: querying a paused box only yields an opaque 409.
222+
const resumeDeadline = Date.now() + 300_000;
223+
while (Date.now() < resumeDeadline) {
224+
await sandbox.refresh().catch(() => {});
225+
if (sandbox.status === "running") break;
226+
if (sandbox.status === "paused") await sandbox.resume().catch(() => {});
227+
await new Promise((resolve) => setTimeout(resolve, 5000));
226228
}
227-
try {
228-
await sandbox.waitUntilRunning({ timeoutMs: 120_000 });
229-
} catch {
230-
// platform still slow; proceed — a truly-down box surfaces at the query
229+
if (sandbox.status !== "running") {
230+
throw new Error(`sandbox stuck in ${sandbox.status} after the pause/resume demo`, {
231+
cause: err,
232+
});
231233
}
232234
console.log(` continuing (status=${sandbox.status})`);
233235
}

‎examples/41-python-pdf-extractor/index.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ const ROOTFS = "devbox:1";
1616

1717
// Python script executed inside the sandbox.
1818
// Iterates every page's widgets, collects {name, value, type} tuples,
19-
// writes JSON to stdout which is captured by runCommand.
19+
// writes JSON to a file: MuPDF prints its own warnings to stdout, so stdout is
20+
// not a clean JSON channel.
2021
const EXTRACTOR_PY = `\
2122
import fitz
2223
import json
2324
import sys
2425
2526
path = sys.argv[1]
27+
out = sys.argv[2]
2628
doc = fitz.open(path)
2729
fields = []
2830
for page in doc:
@@ -33,7 +35,8 @@ for page in doc:
3335
"type": widget.field_type_string,
3436
})
3537
doc.close()
36-
print(json.dumps(fields, ensure_ascii=False))
38+
with open(out, "w", encoding="utf-8") as fh:
39+
json.dump(fields, fh, ensure_ascii=False)
3740
`;
3841

3942
// bridge FCSPAWN_URL -> baseUrl when set; fall back to CREATEOS_SANDBOX_BASE_URL
@@ -71,14 +74,18 @@ try {
7174

7275
// Run the extractor
7376
console.log("[5/6] running extractor ...");
74-
const run = await sandbox.runCommand("python3", ["/tmp/extract.py", "/tmp/form.pdf"]);
77+
const run = await sandbox.runCommand("python3", [
78+
"/tmp/extract.py",
79+
"/tmp/form.pdf",
80+
"/tmp/fields.json",
81+
]);
7582
if (run.result.exit_code !== 0) {
7683
throw new Error(`extractor failed:\n${run.result.stderr}`);
7784
}
7885

79-
// Parse stdout as JSON and pretty-print
86+
// Read the JSON the extractor wrote and pretty-print
8087
const fields: Array<{ name: string; value: string; type: string }> = JSON.parse(
81-
run.result.stdout,
88+
new TextDecoder().decode(await sandbox.files.download("/tmp/fields.json")),
8289
);
8390
console.log(`[6/6] extracted ${fields.length} field(s):`);
8491
for (const f of fields) {

‎examples/49-egress-locked-agent-worker/.env.example‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ CREATEOS_SANDBOX_API_KEY=your-createos-sandbox-api-key
1111
# ANTHROPIC_API_KEY=sk-ant-... # org key with Managed Agents beta
1212
# ANTHROPIC_ENVIRONMENT_ID=env_... # a self_hosted environment
1313
# ANTHROPIC_ENVIRONMENT_KEY=sk-ant-oat01-... # Console > Environments > Generate environment key
14+
15+
# CI (and any env-only setup) reads these instead of .env.ant; env wins over the
16+
# file. ANTHROPIC_ORG_API_KEY is deliberately a distinct name so the gateway's
17+
# ANTHROPIC_API_KEY is never mistaken for the organization key.
18+
# ANTHROPIC_ORG_API_KEY=sk-ant-...
19+
# ANTHROPIC_ENVIRONMENT_ID=env_...
20+
# ANTHROPIC_ENVIRONMENT_KEY=sk-ant-oat01-...

‎examples/49-egress-locked-agent-worker/index.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,27 @@ const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
3838
const ANTHROPIC_HOST = "api.anthropic.com";
3939

4040
function loadAnt(): { apiKey: string; environmentId: string; environmentKey: string } {
41+
// Process env wins over `.env.ant`, and `.env.ant` is optional: CI carries the
42+
// credentials as secrets with no dotenv on disk. The org key is read under a
43+
// distinct name (ANTHROPIC_ORG_API_KEY) so the shared gateway's
44+
// ANTHROPIC_API_KEY — wrong endpoint and auth scheme for Managed Agents —
45+
// can never be picked up by accident.
46+
const fromEnv: Record<string, string> = {
47+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_ORG_API_KEY ?? "",
48+
ANTHROPIC_ENVIRONMENT_ID: process.env.ANTHROPIC_ENVIRONMENT_ID ?? "",
49+
ANTHROPIC_ENVIRONMENT_KEY: process.env.ANTHROPIC_ENVIRONMENT_KEY ?? "",
50+
};
4151
for (const k of ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]) {
4252
delete process.env[k];
4353
}
54+
let dotenv = "";
55+
try {
56+
dotenv = readFileSync(new URL("./.env.ant", import.meta.url), "utf8");
57+
} catch {
58+
dotenv = "";
59+
}
4460
const env: Record<string, string> = {};
45-
for (const line of readFileSync(new URL("./.env.ant", import.meta.url), "utf8").split("\n")) {
61+
for (const line of dotenv.split("\n")) {
4662
const s = line.trim();
4763
if (!s || s.startsWith("#")) continue;
4864
const eq = s.indexOf("=");
@@ -57,13 +73,18 @@ function loadAnt(): { apiKey: string; environmentId: string; environmentKey: str
5773
.trim()
5874
.replace(/^["']|["']$/g, "");
5975
}
60-
const apiKey = env.ANTHROPIC_API_KEY ?? "";
61-
const environmentId = env.ANTHROPIC_ENVIRONMENT_ID ?? "";
62-
const environmentKey = env.ANTHROPIC_ENVIRONMENT_KEY ?? "";
63-
if (!apiKey) throw new Error("ANTHROPIC_API_KEY missing in .env.ant (organization key)");
76+
const pick = (k: string) => fromEnv[k] || (env[k] ?? "");
77+
const apiKey = pick("ANTHROPIC_API_KEY");
78+
const environmentId = pick("ANTHROPIC_ENVIRONMENT_ID");
79+
const environmentKey = pick("ANTHROPIC_ENVIRONMENT_KEY");
80+
if (!apiKey) {
81+
throw new Error(
82+
"organization key missing: set ANTHROPIC_ORG_API_KEY, or ANTHROPIC_API_KEY in .env.ant",
83+
);
84+
}
6485
if (!environmentId || !environmentKey) {
6586
throw new Error(
66-
"ANTHROPIC_ENVIRONMENT_ID / ANTHROPIC_ENVIRONMENT_KEY missing in .env.ant.\n" +
87+
"ANTHROPIC_ENVIRONMENT_ID / ANTHROPIC_ENVIRONMENT_KEY missing (env or .env.ant).\n" +
6788
"Generate one in the Console: Workspace > Environments > your self-hosted env > Generate environment key.",
6889
);
6990
}

‎examples/50-cloud-agent-sandbox-tool/.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ CREATEOS_SANDBOX_API_KEY=your-createos-sandbox-api-key
1313
# That is the only Managed Agents credential this example needs. The environment
1414
# key used by examples 36/37/49 authenticates a *self-hosted* worker polling
1515
# Anthropic's work queue; a cloud environment has no worker, so no such key.
16+
17+
# CI (and any env-only setup) reads these instead of .env.ant; env wins over the
18+
# file. ANTHROPIC_ORG_API_KEY is deliberately a distinct name so the gateway's
19+
# ANTHROPIC_API_KEY is never mistaken for the organization key.
20+
# ANTHROPIC_ORG_API_KEY=sk-ant-...

‎examples/50-cloud-agent-sandbox-tool/index.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,25 @@ import { Sandbox } from "createos-sandbox-sdk";
4040
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
4141

4242
function loadAntKey(): string {
43+
// Process env wins over `.env.ant`, and `.env.ant` is optional: CI carries the
44+
// credentials as secrets with no dotenv on disk. The org key is read under a
45+
// distinct name (ANTHROPIC_ORG_API_KEY) so the shared gateway's
46+
// ANTHROPIC_API_KEY — wrong endpoint and auth scheme for Managed Agents —
47+
// can never be picked up by accident.
48+
const fromEnv: Record<string, string> = {
49+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_ORG_API_KEY ?? "",
50+
};
4351
for (const k of ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]) {
4452
delete process.env[k];
4553
}
54+
let dotenv = "";
55+
try {
56+
dotenv = readFileSync(new URL("./.env.ant", import.meta.url), "utf8");
57+
} catch {
58+
dotenv = "";
59+
}
4660
const env: Record<string, string> = {};
47-
for (const line of readFileSync(new URL("./.env.ant", import.meta.url), "utf8").split("\n")) {
61+
for (const line of dotenv.split("\n")) {
4862
const s = line.trim();
4963
if (!s || s.startsWith("#")) continue;
5064
const eq = s.indexOf("=");
@@ -59,8 +73,12 @@ function loadAntKey(): string {
5973
.trim()
6074
.replace(/^["']|["']$/g, "");
6175
}
62-
const apiKey = env.ANTHROPIC_API_KEY ?? "";
63-
if (!apiKey) throw new Error("ANTHROPIC_API_KEY missing in .env.ant (organization key)");
76+
const apiKey = fromEnv.ANTHROPIC_API_KEY || (env.ANTHROPIC_API_KEY ?? "");
77+
if (!apiKey) {
78+
throw new Error(
79+
"organization key missing: set ANTHROPIC_ORG_API_KEY, or ANTHROPIC_API_KEY in .env.ant",
80+
);
81+
}
6482
return apiKey;
6583
}
6684

‎examples/51-cloud-agent-sandbox-per-call/.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ CREATEOS_SANDBOX_API_KEY=your-createos-sandbox-api-key
1313
# That is the only Managed Agents credential this example needs. The environment
1414
# key used by examples 36/37/49 authenticates a *self-hosted* worker polling
1515
# Anthropic's work queue; a cloud environment has no worker, so no such key.
16+
17+
# CI (and any env-only setup) reads these instead of .env.ant; env wins over the
18+
# file. ANTHROPIC_ORG_API_KEY is deliberately a distinct name so the gateway's
19+
# ANTHROPIC_API_KEY is never mistaken for the organization key.
20+
# ANTHROPIC_ORG_API_KEY=sk-ant-...

0 commit comments

Comments
 (0)