Summary
In @alibaba-group/opensandbox, commands.getBackgroundCommandLogs(commandId, cursor) throws
Error: Get command logs failed: unexpected response shape
at CommandsAdapter.getBackgroundCommandLogs (.../dist/chunk-*.js:419)
whenever the supplied cursor is at or beyond the current buffer length and the server therefore has no new bytes to return. The thrower is the JS SDK's own response-shape parser; the backend itself appears to be returning a body the parser refuses to accept (most likely an empty body, similar to the /ping case fixed in #905 for 0.1.8 but never extended to background command logs).
Why this matters in practice
The natural usage of getBackgroundCommandLogs is a tail loop:
let cursor = 0;
for (;;) {
const { content, cursor: next } = await sandbox.commands.getBackgroundCommandLogs(id, cursor);
if (content.length > 0) consume(content);
if (next !== undefined) cursor = next;
if ((await sandbox.commands.getCommandStatus(id)).running === false) break;
await new Promise(r => setTimeout(r, 1000));
}
Any poll iteration that lands during a quiet interval (no new bytes in the last 1s of the running command) will throw. For commands that emit slower than 1 Hz between bursts — Bazel waiting on a dep, Terraform polling AWS, an LLM thinking between tool calls — the bug fires repeatedly during normal operation. The final post-exit drain that catches output written between the previous drain and the status flip also fires the bug when the last drain happened to consume the final byte before exit.
Workarounds in consumer code are possible (catch the exact message, treat as { content: '', cursor: <unchanged> }) but require coupling to upstream error-message text, which is brittle.
Repro
Versions verified to exhibit the bug: 0.1.5, 0.1.7, 0.1.8.
Minimal Deno/TypeScript repro against a live sandbox (any image that can run a short shell command works):
import { ConnectionConfig, Sandbox, SandboxManager } from '@alibaba-group/opensandbox';
const cfg = new ConnectionConfig({
domain: process.env.OPENSANDBOX_URL!,
useServerProxy: false,
headers: { Authorization: `Bearer ${process.env.OPENSANDBOX_TOKEN}` },
});
const mgr = SandboxManager.create({ connectionConfig: cfg });
const sandbox = await mgr.createSandbox({
image: process.env.OPENSANDBOX_IMAGE!,
ttlSeconds: 600,
});
// Launch a long-running command in the background. Capture commandId from the
// init event.
let commandId: string | undefined;
const stream = (sandbox.commands as any).runStream(
`bash -lc 'for i in $(seq 1 30); do echo "line-$i"; sleep 1; done'`,
{ background: true },
);
for await (const ev of stream as AsyncIterable<{ type?: string; text?: string }>) {
if (ev.type === 'init' && typeof ev.text === 'string') commandId = ev.text;
}
// Wait so the buffer accumulates some bytes.
await new Promise(r => setTimeout(r, 5000));
// First read works.
const { content, cursor } = await (sandbox.commands as any).getBackgroundCommandLogs(commandId, 0);
console.log(`cursor=0 → ${content.length}B, cursor'=${cursor}`); // e.g. 119B, cursor'=119
// Second read at the just-returned cursor, immediately — no new content has arrived yet.
await (sandbox.commands as any).getBackgroundCommandLogs(commandId, cursor);
// throws: Error: Get command logs failed: unexpected response shape
await sandbox.delete();
Same throw observed for:
- Any cursor ≥ current buffer length while running, regardless of how long we wait below the threshold needed for new bytes to arrive (0 / 100 / 500 ms all throw; 1500 ms succeeds because the test command emits a new line by then).
getBackgroundCommandLogs(id, finalEOF) after the command has exited (final buffer length is known and not growing).
- Any cursor strictly greater than the current buffer length, e.g.
cursor = 10_000_000.
All three should plausibly succeed with { content: '', cursor: <unchanged> } rather than throw.
Expected behavior
getBackgroundCommandLogs(id, cursor) should be safe to call when the cursor is at the current end of the buffer:
- While the command is running: returns
{ content: '', cursor: <same> } (or whatever the new EOF is, if a write happened between request and response).
- After the command exits: returns
{ content: '', cursor: <final EOF> }.
- For cursors past EOF: ideally clamp and return
{ content: '', cursor: <current EOF> }; throwing a typed "cursor out of range" error would also be acceptable, but the current "unexpected response shape" message implies a parser failure on a valid backend response.
This mirrors the resolution shape used in #905 for /ping (treat empty body as success rather than as a parser failure).
Suggested fix
The same patch that fixed /ping in #905 — accept an empty response body as "no new content" rather than letting the response-shape validator reject it — applied to whatever shared response-parsing helper handles getBackgroundCommandLogs.
If the backend itself returns a malformed body in this case rather than an empty 200, that's a server-side fix and the JS SDK guard becomes defense-in-depth on top of it.
Workaround we're using
For now, downstream consumers can wrap the call:
async function safeDrain(sandbox, commandId, cursor) {
try {
return await sandbox.commands.getBackgroundCommandLogs(commandId, cursor);
} catch (err) {
if (err instanceof Error && err.message.includes('unexpected response shape')) {
return { content: '', cursor };
}
throw err;
}
}
Happy to send a PR if useful — let me know which file owns the parser.
Summary
In
@alibaba-group/opensandbox,commands.getBackgroundCommandLogs(commandId, cursor)throwswhenever the supplied
cursoris at or beyond the current buffer length and the server therefore has no new bytes to return. The thrower is the JS SDK's own response-shape parser; the backend itself appears to be returning a body the parser refuses to accept (most likely an empty body, similar to the/pingcase fixed in #905 for 0.1.8 but never extended to background command logs).Why this matters in practice
The natural usage of
getBackgroundCommandLogsis a tail loop:Any poll iteration that lands during a quiet interval (no new bytes in the last 1s of the running command) will throw. For commands that emit slower than 1 Hz between bursts — Bazel waiting on a dep, Terraform polling AWS, an LLM thinking between tool calls — the bug fires repeatedly during normal operation. The final post-exit drain that catches output written between the previous drain and the status flip also fires the bug when the last drain happened to consume the final byte before exit.
Workarounds in consumer code are possible (catch the exact message, treat as
{ content: '', cursor: <unchanged> }) but require coupling to upstream error-message text, which is brittle.Repro
Versions verified to exhibit the bug: 0.1.5, 0.1.7, 0.1.8.
Minimal Deno/TypeScript repro against a live sandbox (any image that can run a short shell command works):
Same throw observed for:
getBackgroundCommandLogs(id, finalEOF)after the command has exited (final buffer length is known and not growing).cursor = 10_000_000.All three should plausibly succeed with
{ content: '', cursor: <unchanged> }rather than throw.Expected behavior
getBackgroundCommandLogs(id, cursor)should be safe to call when the cursor is at the current end of the buffer:{ content: '', cursor: <same> }(or whatever the new EOF is, if a write happened between request and response).{ content: '', cursor: <final EOF> }.{ content: '', cursor: <current EOF> }; throwing a typed "cursor out of range" error would also be acceptable, but the current "unexpected response shape" message implies a parser failure on a valid backend response.This mirrors the resolution shape used in #905 for
/ping(treat empty body as success rather than as a parser failure).Suggested fix
The same patch that fixed
/pingin #905 — accept an empty response body as "no new content" rather than letting the response-shape validator reject it — applied to whatever shared response-parsing helper handlesgetBackgroundCommandLogs.If the backend itself returns a malformed body in this case rather than an empty 200, that's a server-side fix and the JS SDK guard becomes defense-in-depth on top of it.
Workaround we're using
For now, downstream consumers can wrap the call:
Happy to send a PR if useful — let me know which file owns the parser.