From 4c757a846310c58c48798081826afb6ede1d77c7 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 8 Jun 2026 17:31:04 +0800 Subject: [PATCH 01/48] feat(sandbox): add sandbox product sdk Add a Qiniu Sandbox module with E2B-compatible sandbox, filesystem, command, git, template, network, and typed-error surfaces. Include unit coverage plus gated real integration tests for sandbox lifecycle, files, commands, and git push flows. --- .gitignore | 1 + .../plans/2026-06-08-sandbox-e2b-compat.md | 690 ++++++++ index.d.ts | 307 ++++ index.js | 8 +- qiniu/sandbox.js | 1 + qiniu/sandbox/client.js | 378 +++++ qiniu/sandbox/commands.js | 194 +++ qiniu/sandbox/constants.js | 4 + qiniu/sandbox/envd.js | 108 ++ qiniu/sandbox/errors.js | 49 + qiniu/sandbox/filesystem.js | 157 ++ qiniu/sandbox/git.js | 314 ++++ qiniu/sandbox/index.js | 27 + qiniu/sandbox/network.js | 1 + qiniu/sandbox/pty.js | 13 + qiniu/sandbox/sandbox.js | 192 +++ qiniu/sandbox/template.js | 72 + qiniu/sandbox/util.js | 129 ++ qiniu/sandbox/volume.js | 17 + test/sandbox.test.js | 1451 +++++++++++++++++ test/sandbox_integration.test.js | 281 ++++ 21 files changed, 4393 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md create mode 100644 qiniu/sandbox.js create mode 100644 qiniu/sandbox/client.js create mode 100644 qiniu/sandbox/commands.js create mode 100644 qiniu/sandbox/constants.js create mode 100644 qiniu/sandbox/envd.js create mode 100644 qiniu/sandbox/errors.js create mode 100644 qiniu/sandbox/filesystem.js create mode 100644 qiniu/sandbox/git.js create mode 100644 qiniu/sandbox/index.js create mode 100644 qiniu/sandbox/network.js create mode 100644 qiniu/sandbox/pty.js create mode 100644 qiniu/sandbox/sandbox.js create mode 100644 qiniu/sandbox/template.js create mode 100644 qiniu/sandbox/util.js create mode 100644 qiniu/sandbox/volume.js create mode 100644 test/sandbox.test.js create mode 100644 test/sandbox_integration.test.js diff --git a/.gitignore b/.gitignore index 918f1ad..438e513 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ coverage/ yarn.lock .claude/settings.local.json +.env diff --git a/docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md b/docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md new file mode 100644 index 0000000..43441a1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md @@ -0,0 +1,690 @@ +# Sandbox E2B Compatibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the Qiniu Node.js `sandbox` module much closer to E2B JS SDK ergonomics while preserving Qiniu-specific Sandbox APIs such as injection rules, repository resources, and AK/SK authentication. + +**Architecture:** Keep the existing split under `qiniu/sandbox/`, add focused compatibility files instead of growing a single module, and expose both `qiniu.sandbox.*` and E2B-style top-level exports where safe. Implement real behavior where Qiniu OpenAPI or envd supports it, and return typed compatibility errors for E2B APIs that require unsupported backend products such as persistent Volumes. + +**Tech Stack:** CommonJS Node.js SDK, `urllib`, Mocha, `should`, TypeScript declaration file `index.d.ts`, Qiniu Sandbox OpenAPI, envd Connect JSON RPC. + +--- + +## File Structure + +- Modify `index.js`: add E2B-style top-level `Sandbox`, `SandboxClient`, and selected error exports. +- Modify `index.d.ts`: declare E2B-compatible overloads, errors, Git/file/template/network helpers, and Qiniu extensions. +- Modify `qiniu/sandbox/index.js`: export new compatibility classes without breaking existing namespace imports. +- Modify `qiniu/sandbox/errors.js`: expand typed errors: `CommandExitError`, `TimeoutError`, `NotImplementedError`, `GitAuthError`, `GitUpstreamError`, `TemplateBuildError`. +- Modify `qiniu/sandbox/sandbox.js`: support `Sandbox.create(template, opts)`, instance `connect`, `updateNetwork`, snapshots/MCP helpers where OpenAPI supports them, and typed unsupported errors where it does not. +- Modify `qiniu/sandbox/client.js`: add any missing control-plane wrappers found in `spec/openapi-public.yml`, especially sandbox network and template build helpers. +- Modify `qiniu/sandbox/commands.js`: align option names (`requestTimeoutMs`, `signal`) and add E2B-like command failure semantics without breaking existing callers. +- Modify `qiniu/sandbox/filesystem.js`: support read/write formats (`text`, `bytes`, `blob`, `stream`) and add watch compatibility if envd streaming can be represented. +- Modify `qiniu/sandbox/git.js`: add E2B-compatible Git auth, branch, reset, restore, credential cleanup, config scopes, and typed Git errors. +- Create `qiniu/sandbox/template.js`: E2B-style `Template` builder facade mapped to Qiniu template create/build endpoints. +- Create `qiniu/sandbox/network.js`: constants and helpers for Qiniu/E2B network config, including `ALL_TRAFFIC`. +- Create `qiniu/sandbox/volume.js`: explicit unsupported compatibility class unless Qiniu OpenAPI adds a matching volume backend. +- Modify `test/sandbox.test.js`: add unit tests for every compatibility behavior, using fake control-plane/envd servers as existing tests do. +- Modify `test/sandbox_integration.test.js`: extend only with non-destructive real checks; keep slow/destructive template builds behind explicit env flags. + +--- + +### Task 1: E2B-Style Entry Points And Create Overload + +**Files:** +- Modify: `index.js` +- Modify: `qiniu/sandbox/index.js` +- Modify: `qiniu/sandbox/sandbox.js` +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` + +- [ ] **Step 1: Write the failing tests** + +Add these tests to `test/sandbox.test.js`: + +```js +it('exports E2B style top-level Sandbox and client classes', function () { + qiniu.Sandbox.should.equal(qiniu.sandbox.Sandbox); + qiniu.SandboxClient.should.equal(qiniu.sandbox.SandboxClient); + qiniu.CommandExitError.should.equal(qiniu.sandbox.CommandExitError); +}); + +it('supports Sandbox.create(template, opts) overload', function () { + var requests = []; + var server = createSandboxApiServer(function (req) { + requests.push(req); + return { sandboxID: 'sbx-template', templateID: 'nodejs', envdAccessToken: 'token' }; + }); + + return server.listenAsync().then(function () { + return qiniu.sandbox.Sandbox.create('nodejs', { + apiKey: 'test-key', + apiUrl: server.url, + metadata: { source: 'e2b-overload' } + }); + }).then(function (sandbox) { + sandbox.sandboxId.should.equal('sbx-template'); + requests[0].body.templateID.should.equal('nodejs'); + requests[0].body.metadata.source.should.equal('e2b-overload'); + }).finally(function () { + return server.closeAsync(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "E2B style top-level\\|create\\(template"` + +Expected: fail because `qiniu.Sandbox` or `Sandbox.create(template, opts)` is not implemented. + +- [ ] **Step 3: Implement the minimal code** + +Implement `Sandbox.create` argument normalization: + +```js +Sandbox.create = function (templateOrOpts, maybeOpts) { + var opts = typeof templateOrOpts === 'string' + ? Object.assign({}, maybeOpts || {}, { templateID: templateOrOpts }) + : (templateOrOpts || {}); + var client = new SandboxClient(opts); + return client.createSandbox(opts).then(function (info) { + return new Sandbox(info, client); + }); +}; +``` + +Export top-level aliases from `index.js` after loading `qiniu/sandbox.js`: + +```js +var sandbox = require('./qiniu/sandbox.js'); +exports.sandbox = sandbox; +exports.Sandbox = sandbox.Sandbox; +exports.SandboxClient = sandbox.SandboxClient; +exports.CommandExitError = sandbox.CommandExitError; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "E2B style top-level\\|create\\(template"` + +Expected: both tests pass. + +--- + +### Task 2: Typed Errors + +**Files:** +- Modify: `qiniu/sandbox/errors.js` +- Modify: `qiniu/sandbox/index.js` +- Modify: `index.js` +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` + +- [ ] **Step 1: Write the failing tests** + +Add: + +```js +it('exposes typed sandbox compatibility errors', function () { + var err = new qiniu.sandbox.CommandExitError({ + command: 'false', + exitCode: 1, + stdout: 'out', + stderr: 'err' + }); + + err.should.be.instanceOf(Error); + err.name.should.equal('CommandExitError'); + err.exitCode.should.equal(1); + err.stdout.should.equal('out'); + err.stderr.should.equal('err'); + + new qiniu.sandbox.GitAuthError('bad credentials').name.should.equal('GitAuthError'); + new qiniu.sandbox.GitUpstreamError('missing upstream').name.should.equal('GitUpstreamError'); + new qiniu.sandbox.NotImplementedError('volume').name.should.equal('NotImplementedError'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "typed sandbox compatibility errors"` + +Expected: fail because error classes are missing. + +- [ ] **Step 3: Implement the minimal code** + +Use a small base helper in `qiniu/sandbox/errors.js`: + +```js +function defineError(name) { + function TypedSandboxError(message, props) { + Error.call(this); + this.name = name; + this.message = message || name; + if (props) Object.assign(this, props); + if (Error.captureStackTrace) Error.captureStackTrace(this, TypedSandboxError); + } + TypedSandboxError.prototype = Object.create(Error.prototype); + TypedSandboxError.prototype.constructor = TypedSandboxError; + return TypedSandboxError; +} + +var SandboxError = defineError('SandboxError'); +function CommandExitError(result) { + SandboxError.call(this, 'Command exited with code ' + result.exitCode, result); + this.name = 'CommandExitError'; +} +CommandExitError.prototype = Object.create(SandboxError.prototype); +CommandExitError.prototype.constructor = CommandExitError; +``` + +Export `CommandExitError`, `TimeoutError`, `NotImplementedError`, `GitAuthError`, `GitUpstreamError`, and `TemplateBuildError`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "typed sandbox compatibility errors"` + +Expected: pass. + +--- + +### Task 3: Command Compatibility + +**Files:** +- Modify: `qiniu/sandbox/commands.js` +- Modify: `qiniu/sandbox/envd.js` +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` + +- [ ] **Step 1: Write the failing tests** + +Add: + +```js +it('supports E2B command timeout aliases and optional exit throwing', function () { + var calls = []; + var sandbox = createFakeSandbox({ + rpc: function (service, method, body, opts) { + calls.push({ service: service, method: method, body: body, opts: opts }); + return Promise.resolve({ + process: { pid: 123 }, + event: { end: { exitCode: 2 } }, + stdout: Buffer.from('out').toString('base64'), + stderr: Buffer.from('err').toString('base64') + }); + } + }); + + return sandbox.commands.run('false', { + requestTimeoutMs: 12000, + throwOnError: true + }).then(function () { + throw new Error('expected command to throw'); + }, function (err) { + err.name.should.equal('CommandExitError'); + err.exitCode.should.equal(2); + err.stdout.should.equal('out'); + err.stderr.should.equal('err'); + calls[0].opts.timeout.should.equal(12000); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "command timeout aliases"` + +Expected: fail because `requestTimeoutMs` and `throwOnError` semantics are missing. + +- [ ] **Step 3: Implement the minimal code** + +Normalize timeout options: + +```js +function requestTimeout(opts) { + return opts && (opts.requestTimeoutMs || opts.timeoutMs || opts.timeout); +} +``` + +After command completion: + +```js +if (opts && opts.throwOnError && result.exitCode) { + throw new CommandExitError(result); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "command timeout aliases"` + +Expected: pass. + +--- + +### Task 4: Filesystem Format Compatibility + +**Files:** +- Modify: `qiniu/sandbox/filesystem.js` +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` + +- [ ] **Step 1: Write the failing tests** + +Add: + +```js +it('reads files as text, bytes, blob, and stream formats', function () { + var sandbox = createFakeSandbox({ + fileRead: function () { + return Promise.resolve(Buffer.from('hello')); + } + }); + + return sandbox.files.read('/tmp/a.txt').then(function (text) { + text.should.equal('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'bytes' }); + }).then(function (bytes) { + Buffer.isBuffer(bytes).should.equal(true); + bytes.toString().should.equal('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'stream' }); + }).then(function (stream) { + (typeof stream.pipe).should.equal('function'); + return sandbox.files.read('/tmp/a.txt', { format: 'blob' }); + }).then(function (blob) { + if (typeof Blob !== 'undefined') { + blob.should.be.instanceOf(Blob); + } else { + Buffer.isBuffer(blob).should.equal(true); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "reads files as text"` + +Expected: fail because non-text formats are incomplete. + +- [ ] **Step 3: Implement the minimal code** + +Convert the existing file read buffer: + +```js +var Readable = require('stream').Readable; + +function formatReadResult(buffer, opts) { + var format = opts && opts.format || 'text'; + if (format === 'bytes') return buffer; + if (format === 'stream') return Readable.from([buffer]); + if (format === 'blob' && typeof Blob !== 'undefined') return new Blob([buffer]); + if (format === 'blob') return buffer; + return buffer.toString(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "reads files as text"` + +Expected: pass. + +--- + +### Task 5: Git Advanced Operations And Auth + +**Files:** +- Modify: `qiniu/sandbox/git.js` +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` +- Test: `test/sandbox_integration.test.js` + +- [ ] **Step 1: Write the failing unit tests** + +Add: + +```js +it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { + var commands = []; + var sandbox = createFakeSandbox({ + run: function (cmd) { + commands.push(cmd); + if (cmd.indexOf('branch --format') >= 0) return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); + if (cmd.indexOf('remote get-url origin') >= 0) return Promise.resolve({ stdout: 'https://github.com/acme/repo.git\n', exitCode: 0 }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return sandbox.git.clone('https://github.com/acme/repo.git', '/repo', { + username: 'u', + password: 'p', + depth: 1, + branch: 'main' + }).then(function () { + return sandbox.git.branches('/repo'); + }).then(function (branches) { + branches.should.eql([{ name: 'main', current: true }, { name: 'feature', current: false }]); + return sandbox.git.reset('/repo', { hard: true, ref: 'HEAD~1' }); + }).then(function () { + return sandbox.git.restore('/repo', { staged: true, paths: ['a.txt'] }); + }).then(function () { + commands.join('\n').should.containEql('clone --depth 1 --branch main'); + commands.join('\n').should.containEql('remote set-url origin https://github.com/acme/repo.git'); + commands.join('\n').should.containEql('reset --hard HEAD~1'); + commands.join('\n').should.containEql('restore --staged -- a.txt'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "git auth, branches"` + +Expected: fail because methods/options are missing or incomplete. + +- [ ] **Step 3: Implement the minimal code** + +Add helpers: + +```js +function authedUrl(url, opts) { + if (!opts || !opts.username || !opts.password) return url; + return url.replace(/^https:\/\//, 'https://' + encodeURIComponent(opts.username) + ':' + encodeURIComponent(opts.password) + '@'); +} + +function stripAuth(url) { + return url.replace(/^https:\/\/[^/@]+:[^/@]+@/, 'https://'); +} +``` + +After clone/push/pull with credentials, restore `origin` to the stripped URL unless `dangerouslyStoreCredentials` is true. + +Add `branches`, `reset`, `restore`, `dangerouslyAuthenticate`, `remoteAdd` overwrite/fetch options, `commit` author options, and config scopes using the current `_runGit()` path. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "git auth, branches"` + +Expected: pass. + +--- + +### Task 6: Template Builder Facade + +**Files:** +- Create: `qiniu/sandbox/template.js` +- Modify: `qiniu/sandbox/client.js` +- Modify: `qiniu/sandbox/index.js` +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` + +- [ ] **Step 1: Write the failing tests** + +Add: + +```js +it('builds templates through an E2B style Template facade', function () { + var requests = []; + var server = createSandboxApiServer(function (req) { + requests.push(req); + return { templateID: 'tpl_1', buildID: 'bld_1', status: 'building' }; + }); + + return server.listenAsync().then(function () { + var template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['git']) + .runCmd('node --version') + .setStartCmd('node server.js') + .setReadyCmd('curl -f http://localhost:3000/health'); + + return template.build({ + apiKey: 'test-key', + apiUrl: server.url, + name: 'node-template:test' + }); + }).then(function (result) { + result.templateID.should.equal('tpl_1'); + requests[0].body.name.should.equal('node-template:test'); + requests[0].body.cpuCount.should.be.undefined(); + requests[0].body.buildConfig.fromImage.should.equal('ubuntu:22.04'); + requests[0].body.buildConfig.steps[0].type.should.equal('apt'); + requests[0].body.buildConfig.steps[1].type.should.equal('run'); + }).finally(function () { + return server.closeAsync(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Template facade"` + +Expected: fail because `Template` is missing. + +- [ ] **Step 3: Implement the minimal code** + +Create a chainable facade: + +```js +function Template() { + if (!(this instanceof Template)) return new Template(); + this.buildConfig = { steps: [] }; +} +Template.prototype.fromImage = function (image) { this.buildConfig.fromImage = image; return this; }; +Template.prototype.fromTemplate = function (templateID) { this.buildConfig.fromTemplate = templateID; return this; }; +Template.prototype.aptInstall = function (packages) { this.buildConfig.steps.push({ type: 'apt', packages: packages }); return this; }; +Template.prototype.runCmd = function (cmd) { this.buildConfig.steps.push({ type: 'run', cmd: cmd }); return this; }; +Template.prototype.copy = function (src, dest) { this.buildConfig.steps.push({ type: 'copy', src: src, dest: dest }); return this; }; +Template.prototype.setStartCmd = function (cmd) { this.buildConfig.startCmd = cmd; return this; }; +Template.prototype.setReadyCmd = function (cmd) { this.buildConfig.readyCmd = cmd; return this; }; +Template.prototype.build = function (opts) { + var client = new SandboxClient(opts); + return client.createTemplateV3(Object.assign({}, opts, { buildConfig: this.buildConfig })); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Template facade"` + +Expected: pass. + +--- + +### Task 7: Network, Snapshot, MCP, And Unsupported Volume Compatibility + +**Files:** +- Create: `qiniu/sandbox/network.js` +- Create: `qiniu/sandbox/volume.js` +- Modify: `qiniu/sandbox/sandbox.js` +- Modify: `qiniu/sandbox/client.js` +- Modify: `qiniu/sandbox/index.js` +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` + +- [ ] **Step 1: Write the failing tests** + +Add: + +```js +it('exposes network constants and maps updateNetwork to Qiniu API', function () { + var requests = []; + var server = createSandboxApiServer(function (req) { + requests.push(req); + return { sandboxID: 'sbx-net', network: req.body.network }; + }); + + return server.listenAsync().then(function () { + var client = new qiniu.sandbox.SandboxClient({ apiKey: 'test-key', apiUrl: server.url }); + var sandbox = new qiniu.sandbox.Sandbox({ sandboxID: 'sbx-net' }, client); + qiniu.sandbox.ALL_TRAFFIC.should.equal('0.0.0.0/0'); + return sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }); + }).then(function () { + requests[0].method.should.equal('PATCH'); + requests[0].url.should.containEql('/sandboxes/sbx-net'); + requests[0].body.network.allowOut[0].should.equal('0.0.0.0/0'); + }).finally(function () { + return server.closeAsync(); + }); +}); + +it('returns typed unsupported errors for E2B volume compatibility', function () { + var volume = new qiniu.sandbox.Volume(); + return volume.create().then(function () { + throw new Error('expected volume.create to fail'); + }, function (err) { + err.name.should.equal('NotImplementedError'); + err.message.should.containEql('Volume'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "network constants\\|volume compatibility"` + +Expected: fail because helpers are missing. + +- [ ] **Step 3: Implement the minimal code** + +Add: + +```js +exports.ALL_TRAFFIC = '0.0.0.0/0'; +``` + +Map `sandbox.updateNetwork(network)` to the existing sandbox update endpoint with body `{ network: network }`. + +Create `Volume` with methods that reject: + +```js +Volume.prototype.create = function () { + return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "network constants\\|volume compatibility"` + +Expected: pass. + +--- + +### Task 8: Qiniu Extensions And Integration Gates + +**Files:** +- Modify: `qiniu/sandbox/client.js` +- Modify: `qiniu/sandbox/sandbox.js` +- Modify: `index.d.ts` +- Modify: `test/sandbox_integration.test.js` + +- [ ] **Step 1: Write the failing tests** + +Add unit coverage for Qiniu-only sandbox creation body fields: + +```js +it('keeps Qiniu sandbox extensions in create body', function () { + var requests = []; + var server = createSandboxApiServer(function (req) { + requests.push(req); + return { sandboxID: 'sbx-qiniu', templateID: 'base' }; + }); + + return server.listenAsync().then(function () { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'test-key', + apiUrl: server.url, + mcp: { enabled: true }, + injections: [{ injectionRuleID: 'rule_1' }], + resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] + }); + }).then(function () { + requests[0].body.mcp.enabled.should.equal(true); + requests[0].body.injections[0].injectionRuleID.should.equal('rule_1'); + requests[0].body.resources[0].type.should.equal('github_repository'); + }).finally(function () { + return server.closeAsync(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Qiniu sandbox extensions"` + +Expected: fail if any extension field is dropped. + +- [ ] **Step 3: Implement the minimal code** + +Extend the allowed create body field list in `SandboxClient.createSandbox()` to pass through `mcp`, `injections`, and `resources`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Qiniu sandbox extensions"` + +Expected: pass. + +--- + +### Task 9: Type Declarations And Verification + +**Files:** +- Modify: `index.d.ts` +- Test: `test/sandbox.test.js` +- Test: `test/sandbox_integration.test.js` + +- [ ] **Step 1: Add declaration coverage through `npm run check-type`** + +Update `index.d.ts` with: + +```ts +export const Sandbox: typeof sandbox.Sandbox +export const SandboxClient: typeof sandbox.SandboxClient +export const CommandExitError: typeof sandbox.CommandExitError +``` + +Add overloads: + +```ts +static create(opts?: SandboxCreateOptions): Promise +static create(template: string, opts?: SandboxCreateOptions): Promise +``` + +Declare `Template`, `Volume`, `ALL_TRAFFIC`, command `requestTimeoutMs`, file read formats, and Git methods from Tasks 3-7. + +- [ ] **Step 2: Run full unit tests** + +Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js` + +Expected: all sandbox unit tests pass. + +- [ ] **Step 3: Run type check** + +Run: `npm run check-type` + +Expected: pass. + +- [ ] **Step 4: Run focused lint** + +Run: `./node_modules/.bin/eslint qiniu/sandbox test/sandbox.test.js test/sandbox_integration.test.js` + +Expected: pass. + +- [ ] **Step 5: Run optional integration** + +Run: `./node_modules/.bin/mocha -t 600000 test/sandbox_integration.test.js` + +Expected: real integration passes when `.env` has `QINIU_SANDBOX_INTEGRATION=true`, `QINIU_SANDBOX_API_KEY`, and optional `GIT_REPO_URL`, `GIT_USERNAME`, `GIT_PASSWORD`; injection rule test remains pending unless `QINIU_SANDBOX_TEST_INJECTION_RULES=true` and AK/SK are present. + +--- + +## Self-Review + +- Spec coverage: The plan covers E2B-style Sandbox entry points, command behavior, filesystem read formats, Git operations/auth, template builder, network update, unsupported Volume handling, Qiniu injection/resource extensions, TypeScript declarations, and integration gates. +- Backend reality check: E2B persistent Volume is not present in the Qiniu public OpenAPI observed during planning, so the compatibility surface returns `NotImplementedError` instead of pretending a server-backed Volume exists. +- Placeholder scan: The plan avoids deferred TODOs and gives each task explicit test snippets, implementation shape, and verification commands. +- Type consistency: Method names match the current `qiniu.sandbox` namespace and proposed E2B-compatible aliases. diff --git a/index.d.ts b/index.d.ts index 6df2665..0e1da7b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -38,6 +38,313 @@ export declare namespace auth { } } +export declare namespace sandbox { + interface SandboxClientOptions { + endpoint?: string; + apiUrl?: string; + apiKey?: string; + accessToken?: string; + mac?: auth.digest.Mac; + accessKey?: string; + secretKey?: string; + macOptions?: auth.digest.MacOptions; + timeout?: number | number[]; + httpAgent?: HttpAgent; + httpsAgent?: HttpsAgent; + } + + interface SandboxCreateOptions extends SandboxClientOptions { + template?: string; + templateID?: string; + timeout?: number; + timeoutMs?: number; + autoPause?: boolean; + secure?: boolean; + allow_internet_access?: boolean; + allowInternetAccess?: boolean; + network?: any; + metadata?: {[key: string]: string}; + envVars?: {[key: string]: string}; + envs?: {[key: string]: string}; + mcp?: any; + injections?: any[]; + resources?: any[]; + client?: SandboxClient; + } + + interface SandboxConnectOptions extends SandboxClientOptions { + timeout?: number; + timeoutMs?: number; + client?: SandboxClient; + } + + interface PollOptions { + interval?: number; + intervalMs?: number; + timeout?: number; + timeoutMs?: number; + } + + interface FileUrlOptions { + user?: string; + signatureExpiration?: number; + signature_expiration?: number; + } + + interface EntryInfo { + name?: string; + type?: string; + path?: string; + size?: number; + mode?: number; + permissions?: string; + owner?: string; + group?: string; + modifiedTime?: string | Date; + symlinkTarget?: string; + } + + interface CommandResult { + pid?: number; + exitCode: number; + stdout: string; + stderr: string; + error?: string; + } + + interface CommandOptions { + background?: boolean; + cwd?: string; + user?: string; + envs?: {[key: string]: string}; + tag?: string; + stdin?: boolean; + timeout?: number; + timeoutMs?: number; + requestTimeoutMs?: number; + throwOnError?: boolean; + config?: {[key: string]: string | number | boolean}; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + } + + interface GitStatus { + currentBranch: string; + changedFiles: string[]; + untrackedFiles: string[]; + raw: string; + } + + class Filesystem { + read(path: string, options?: FileUrlOptions & {format?: string}): Promise; + readText(path: string, options?: FileUrlOptions): Promise; + write(path: string, data: string | Buffer, options?: FileUrlOptions): Promise; + write(files: Array<{path: string; data: string | Buffer}>, options?: FileUrlOptions): Promise; + writeFiles(files: Array<{path: string; data: string | Buffer}>, options?: FileUrlOptions): Promise; + getInfo(path: string, options?: {user?: string}): Promise; + stat(path: string, options?: {user?: string}): Promise; + list(path: string, options?: {user?: string; depth?: number}): Promise; + exists(path: string, options?: {user?: string}): Promise; + makeDir(path: string, options?: {user?: string}): Promise; + mkdir(path: string, options?: {user?: string}): Promise; + remove(path: string, options?: {user?: string}): Promise; + rename(oldPath: string, newPath: string, options?: {user?: string}): Promise; + move(oldPath: string, newPath: string, options?: {user?: string}): Promise; + } + + class CommandHandle { + pid: number; + result?: CommandResult; + wait(): Promise; + kill(): Promise; + } + + class Commands { + run(command: string, options?: CommandOptions & {background?: false}): Promise; + run(command: string, options: CommandOptions & {background: true}): Promise; + start(command: string, options?: CommandOptions): Promise; + list(options?: {user?: string}): Promise; + sendStdin(pid: number, data: string | Buffer, options?: {user?: string}): Promise; + closeStdin(pid: number, options?: {user?: string}): Promise; + kill(pid: number, options?: {user?: string}): Promise; + } + + class Git { + clone(repoUrl: string, options?: CommandOptions & {path?: string; depth?: number; branch?: string; username?: string; password?: string; dangerouslyStoreCredentials?: boolean}): Promise; + clone(repoUrl: string, path: string, options?: CommandOptions & {depth?: number; branch?: string; username?: string; password?: string; dangerouslyStoreCredentials?: boolean}): Promise; + init(repoPath: string, options?: CommandOptions & {bare?: boolean; initialBranch?: string}): Promise; + status(repoPath: string, options?: CommandOptions): Promise; + add(repoPath: string, options?: CommandOptions & {files?: string[]; all?: boolean}): Promise; + commit(repoPath: string, message: string, options?: CommandOptions & {allowEmpty?: boolean; authorName?: string; authorEmail?: string}): Promise; + pull(repoPath: string, options?: CommandOptions & {remote?: string; branch?: string; username?: string; password?: string}): Promise; + push(repoPath: string, options?: CommandOptions & {remote?: string; branch?: string; username?: string; password?: string}): Promise; + createBranch(repoPath: string, branch: string, options?: CommandOptions): Promise; + checkoutBranch(repoPath: string, branch: string, options?: CommandOptions): Promise; + deleteBranch(repoPath: string, branch: string, options?: CommandOptions & {force?: boolean}): Promise; + branches(repoPath: string, options?: CommandOptions): Promise>; + reset(repoPath: string, options?: CommandOptions & {hard?: boolean; soft?: boolean; mixed?: boolean; ref?: string}): Promise; + restore(repoPath: string, options?: CommandOptions & {staged?: boolean; worktree?: boolean; source?: string; paths?: string[]; files?: string[]}): Promise; + remoteAdd(repoPath: string, name: string, repoUrl: string, options?: CommandOptions & {overwrite?: boolean; fetch?: boolean}): Promise; + remoteGet(repoPath: string, name: string, options?: CommandOptions): Promise; + setConfig(repoPath: string, key: string, value: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; + getConfig(repoPath: string, key: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; + configureUser(repoPath: string, name: string, email: string, options?: CommandOptions): Promise; + dangerouslyAuthenticate(repoPath: string, remote: string, username: string, password: string, options?: CommandOptions): Promise; + } + + class Pty { + create(options?: CommandOptions & {cmd?: string}): Promise; + } + + const DEFAULT_ENDPOINT: string; + const ALL_TRAFFIC: string; + + class SandboxError extends Error { + response?: IncomingMessage; + data?: any; + } + + class CommandExitError extends SandboxError { + command?: string; + exitCode: number; + stdout: string; + stderr: string; + } + + class TimeoutError extends Error {} + class NotImplementedError extends Error {} + class GitAuthError extends Error {} + class GitUpstreamError extends Error {} + class TemplateBuildError extends Error {} + + interface TemplateBuilder { + fromImage(image: string): this; + fromTemplate(templateID: string): this; + aptInstall(packages: string | string[]): this; + runCmd(command: string): this; + copy(src: string, dest: string): this; + setStartCmd(command: string): this; + setReadyCmd(command: string): this; + build(options?: SandboxClientOptions & {client?: SandboxClient; name?: string; alias?: string; tags?: string[]}): Promise; + } + + const Template: { + new(): TemplateBuilder; + (): TemplateBuilder; + }; + + class Volume { + create(): Promise; + delete(): Promise; + list(): Promise; + } + + class SandboxClient { + endpoint: string; + apiKey?: string; + accessToken?: string; + mac?: auth.digest.Mac; + + constructor(options?: SandboxClientOptions); + + listSandboxes(options?: any): Promise; + listSandboxesV2(options?: any): Promise; + createSandbox(options?: SandboxCreateOptions): Promise; + getSandboxesMetrics(sandboxIDs: string[] | any): Promise; + getSandboxLogs(sandboxID: string, options?: any): Promise; + getSandbox(sandboxID: string): Promise; + deleteSandbox(sandboxID: string): Promise; + killSandbox(sandboxID: string): Promise; + getSandboxMetrics(sandboxID: string, options?: any): Promise; + pauseSandbox(sandboxID: string): Promise; + resumeSandbox(sandboxID: string, options?: any): Promise; + connectSandbox(sandboxID: string, options?: SandboxConnectOptions): Promise; + updateSandboxTimeout(sandboxID: string, timeoutOrOptions: number | {timeout?: number; timeoutMs?: number}): Promise; + updateSandbox(sandboxID: string, options?: any): Promise; + refreshSandbox(sandboxID: string, options?: {duration?: number}): Promise; + + createTemplate(options?: any): Promise; + createTemplateV3(options?: any): Promise; + createTemplateV2(options?: any): Promise; + getTemplateFiles(templateID: string, hash: string): Promise; + listDefaultTemplates(): Promise; + listTemplates(options?: any): Promise; + getTemplate(templateID: string, options?: any): Promise; + deleteTemplate(templateID: string): Promise; + updateTemplate(templateID: string, options?: any): Promise; + startTemplateBuildV2(templateID: string, buildID: string, options?: any): Promise; + getTemplateBuildStatus(templateID: string, buildID: string, options?: any): Promise; + getTemplateBuildLogs(templateID: string, buildID: string, options?: any): Promise; + assignTemplateTags(options?: any): Promise; + deleteTemplateTags(options?: any): Promise; + getTemplateByAlias(alias: string): Promise; + + listInjectionRules(): Promise; + createInjectionRule(options?: any): Promise; + getInjectionRule(ruleID: string): Promise; + updateInjectionRule(ruleID: string, options?: any): Promise; + deleteInjectionRule(ruleID: string): Promise; + + create(options?: SandboxCreateOptions): Promise; + connect(sandboxID: string, options?: SandboxConnectOptions): Promise; + list(options?: any): Promise; + kill(sandboxID: string): Promise; + setTimeout(sandboxID: string, timeoutOrOptions: number | {timeout?: number; timeoutMs?: number}): Promise; + getInfo(sandboxID: string): Promise; + getMetrics(sandboxID: string, options?: any): Promise; + getLogs(sandboxID: string, options?: any): Promise; + createAndWait(options?: SandboxCreateOptions, pollOptions?: PollOptions): Promise; + rebuildTemplate(templateID: string, options?: any): Promise; + startTemplateBuild(templateID: string, buildID: string, options?: any): Promise; + waitForBuild(templateID: string, buildID: string, options?: PollOptions): Promise; + } + + class Sandbox { + sandboxId: string; + sandboxID: string; + sandboxDomain?: string; + domain?: string; + info: any; + client: SandboxClient; + files: Filesystem; + filesystem: Filesystem; + commands: Commands; + pty: Pty; + git: Git; + + constructor(options?: any); + + static create(options?: SandboxCreateOptions): Promise; + static create(template: string, options?: SandboxCreateOptions): Promise; + static connect(sandboxID: string, options?: SandboxConnectOptions): Promise; + static list(options?: SandboxClientOptions & {client?: SandboxClient}): Promise; + + kill(): Promise; + setTimeout(timeoutOrOptions: number | {timeout?: number; timeoutMs?: number}): Promise; + refresh(options?: {duration?: number}): Promise; + updateNetwork(network: any): Promise; + pause(): Promise; + getInfo(): Promise; + getMetrics(options?: any): Promise; + getLogs(options?: any): Promise; + waitForReady(options?: PollOptions): Promise; + isRunning(): Promise; + getHost(port: number): string; + envdUrl(): string; + fileUrl(path: string, operation: string, options?: FileUrlOptions): string; + downloadUrl(path: string, options?: FileUrlOptions): string; + DownloadURL(path: string, options?: FileUrlOptions): string; + uploadUrl(path: string, options?: FileUrlOptions): string; + UploadURL(path: string, options?: FileUrlOptions): string; + batchUploadUrl(user?: string): string; + } +} + +export declare const Sandbox: typeof sandbox.Sandbox; +export declare const SandboxClient: typeof sandbox.SandboxClient; +export declare const CommandExitError: typeof sandbox.CommandExitError; +export declare const SandboxError: typeof sandbox.SandboxError; + export declare namespace cdn { class CdnManager { mac: auth.digest.Mac; diff --git a/index.js b/index.js index 8a3fbfb..8f2b38e 100644 --- a/index.js +++ b/index.js @@ -28,5 +28,11 @@ module.exports = { Credentials: require('./qiniu/rtc/credentials.js'), sms: { message: require('./qiniu/sms/message.js') - } + }, + sandbox: require('./qiniu/sandbox.js') }; + +module.exports.Sandbox = module.exports.sandbox.Sandbox; +module.exports.SandboxClient = module.exports.sandbox.SandboxClient; +module.exports.CommandExitError = module.exports.sandbox.CommandExitError; +module.exports.SandboxError = module.exports.sandbox.SandboxError; diff --git a/qiniu/sandbox.js b/qiniu/sandbox.js new file mode 100644 index 0000000..48e1639 --- /dev/null +++ b/qiniu/sandbox.js @@ -0,0 +1 @@ +module.exports = require('./sandbox/index.js'); diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js new file mode 100644 index 0000000..c471934 --- /dev/null +++ b/qiniu/sandbox/client.js @@ -0,0 +1,378 @@ +const http = require('http'); +const https = require('https'); + +const { HttpClient } = require('../httpc/client'); +const { QiniuAuthMiddleware } = require('../httpc/middleware/qiniuAuth'); +const digest = require('../auth/digest'); +const { DEFAULT_TEMPLATE } = require('./constants'); +const { SandboxError } = require('./errors'); +const { + appendQuery, + copyDefined, + encodePath, + normalizeEndpoint, + poll, + timeoutSecondsFromOptions +} = require('./util'); + +function normalizeSandboxCreateOptions (opts) { + opts = opts || {}; + const body = {}; + body.templateID = opts.templateID || opts.template || DEFAULT_TEMPLATE; + + const timeout = timeoutSecondsFromOptions(opts); + if (timeout !== undefined) { + body.timeout = timeout; + } + + copyDefined(body, opts, 'autoPause'); + copyDefined(body, opts, 'secure'); + copyDefined(body, opts, 'allow_internet_access'); + copyDefined(body, opts, 'allowInternetAccess', 'allow_internet_access'); + copyDefined(body, opts, 'network'); + copyDefined(body, opts, 'metadata'); + copyDefined(body, opts, 'envVars'); + copyDefined(body, opts, 'envs', 'envVars'); + copyDefined(body, opts, 'mcp'); + copyDefined(body, opts, 'injections'); + copyDefined(body, opts, 'resources'); + + return body; +} + +function normalizeInjection (injection) { + if (!injection || typeof injection !== 'object' || Array.isArray(injection)) { + return injection; + } + + const normalized = Object.assign({}, injection); + if (normalized.apiKey !== undefined && normalized.api_key === undefined) { + normalized.api_key = normalized.apiKey; + delete normalized.apiKey; + } + if (normalized.baseUrl !== undefined && normalized.base_url === undefined) { + normalized.base_url = normalized.baseUrl; + delete normalized.baseUrl; + } + if (normalized.ruleId !== undefined && normalized.ruleID === undefined) { + normalized.ruleID = normalized.ruleId; + delete normalized.ruleId; + } + return normalized; +} + +function normalizeInjectionRuleOptions (opts) { + opts = Object.assign({}, opts || {}); + if (opts.injection) { + opts.injection = normalizeInjection(opts.injection); + } + return opts; +} + +function normalizeClientOptions (opts) { + opts = opts || {}; + const mac = opts.mac || (opts.accessKey || opts.secretKey + ? new digest.Mac(opts.accessKey, opts.secretKey, opts.macOptions) + : null); + + return { + endpoint: normalizeEndpoint(opts.endpoint || opts.apiUrl), + apiKey: opts.apiKey || process.env.QINIU_SANDBOX_API_KEY, + accessToken: opts.accessToken || process.env.QINIU_SANDBOX_ACCESS_TOKEN, + mac, + httpAgent: opts.httpAgent || http.globalAgent, + httpsAgent: opts.httpsAgent || https.globalAgent, + timeout: opts.timeout + }; +} + +function SandboxClient (opts) { + const normalized = normalizeClientOptions(opts); + this.endpoint = normalized.endpoint; + this.apiKey = normalized.apiKey; + this.accessToken = normalized.accessToken; + this.mac = normalized.mac; + this.httpClient = new HttpClient({ + httpAgent: normalized.httpAgent, + httpsAgent: normalized.httpsAgent, + timeout: normalized.timeout + }); +} + +SandboxClient.prototype._headers = function (authType) { + const headers = { + 'Content-Type': 'application/json' + }; + + if (authType === 'accessToken') { + if (this.accessToken) { + headers.Authorization = `Bearer ${this.accessToken}`; + } + return headers; + } + + if (authType === 'qiniu') { + return headers; + } + + if (this.apiKey) { + headers['X-API-Key'] = this.apiKey; + headers.Authorization = `Bearer ${this.apiKey}`; + } else if (this.accessToken) { + headers.Authorization = `Bearer ${this.accessToken}`; + } + + return headers; +}; + +SandboxClient.prototype._middlewares = function (authType) { + if (authType === 'qiniu' || (!this.apiKey && !this.accessToken && this.mac)) { + return [new QiniuAuthMiddleware({ mac: this.mac })]; + } + return []; +}; + +SandboxClient.prototype._request = function (method, path, options) { + options = options || {}; + const body = options.body; + const hasBody = body !== undefined && body !== null; + const headers = this._headers(options.authType); + const urllibOptions = { + method, + headers, + dataType: 'json', + gzip: true, + followRedirect: true + }; + + if (hasBody) { + urllibOptions.content = JSON.stringify(body); + urllibOptions.contentType = 'application/json'; + } else { + urllibOptions.headers['Content-Length'] = '0'; + } + + return this.httpClient.sendRequest({ + url: this.endpoint + path, + middlewares: this._middlewares(options.authType), + urllibOptions + }).then(wrapper => this._handleResponse(wrapper, options.empty)); +}; + +SandboxClient.prototype._handleResponse = function (wrapper, empty) { + if (wrapper.ok()) { + return empty ? null : wrapper.data; + } + + const statusCode = wrapper.resp && wrapper.resp.statusCode; + let message = `Sandbox API request failed with status ${statusCode}`; + const data = wrapper.data; + if (data && data.message) { + message += `: ${data.message}`; + } else if (typeof data === 'string' && data) { + message += `: ${data}`; + } + throw new SandboxError(message, wrapper.resp, data); +}; + +SandboxClient.prototype.listSandboxes = function (opts) { + return this._request('GET', appendQuery('/sandboxes', opts)); +}; + +SandboxClient.prototype.listSandboxesV2 = function (opts) { + return this._request('GET', appendQuery('/v2/sandboxes', opts)); +}; + +SandboxClient.prototype.createSandbox = function (opts) { + return this._request('POST', '/sandboxes', { + body: normalizeSandboxCreateOptions(opts) + }); +}; + +SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { + const ids = Array.isArray(sandboxIDs) ? sandboxIDs : sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs; + return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); +}; + +SandboxClient.prototype.getSandboxLogs = function (sandboxID, opts) { + return this._request('GET', appendQuery(`/sandboxes/${encodePath(sandboxID)}/logs`, opts)); +}; + +SandboxClient.prototype.getSandbox = function (sandboxID) { + return this._request('GET', `/sandboxes/${encodePath(sandboxID)}`); +}; + +SandboxClient.prototype.deleteSandbox = function (sandboxID) { + return this._request('DELETE', `/sandboxes/${encodePath(sandboxID)}`, { empty: true }); +}; + +SandboxClient.prototype.killSandbox = SandboxClient.prototype.deleteSandbox; + +SandboxClient.prototype.pauseSandbox = function (sandboxID) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/pause`, { empty: true }); +}; + +SandboxClient.prototype.resumeSandbox = function (sandboxID, opts) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/resume`, { body: opts || {} }); +}; + +SandboxClient.prototype.connectSandbox = function (sandboxID, opts) { + const timeout = timeoutSecondsFromOptions(opts); + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/connect`, { + body: { + timeout: timeout === undefined ? 15 : timeout + } + }); +}; + +SandboxClient.prototype.updateSandboxTimeout = function (sandboxID, timeoutOrOpts) { + const timeout = typeof timeoutOrOpts === 'number' ? timeoutOrOpts : timeoutSecondsFromOptions(timeoutOrOpts); + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/timeout`, { + body: { timeout }, + empty: true + }); +}; + +SandboxClient.prototype.refreshSandbox = function (sandboxID, opts) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/refreshes`, { + body: opts || {}, + empty: true + }); +}; + +SandboxClient.prototype.updateSandbox = function (sandboxID, opts) { + return this._request('PATCH', `/sandboxes/${encodePath(sandboxID)}`, { + body: opts || {} + }); +}; + +SandboxClient.prototype.getSandboxMetrics = function (sandboxID, opts) { + return this._request('GET', appendQuery(`/sandboxes/${encodePath(sandboxID)}/metrics`, opts)); +}; + +SandboxClient.prototype.createTemplate = function (opts) { + return this._request('POST', '/v3/templates', { body: opts || {} }); +}; + +SandboxClient.prototype.createTemplateV3 = SandboxClient.prototype.createTemplate; + +SandboxClient.prototype.createTemplateV2 = function (opts) { + return this._request('POST', '/v2/templates', { body: opts || {} }); +}; + +SandboxClient.prototype.getTemplateFiles = function (templateID, hash) { + return this._request('GET', `/templates/${encodePath(templateID)}/files/${encodePath(hash)}`); +}; + +SandboxClient.prototype.listDefaultTemplates = function () { + return this._request('GET', '/default-templates'); +}; + +SandboxClient.prototype.listTemplates = function (opts) { + return this._request('GET', appendQuery('/templates', opts)); +}; + +SandboxClient.prototype.getTemplate = function (templateID, opts) { + return this._request('GET', appendQuery(`/templates/${encodePath(templateID)}`, opts)); +}; + +SandboxClient.prototype.deleteTemplate = function (templateID) { + return this._request('DELETE', `/templates/${encodePath(templateID)}`, { empty: true }); +}; + +SandboxClient.prototype.updateTemplate = function (templateID, opts) { + return this._request('PATCH', `/templates/${encodePath(templateID)}`, { body: opts || {} }); +}; + +SandboxClient.prototype.startTemplateBuildV2 = function (templateID, buildID, opts) { + return this._request('POST', `/v2/templates/${encodePath(templateID)}/builds/${encodePath(buildID)}`, { + body: opts || {}, + empty: true + }); +}; + +SandboxClient.prototype.getTemplateBuildStatus = function (templateID, buildID, opts) { + return this._request('GET', appendQuery(`/templates/${encodePath(templateID)}/builds/${encodePath(buildID)}/status`, opts)); +}; + +SandboxClient.prototype.getTemplateBuildLogs = function (templateID, buildID, opts) { + return this._request('GET', appendQuery(`/templates/${encodePath(templateID)}/builds/${encodePath(buildID)}/logs`, opts)); +}; + +SandboxClient.prototype.assignTemplateTags = function (opts) { + return this._request('POST', '/templates/tags', { body: opts || {} }); +}; + +SandboxClient.prototype.deleteTemplateTags = function (opts) { + return this._request('DELETE', '/templates/tags', { + body: opts || {}, + empty: true + }); +}; + +SandboxClient.prototype.getTemplateByAlias = function (alias) { + return this._request('GET', `/templates/aliases/${encodePath(alias)}`); +}; + +SandboxClient.prototype.listInjectionRules = function () { + return this._request('GET', '/injection-rules', { authType: 'qiniu' }); +}; + +SandboxClient.prototype.createInjectionRule = function (opts) { + return this._request('POST', '/injection-rules', { + authType: 'qiniu', + body: normalizeInjectionRuleOptions(opts) + }); +}; + +SandboxClient.prototype.getInjectionRule = function (ruleID) { + return this._request('GET', `/injection-rules/${encodePath(ruleID)}`, { authType: 'qiniu' }); +}; + +SandboxClient.prototype.updateInjectionRule = function (ruleID, opts) { + return this._request('PUT', `/injection-rules/${encodePath(ruleID)}`, { + authType: 'qiniu', + body: normalizeInjectionRuleOptions(opts) + }); +}; + +SandboxClient.prototype.deleteInjectionRule = function (ruleID) { + return this._request('DELETE', `/injection-rules/${encodePath(ruleID)}`, { + authType: 'qiniu', + empty: true + }); +}; + +SandboxClient.prototype.create = SandboxClient.prototype.createSandbox; +SandboxClient.prototype.connect = SandboxClient.prototype.connectSandbox; +SandboxClient.prototype.list = SandboxClient.prototype.listSandboxesV2; +SandboxClient.prototype.kill = SandboxClient.prototype.deleteSandbox; +SandboxClient.prototype.setTimeout = SandboxClient.prototype.updateSandboxTimeout; +SandboxClient.prototype.getInfo = SandboxClient.prototype.getSandbox; +SandboxClient.prototype.getMetrics = SandboxClient.prototype.getSandboxMetrics; +SandboxClient.prototype.getLogs = SandboxClient.prototype.getSandboxLogs; + +SandboxClient.prototype.createAndWait = function (opts, pollOpts) { + return this.createSandbox(opts).then(info => { + const Sandbox = require('./sandbox').Sandbox; + const sb = new Sandbox({ client: this, info }); + return sb.waitForReady(pollOpts).then(() => sb); + }); +}; + +SandboxClient.prototype.waitForBuild = function (templateID, buildID, opts) { + return poll(() => this.getTemplateBuildStatus(templateID, buildID), opts, info => { + return info && (info.status === 'ready' || info.status === 'error'); + }); +}; + +SandboxClient.prototype.rebuildTemplate = function (templateID, opts) { + return this._request('POST', `/templates/${encodePath(templateID)}`, { + authType: 'accessToken', + body: opts || {} + }); +}; + +SandboxClient.prototype.startTemplateBuild = SandboxClient.prototype.startTemplateBuildV2; + +exports.SandboxClient = SandboxClient; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js new file mode 100644 index 0000000..4d01edc --- /dev/null +++ b/qiniu/sandbox/commands.js @@ -0,0 +1,194 @@ +const { connectRPC, connectStreamRPC } = require('./envd'); +const { CommandExitError } = require('./errors'); + +function eventPayload (event) { + return event.event || event; +} + +function bytesToString (value) { + if (!value) { + return ''; + } + if (Buffer.isBuffer(value)) { + return value.toString(); + } + if (Array.isArray(value)) { + return Buffer.from(value).toString(); + } + if (typeof value === 'string' && isBase64Text(value)) { + return Buffer.from(value, 'base64').toString(); + } + return String(value); +} + +function isBase64Text (value) { + if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + return false; + } + const normalized = value.replace(/=+$/, ''); + const decoded = Buffer.from(value, 'base64'); + const encoded = decoded.toString('base64').replace(/=+$/, ''); + if (encoded !== normalized) { + return false; + } + const text = decoded.toString(); + if (!text) { + return false; + } + let printable = 0; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) { + printable++; + } + } + return printable / text.length > 0.8; +} + +function commandResultFromEvents (events, callbacks) { + callbacks = callbacks || {}; + let pid = 0; + let stdout = ''; + let stderr = ''; + let exitCode = -1; + let error = ''; + + events.forEach(raw => { + const event = eventPayload(raw); + const start = event.start || (event.event && event.event.start); + const data = event.data || (event.event && event.event.data); + const end = event.end || (event.event && event.event.end); + if (start) { + pid = start.pid || pid; + } + if (data) { + const out = data.stdout !== undefined ? bytesToString(data.stdout) : ''; + const err = data.stderr !== undefined ? bytesToString(data.stderr) : ''; + if (out) { + stdout += out; + if (callbacks.onStdout) { + callbacks.onStdout(out); + } + } + if (err) { + stderr += err; + if (callbacks.onStderr) { + callbacks.onStderr(err); + } + } + } + if (end) { + exitCode = end.exitCode === undefined ? 0 : end.exitCode; + error = end.error || ''; + } + }); + + return { pid, exitCode, stdout, stderr, error }; +} + +function requestTimeout (opts) { + opts = opts || {}; + return opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; +} + +function CommandHandle (commands, pid, result, opts) { + this.commands = commands; + this.pid = pid; + this.result = result; + this.opts = opts || {}; +} + +CommandHandle.prototype.wait = function () { + if (this.opts.throwOnError && this.result && this.result.exitCode) { + return Promise.reject(new CommandExitError(this.result)); + } + return Promise.resolve(this.result); +}; + +CommandHandle.prototype.kill = function () { + return this.commands.kill(this.pid); +}; + +function Commands (sandbox) { + this.sandbox = sandbox; +} + +Commands.prototype.run = function (cmd, opts) { + opts = opts || {}; + return this.start(cmd, opts).then(handle => opts.background ? handle : handle.wait()); +}; + +Commands.prototype.start = function (cmd, opts) { + opts = opts || {}; + const body = { + process: { + cmd: '/bin/bash', + args: ['-l', '-c', cmd] + }, + stdin: opts.stdin || false + }; + if (opts.cwd) { + body.process.cwd = opts.cwd; + } + if (opts.envs) { + body.process.envs = opts.envs; + } + if (opts.tag) { + body.tag = opts.tag; + } + + return connectStreamRPC(this.sandbox, '/process.Process/Start', body, { + user: opts.user, + keepalive: true, + timeout: requestTimeout(opts), + timeoutMs: requestTimeout(opts), + requestTimeoutMs: requestTimeout(opts) + }).then(events => { + const result = commandResultFromEvents(events, opts); + return new CommandHandle(this, result.pid, result, opts); + }); +}; + +Commands.prototype.list = function (opts) { + return connectRPC(this.sandbox, '/process.Process/List', {}, opts).then(data => { + return (data.processes || []).map(p => ({ + pid: p.pid, + tag: p.tag, + cmd: p.config && p.config.cmd, + args: p.config && p.config.args, + envs: p.config && p.config.envs, + cwd: p.config && p.config.cwd + })); + }); +}; + +Commands.prototype.sendStdin = function (pid, data, opts) { + return connectRPC(this.sandbox, '/process.Process/SendInput', { + process: { + selector: { pid } + }, + input: { + stdin: typeof data === 'string' ? Buffer.from(data).toString('base64') : data + } + }, opts).then(() => null); +}; + +Commands.prototype.closeStdin = function (pid, opts) { + return connectRPC(this.sandbox, '/process.Process/CloseStdin', { + process: { + selector: { pid } + } + }, opts).then(() => null); +}; + +Commands.prototype.kill = function (pid, opts) { + return connectRPC(this.sandbox, '/process.Process/SendSignal', { + process: { + selector: { pid } + }, + signal: 'SIGNAL_SIGKILL' + }, opts).then(() => null); +}; + +exports.Commands = Commands; +exports.CommandHandle = CommandHandle; diff --git a/qiniu/sandbox/constants.js b/qiniu/sandbox/constants.js new file mode 100644 index 0000000..7b96b01 --- /dev/null +++ b/qiniu/sandbox/constants.js @@ -0,0 +1,4 @@ +exports.DEFAULT_ENDPOINT = 'https://cn-yangzhou-1-sandbox.qiniuapi.com'; +exports.DEFAULT_TEMPLATE = 'base'; +exports.ENVD_PORT = 49983; +exports.DEFAULT_USER = 'user'; diff --git a/qiniu/sandbox/envd.js b/qiniu/sandbox/envd.js new file mode 100644 index 0000000..771209d --- /dev/null +++ b/qiniu/sandbox/envd.js @@ -0,0 +1,108 @@ +const { basicAuth, parseJSON, rawRequest } = require('./util'); + +function envdHeaders (sandbox, user) { + const headers = { + Authorization: basicAuth(user) + }; + if (sandbox.envdAccessToken) { + headers['X-Access-Token'] = sandbox.envdAccessToken; + } + return headers; +} + +function parseConnectResponse (data) { + if (data && data.result !== undefined) { + return data.result; + } + return data; +} + +function connectRPC (sandbox, procedure, body, opts) { + opts = opts || {}; + const headers = Object.assign({ + 'Content-Type': 'application/json' + }, envdHeaders(sandbox, opts.user)); + if (opts.keepalive) { + headers['Keepalive-Ping-Interval'] = '50'; + } + + return rawRequest(sandbox.envdUrl() + procedure, { + method: 'POST', + content: JSON.stringify(body || {}), + dataType: 'text', + headers, + timeout: opts.requestTimeoutMs || opts.timeoutMs || opts.timeout + }).then(({ data }) => parseConnectResponse(parseJSON(data))); +} + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function decodeConnectEnvelopes (data) { + data = Buffer.isBuffer(data) ? data : Buffer.from(data || ''); + const messages = []; + let offset = 0; + while (offset + 5 <= data.length) { + const flags = data[offset]; + const length = data.readUInt32BE(offset + 1); + offset += 5; + if (offset + length > data.length) { + break; + } + const payload = data.slice(offset, offset + length).toString(); + offset += length; + if (flags & 2) { + continue; + } + if (payload) { + messages.push(JSON.parse(payload)); + } + } + return messages; +} + +function eventListFromResponse (data) { + if (Array.isArray(data.events)) { + return data.events; + } + if (Array.isArray(data)) { + return data; + } + if (data.event) { + return [data]; + } + return []; +} + +function connectStreamRPC (sandbox, procedure, body, opts) { + opts = opts || {}; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json' + }, envdHeaders(sandbox, opts.user)); + if (opts.keepalive) { + headers['Keepalive-Ping-Interval'] = '50'; + } + + return rawRequest(sandbox.envdUrl() + procedure, { + method: 'POST', + content: encodeConnectEnvelope(body), + dataType: 'buffer', + headers, + timeout: opts.requestTimeoutMs || opts.timeoutMs || opts.timeout + }).then(({ data, resp }) => { + const contentType = (resp.headers && resp.headers['content-type']) || ''; + if (contentType.indexOf('application/connect+json') >= 0) { + return decodeConnectEnvelopes(data); + } + return eventListFromResponse(parseJSON(data)); + }); +} + +exports.connectRPC = connectRPC; +exports.connectStreamRPC = connectStreamRPC; +exports.envdHeaders = envdHeaders; diff --git a/qiniu/sandbox/errors.js b/qiniu/sandbox/errors.js new file mode 100644 index 0000000..4795770 --- /dev/null +++ b/qiniu/sandbox/errors.js @@ -0,0 +1,49 @@ +function defineError (name) { + function TypedSandboxError (message, props) { + Error.call(this); + this.name = name; + this.message = message || name; + if (props) { + Object.assign(this, props); + } + if (Error.captureStackTrace) { + Error.captureStackTrace(this, TypedSandboxError); + } + } + + TypedSandboxError.prototype = Object.create(Error.prototype); + TypedSandboxError.prototype.constructor = TypedSandboxError; + return TypedSandboxError; +} + +function SandboxError (message, response, data) { + Error.call(this); + this.name = 'SandboxError'; + this.message = message; + this.response = response; + this.data = data; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, SandboxError); + } +} + +SandboxError.prototype = Object.create(Error.prototype); +SandboxError.prototype.constructor = SandboxError; + +function CommandExitError (result) { + result = result || {}; + SandboxError.call(this, `Command exited with code ${result.exitCode}`, null, result); + this.name = 'CommandExitError'; + Object.assign(this, result); +} + +CommandExitError.prototype = Object.create(SandboxError.prototype); +CommandExitError.prototype.constructor = CommandExitError; + +exports.SandboxError = SandboxError; +exports.CommandExitError = CommandExitError; +exports.TimeoutError = defineError('TimeoutError'); +exports.NotImplementedError = defineError('NotImplementedError'); +exports.GitAuthError = defineError('GitAuthError'); +exports.GitUpstreamError = defineError('GitUpstreamError'); +exports.TemplateBuildError = defineError('TemplateBuildError'); diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js new file mode 100644 index 0000000..a9d8b4c --- /dev/null +++ b/qiniu/sandbox/filesystem.js @@ -0,0 +1,157 @@ +const { connectRPC } = require('./envd'); +const { rawRequest } = require('./util'); +const { Readable } = require('stream'); + +function normalizeFileType (type) { + if (type === 'FILE_TYPE_DIRECTORY' || type === 'DIRECTORY' || type === 'dir') { + return 'dir'; + } + if (type === 'FILE_TYPE_FILE' || type === 'FILE' || type === 'file') { + return 'file'; + } + return type || 'unknown'; +} + +function normalizeEntry (entry) { + entry = entry || {}; + return Object.assign({}, entry, { + type: normalizeFileType(entry.type) + }); +} + +function multipartBody (boundary, parts) { + const chunks = []; + parts.forEach(part => { + chunks.push(Buffer.from(`--${boundary}\r\n`)); + chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.field}"; filename="${part.filename}"\r\n`)); + chunks.push(Buffer.from('Content-Type: application/octet-stream\r\n\r\n')); + chunks.push(Buffer.isBuffer(part.data) ? part.data : Buffer.from(String(part.data))); + chunks.push(Buffer.from('\r\n')); + }); + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + return Buffer.concat(chunks); +} + +function Filesystem (sandbox) { + this.sandbox = sandbox; +} + +function formatReadResult (data, opts) { + opts = opts || {}; + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data || '')); + const format = opts.format || 'text'; + if (format === 'bytes') { + return buffer; + } + if (format === 'stream') { + return Readable.from([buffer]); + } + if (format === 'blob') { + return typeof global.Blob !== 'undefined' ? new global.Blob([buffer]) : buffer; + } + return buffer.toString(); +} + +Filesystem.prototype.read = function (path, opts) { + opts = opts || {}; + return rawRequest(this.sandbox.downloadUrl(path, opts), { + method: 'GET', + dataType: 'buffer', + headers: {} + }).then(({ data }) => formatReadResult(data, opts)); +}; + +Filesystem.prototype.readText = function (path, opts) { + return this.read(path, opts).then(data => Buffer.isBuffer(data) ? data.toString() : data); +}; + +Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { + if (Array.isArray(pathOrFiles)) { + return this.writeFiles(pathOrFiles, dataOrOpts); + } + + const path = pathOrFiles; + const opts = maybeOpts || {}; + const boundary = `qiniu-sandbox-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const body = multipartBody(boundary, [{ + field: 'file', + filename: path, + data: dataOrOpts + }]); + + return rawRequest(this.sandbox.uploadUrl(path, opts), { + method: 'POST', + content: body, + dataType: 'json', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}` + } + }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); +}; + +Filesystem.prototype.writeFiles = function (files, opts) { + opts = opts || {}; + const boundary = `qiniu-sandbox-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const parts = files.map(file => ({ + field: 'file', + filename: file.path, + data: file.data + })); + + return rawRequest(this.sandbox.batchUploadUrl(opts.user), { + method: 'POST', + content: multipartBody(boundary, parts), + dataType: 'json', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}` + } + }).then(({ data }) => (data || []).map(normalizeEntry)); +}; + +Filesystem.prototype.getInfo = function (path, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/Stat', { path }, opts) + .then(data => normalizeEntry(data.entry)); +}; + +Filesystem.prototype.stat = Filesystem.prototype.getInfo; + +Filesystem.prototype.list = function (path, opts) { + opts = opts || {}; + return connectRPC(this.sandbox, '/filesystem.Filesystem/ListDir', { + path, + depth: opts.depth || 1 + }, opts).then(data => (data.entries || []).map(normalizeEntry)); +}; + +Filesystem.prototype.exists = function (path, opts) { + return this.getInfo(path, opts).then(() => true, err => { + if (err.response && err.response.statusCode === 404) { + return false; + } + throw err; + }); +}; + +Filesystem.prototype.makeDir = function (path, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/MakeDir', { path }, opts) + .then(data => normalizeEntry(data.entry)); +}; + +Filesystem.prototype.mkdir = Filesystem.prototype.makeDir; + +Filesystem.prototype.remove = function (path, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/Remove', { path }, opts) + .then(() => null); +}; + +Filesystem.prototype.rename = function (oldPath, newPath, opts) { + return connectRPC(this.sandbox, '/filesystem.Filesystem/Move', { + source: oldPath, + destination: newPath + }, opts).then(data => normalizeEntry(data.entry)); +}; + +Filesystem.prototype.move = Filesystem.prototype.rename; + +exports.Filesystem = Filesystem; +exports.normalizeEntry = normalizeEntry; diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js new file mode 100644 index 0000000..a1c881d --- /dev/null +++ b/qiniu/sandbox/git.js @@ -0,0 +1,314 @@ +const { shellQuote } = require('./util'); +const { GitAuthError, GitUpstreamError } = require('./errors'); + +function Git (commands) { + this.commands = commands; +} + +function gitConfigArgs (opts) { + opts = opts || {}; + const config = opts.config || {}; + return Object.keys(config).map(key => { + return ['-c', shellQuote(`${key}=${config[key]}`)]; + }).reduce((acc, item) => acc.concat(item), []); +} + +function pathAndOptions (pathOrOpts, maybeOpts) { + if (typeof pathOrOpts === 'string') { + return { path: pathOrOpts, opts: Object.assign({}, maybeOpts || {}) }; + } + const opts = Object.assign({}, pathOrOpts || {}); + return { path: opts.path, opts }; +} + +function authUrl (repoUrl, opts) { + opts = opts || {}; + if (!opts.username && !opts.password) { + return repoUrl; + } + if (!opts.username || !opts.password) { + throw new GitAuthError('Both username and password are required for git authentication'); + } + return repoUrl.replace(/^https:\/\//, `https://${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@`); +} + +function stripAuth (repoUrl) { + return String(repoUrl || '').replace(/^https:\/\/[^/@]+:[^/@]+@/, 'https://'); +} + +function configScopeArg (opts) { + opts = opts || {}; + if (opts.scope === 'global') { + return '--global'; + } + if (opts.scope === 'system') { + return '--system'; + } + if (opts.scope === 'local') { + return '--local'; + } + return null; +} + +Git.prototype._runGit = function (repoPath, args, opts) { + opts = Object.assign({}, opts || {}); + if (repoPath) { + opts.cwd = repoPath; + } + return this.commands.run(`git ${gitConfigArgs(opts).concat(args).join(' ')}`, opts); +}; + +Git.prototype.clone = function (repoUrl, pathOrOpts, maybeOpts) { + const normalized = pathAndOptions(pathOrOpts, maybeOpts); + const opts = normalized.opts; + const cloneUrl = authUrl(repoUrl, opts); + const args = gitConfigArgs(opts).concat(['clone', shellQuote(cloneUrl)]); + if (opts.depth) { + args.push('--depth', shellQuote(opts.depth)); + } + if (opts.branch) { + args.push('--branch', shellQuote(opts.branch)); + } + if (normalized.path) { + args.push(shellQuote(normalized.path)); + } + return this.commands.run(`git ${args.join(' ')}`, opts).then(result => { + if ((opts.username || opts.password) && !opts.dangerouslyStoreCredentials && normalized.path) { + return this._runGit(normalized.path, ['remote', 'set-url', 'origin', shellQuote(stripAuth(cloneUrl))], opts) + .then(() => result); + } + return result; + }); +}; + +Git.prototype.init = function (repoPath, opts) { + opts = opts || {}; + const args = ['init']; + if (opts.bare) { + args.push('--bare'); + } + if (opts.initialBranch) { + args.push('--initial-branch', shellQuote(opts.initialBranch)); + } + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.status = function (repoPath, opts) { + return this._runGit(repoPath, ['status', '--porcelain=v1', '-b'], opts) + .then(result => parseGitStatus(result.stdout)); +}; + +Git.prototype.add = function (repoPath, opts) { + opts = opts || {}; + const files = opts.all ? ['--all'] : (opts.files || ['.']).map(shellQuote); + return this._runGit(repoPath, ['add'].concat(files), opts); +}; + +Git.prototype.commit = function (repoPath, message, opts) { + opts = opts || {}; + const args = ['commit', '-m', shellQuote(message)]; + if (opts.authorName || opts.authorEmail) { + if (!opts.authorName || !opts.authorEmail) { + throw new GitAuthError('Both authorName and authorEmail are required for git commit author'); + } + args.push('--author', shellQuote(`${opts.authorName} <${opts.authorEmail}>`)); + } + if (opts.allowEmpty) { + args.push('--allow-empty'); + } + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.pull = function (repoPath, opts) { + opts = opts || {}; + const args = ['pull']; + if (opts.remote) { + args.push(shellQuote(opts.remote)); + } + if (opts.branch) { + args.push(shellQuote(opts.branch)); + } + return this._runGitWithTemporaryAuth(repoPath, args, opts); +}; + +Git.prototype.push = function (repoPath, opts) { + opts = opts || {}; + const args = ['push']; + if (opts.remote) { + args.push(shellQuote(opts.remote)); + } + if (opts.branch) { + args.push(shellQuote(opts.branch)); + } + return this._runGitWithTemporaryAuth(repoPath, args, opts); +}; + +Git.prototype.createBranch = function (repoPath, branch, opts) { + return this._runGit(repoPath, ['checkout', '-b', shellQuote(branch)], opts); +}; + +Git.prototype.checkoutBranch = function (repoPath, branch, opts) { + return this._runGit(repoPath, ['checkout', shellQuote(branch)], opts); +}; + +Git.prototype.deleteBranch = function (repoPath, branch, opts) { + opts = opts || {}; + return this._runGit(repoPath, ['branch', opts.force ? '-D' : '-d', shellQuote(branch)], opts); +}; + +Git.prototype.remoteAdd = function (repoPath, name, repoUrl, opts) { + opts = opts || {}; + const add = () => this._runGit(repoPath, ['remote', 'add', shellQuote(name), shellQuote(repoUrl)], opts); + const afterAdd = () => opts.fetch ? this._runGit(repoPath, ['fetch', shellQuote(name)], opts) : null; + if (opts.overwrite) { + return this._runGit(repoPath, ['remote', 'remove', shellQuote(name)], opts) + .then(add, add) + .then(afterAdd); + } + return add().then(afterAdd); +}; + +Git.prototype.remoteGet = function (repoPath, name, opts) { + return this._runGit(repoPath, ['remote', 'get-url', shellQuote(name)], opts) + .then(result => result.exitCode ? undefined : result.stdout.trim()); +}; + +Git.prototype.setConfig = function (repoPath, key, value, opts) { + const scope = configScopeArg(opts); + const args = ['config']; + if (scope) { + args.push(scope); + } + args.push(shellQuote(key), shellQuote(value)); + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.getConfig = function (repoPath, key, opts) { + const scope = configScopeArg(opts); + const args = ['config']; + if (scope) { + args.push(scope); + } + args.push('--get', shellQuote(key)); + return this._runGit(repoPath, args, opts) + .then(result => result.stdout.trim()); +}; + +Git.prototype.configureUser = function (repoPath, name, email, opts) { + return this._runGit(repoPath, [ + 'config', + 'user.name', + shellQuote(name), + '&&', + 'git', + 'config', + 'user.email', + shellQuote(email) + ], opts); +}; + +Git.prototype.branches = function (repoPath, opts) { + return this._runGit(repoPath, ['branch', '--format=%(HEAD) %(refname:short)'], opts) + .then(result => String(result.stdout || '').split(/\r?\n/) + .filter(Boolean) + .map(line => ({ + current: line.charAt(0) === '*', + name: line.slice(2).trim() + }))); +}; + +Git.prototype.reset = function (repoPath, opts) { + opts = opts || {}; + const args = ['reset']; + if (opts.hard) { + args.push('--hard'); + } else if (opts.soft) { + args.push('--soft'); + } else if (opts.mixed) { + args.push('--mixed'); + } + if (opts.ref) { + args.push(shellQuote(opts.ref)); + } + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.restore = function (repoPath, opts) { + opts = opts || {}; + const args = ['restore']; + if (opts.staged) { + args.push('--staged'); + } + if (opts.worktree) { + args.push('--worktree'); + } + if (opts.source) { + args.push('--source', shellQuote(opts.source)); + } + const paths = opts.paths || opts.files || []; + if (paths.length) { + args.push('--'); + paths.forEach(path => args.push(shellQuote(path))); + } + return this._runGit(repoPath, args, opts); +}; + +Git.prototype.dangerouslyAuthenticate = function (repoPath, remote, username, password, opts) { + opts = opts || {}; + return this.remoteGet(repoPath, remote, opts).then(repoUrl => { + if (!repoUrl) { + throw new GitUpstreamError(`Remote ${remote} does not exist`); + } + return this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(authUrl(repoUrl, { + username, + password + }))], opts); + }); +}; + +Git.prototype._runGitWithTemporaryAuth = function (repoPath, args, opts) { + opts = opts || {}; + if (!opts.username && !opts.password) { + return this._runGit(repoPath, args, opts); + } + const remote = opts.remote || 'origin'; + let originalUrl; + return this.remoteGet(repoPath, remote, opts).then(repoUrl => { + if (!repoUrl) { + throw new GitUpstreamError(`Remote ${remote} does not exist`); + } + originalUrl = repoUrl; + return this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(authUrl(repoUrl, opts))], opts); + }).then(() => this._runGit(repoPath, args, opts)) + .then(result => this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(stripAuth(originalUrl))], opts) + .then(() => result)); +}; + +function parseGitStatus (stdout) { + stdout = String(stdout || '').replace(/\\n/g, '\n'); + const status = { + currentBranch: '', + changedFiles: [], + untrackedFiles: [], + raw: stdout + }; + stdout.split(/\r?\n/).forEach(line => { + if (!line) { + return; + } + if (line.indexOf('## ') === 0) { + status.currentBranch = line.slice(3).split('...')[0].trim(); + return; + } + if (line.indexOf('?? ') === 0) { + status.untrackedFiles.push(line.slice(3).trim()); + return; + } + status.changedFiles.push(line.slice(3).trim()); + }); + return status; +} + +exports.Git = Git; +exports.parseGitStatus = parseGitStatus; +exports.stripAuth = stripAuth; diff --git a/qiniu/sandbox/index.js b/qiniu/sandbox/index.js new file mode 100644 index 0000000..1d78ecd --- /dev/null +++ b/qiniu/sandbox/index.js @@ -0,0 +1,27 @@ +const { ResponseWrapper } = require('../httpc/responseWrapper'); +const constants = require('./constants'); +const errors = require('./errors'); +const network = require('./network'); + +module.exports = { + DEFAULT_ENDPOINT: constants.DEFAULT_ENDPOINT, + DEFAULT_USER: constants.DEFAULT_USER, + ALL_TRAFFIC: network.ALL_TRAFFIC, + SandboxClient: require('./client').SandboxClient, + Sandbox: require('./sandbox').Sandbox, + Filesystem: require('./filesystem').Filesystem, + Commands: require('./commands').Commands, + CommandHandle: require('./commands').CommandHandle, + Git: require('./git').Git, + Pty: require('./pty').Pty, + Template: require('./template').Template, + Volume: require('./volume').Volume, + SandboxError: errors.SandboxError, + CommandExitError: errors.CommandExitError, + TimeoutError: errors.TimeoutError, + NotImplementedError: errors.NotImplementedError, + GitAuthError: errors.GitAuthError, + GitUpstreamError: errors.GitUpstreamError, + TemplateBuildError: errors.TemplateBuildError, + ResponseWrapper +}; diff --git a/qiniu/sandbox/network.js b/qiniu/sandbox/network.js new file mode 100644 index 0000000..fc88c78 --- /dev/null +++ b/qiniu/sandbox/network.js @@ -0,0 +1 @@ +exports.ALL_TRAFFIC = '0.0.0.0/0'; diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js new file mode 100644 index 0000000..bb87dd9 --- /dev/null +++ b/qiniu/sandbox/pty.js @@ -0,0 +1,13 @@ +function Pty (sandbox) { + this.sandbox = sandbox; + this.commands = sandbox.commands; +} + +Pty.prototype.create = function (opts) { + opts = opts || {}; + return this.commands.start(opts.cmd || '/bin/bash', Object.assign({}, opts, { + stdin: true + })); +}; + +exports.Pty = Pty; diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js new file mode 100644 index 0000000..df5f38d --- /dev/null +++ b/qiniu/sandbox/sandbox.js @@ -0,0 +1,192 @@ +const { Commands } = require('./commands'); +const { DEFAULT_USER, ENVD_PORT } = require('./constants'); +const { Filesystem } = require('./filesystem'); +const { Git } = require('./git'); +const { Pty } = require('./pty'); +const { SandboxClient } = require('./client'); +const { appendQuery, fileSignature, poll, rawRequest } = require('./util'); + +function getSandboxID (data) { + return data && (data.sandboxID || data.sandboxId || data.sandbox_id || data.id); +} + +function getInfoValue (info, camelKey, snakeKey) { + return info && (info[camelKey] !== undefined ? info[camelKey] : info[snakeKey]); +} + +function Sandbox (opts) { + opts = opts || {}; + this.client = opts.client || new SandboxClient(opts); + this.info = opts.info || {}; + this.sandboxId = opts.sandboxId || opts.sandboxID || getSandboxID(this.info); + this.sandboxID = this.sandboxId; + this.sandboxDomain = this.info.domain || this.info.sandboxDomain || this.info.sandbox_domain; + this.domain = this.sandboxDomain; + this.envdVersion = getInfoValue(this.info, 'envdVersion', 'envd_version'); + this.envdAccessToken = opts.envdAccessToken || getInfoValue(this.info, 'envdAccessToken', 'envd_access_token'); + this.trafficAccessToken = getInfoValue(this.info, 'trafficAccessToken', 'traffic_access_token'); + this._envdUrl = opts.envdUrl; + this.files = new Filesystem(this); + this.filesystem = this.files; + this.commands = new Commands(this); + this.pty = new Pty(this); + this.git = new Git(this.commands); +} + +Sandbox.create = function (templateOrOpts, maybeOpts) { + const opts = typeof templateOrOpts === 'string' + ? Object.assign({}, maybeOpts || {}, { templateID: templateOrOpts }) + : (templateOrOpts || {}); + const client = opts.client || new SandboxClient(opts); + return client.createSandbox(opts).then(info => { + const sandbox = new Sandbox({ client, info }); + return sandbox.refreshEnvdTokenIfNeeded(); + }); +}; + +Sandbox.connect = function (sandboxID, opts) { + opts = opts || {}; + const client = opts.client || new SandboxClient(opts); + return client.connectSandbox(sandboxID, opts).then(info => { + const sandbox = new Sandbox({ + client, + sandboxId: sandboxID, + info + }); + return sandbox.refreshEnvdTokenIfNeeded(); + }); +}; + +Sandbox.list = function (opts) { + const client = opts && opts.client ? opts.client : new SandboxClient(opts); + const params = Object.assign({}, opts || {}); + delete params.client; + return client.listSandboxesV2(params).then(items => { + if (!Array.isArray(items)) { + return items; + } + return items.map(info => new Sandbox({ client, info })); + }); +}; + +Sandbox.prototype.kill = function () { + return this.client.deleteSandbox(this.sandboxId); +}; + +Sandbox.prototype.setTimeout = function (timeoutOrOpts) { + return this.client.updateSandboxTimeout(this.sandboxId, timeoutOrOpts); +}; + +Sandbox.prototype.refresh = function (opts) { + return this.client.refreshSandbox(this.sandboxId, opts); +}; + +Sandbox.prototype.updateNetwork = function (network) { + return this.client.updateSandbox(this.sandboxId, { network }); +}; + +Sandbox.prototype.pause = function () { + return this.client.pauseSandbox(this.sandboxId); +}; + +Sandbox.prototype.getInfo = function () { + return this.client.getSandbox(this.sandboxId); +}; + +Sandbox.prototype.refreshEnvdTokenIfNeeded = function () { + if (this.envdAccessToken) { + return Promise.resolve(this); + } + return this.getInfo().then(info => { + this.updateInfo(info); + return this; + }); +}; + +Sandbox.prototype.updateInfo = function (info) { + if (info) { + this.info = info; + this.envdAccessToken = getInfoValue(info, 'envdAccessToken', 'envd_access_token') || this.envdAccessToken; + this.envdVersion = getInfoValue(info, 'envdVersion', 'envd_version') || this.envdVersion; + this.domain = info.domain || info.sandboxDomain || info.sandbox_domain || this.domain; + this.sandboxDomain = this.domain; + } + return this; +}; + +Sandbox.prototype.getMetrics = function (opts) { + return this.client.getSandboxMetrics(this.sandboxId, opts); +}; + +Sandbox.prototype.getLogs = function (opts) { + return this.client.getSandboxLogs(this.sandboxId, opts); +}; + +Sandbox.prototype.waitForReady = function (opts) { + return poll(() => this.getInfo(), opts, info => info && info.state === 'running') + .then(info => { + this.updateInfo(info); + return info; + }); +}; + +Sandbox.prototype.isRunning = function () { + return rawRequest(this.envdUrl() + '/health', { + method: 'GET', + dataType: 'text' + }).then(() => true, err => { + if (err.response && err.response.statusCode === 502) { + return false; + } + if (err.resp && err.resp.statusCode === 502) { + return false; + } + throw err; + }); +}; + +Sandbox.prototype.getHost = function (port) { + if (!this.domain) { + return ''; + } + return `${port}-${this.sandboxId}.${this.domain}`; +}; + +Sandbox.prototype.envdUrl = function () { + return this._envdUrl || `https://${this.getHost(ENVD_PORT)}`; +}; + +Sandbox.prototype.fileUrl = function (path, operation, opts) { + opts = opts || {}; + const user = opts.user || DEFAULT_USER; + const query = { + path, + username: user + }; + if (this.envdAccessToken) { + const expiration = opts.signatureExpiration || opts.signature_expiration || 300; + query.signature = fileSignature(path, operation, user, this.envdAccessToken, expiration); + query.signature_expiration = expiration; + } + return this.envdUrl() + appendQuery('/files', query); +}; + +Sandbox.prototype.downloadUrl = function (path, opts) { + return this.fileUrl(path, 'read', opts); +}; + +Sandbox.prototype.DownloadURL = Sandbox.prototype.downloadUrl; + +Sandbox.prototype.uploadUrl = function (path, opts) { + return this.fileUrl(path, 'write', opts); +}; + +Sandbox.prototype.UploadURL = Sandbox.prototype.uploadUrl; + +Sandbox.prototype.batchUploadUrl = function (user) { + return this.envdUrl() + appendQuery('/files', { + username: user || DEFAULT_USER + }); +}; + +exports.Sandbox = Sandbox; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js new file mode 100644 index 0000000..92f5ab1 --- /dev/null +++ b/qiniu/sandbox/template.js @@ -0,0 +1,72 @@ +const { SandboxClient } = require('./client'); + +function Template () { + if (!(this instanceof Template)) { + return new Template(); + } + this.buildConfig = { + steps: [] + }; +} + +Template.prototype.fromImage = function (image) { + this.buildConfig.fromImage = image; + return this; +}; + +Template.prototype.fromTemplate = function (templateID) { + this.buildConfig.fromTemplate = templateID; + return this; +}; + +Template.prototype.aptInstall = function (packages) { + this.buildConfig.steps.push({ + type: 'apt', + packages: Array.isArray(packages) ? packages : [packages] + }); + return this; +}; + +Template.prototype.runCmd = function (cmd) { + this.buildConfig.steps.push({ + type: 'run', + cmd + }); + return this; +}; + +Template.prototype.copy = function (src, dest) { + this.buildConfig.steps.push({ + type: 'copy', + src, + dest + }); + return this; +}; + +Template.prototype.setStartCmd = function (cmd) { + this.buildConfig.startCmd = cmd; + return this; +}; + +Template.prototype.setReadyCmd = function (cmd) { + this.buildConfig.readyCmd = cmd; + return this; +}; + +Template.prototype.build = function (opts) { + opts = opts || {}; + const client = opts.client || new SandboxClient(opts); + const body = Object.assign({}, opts, { + buildConfig: this.buildConfig + }); + delete body.client; + delete body.endpoint; + delete body.apiUrl; + delete body.apiKey; + delete body.accessToken; + delete body.mac; + return client.createTemplateV3(body); +}; + +exports.Template = Template; diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js new file mode 100644 index 0000000..5d31f2e --- /dev/null +++ b/qiniu/sandbox/util.js @@ -0,0 +1,129 @@ +const crypto = require('crypto'); +const urllib = require('urllib'); + +const { DEFAULT_ENDPOINT, DEFAULT_USER } = require('./constants'); +const { SandboxError } = require('./errors'); + +function normalizeEndpoint (endpoint) { + endpoint = endpoint || process.env.QINIU_SANDBOX_ENDPOINT || DEFAULT_ENDPOINT; + return endpoint.replace(/\/+$/, ''); +} + +function encodePath (value) { + return encodeURIComponent(value); +} + +function appendQuery (path, query) { + const pairs = []; + query = query || {}; + + Object.keys(query).forEach(key => { + const value = query[key]; + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.join(','))}`); + return; + } + pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + }); + + return pairs.length ? `${path}?${pairs.join('&')}` : path; +} + +function timeoutSecondsFromOptions (opts) { + opts = opts || {}; + if (opts.timeout !== undefined) { + return opts.timeout; + } + if (opts.timeoutMs !== undefined) { + return Math.ceil(opts.timeoutMs / 1000); + } + return undefined; +} + +function copyDefined (target, source, key, outKey) { + if (source[key] !== undefined) { + target[outKey || key] = source[key]; + } +} + +function poll (fn, opts, done) { + opts = opts || {}; + const interval = opts.interval || opts.intervalMs || 1000; + const timeout = opts.timeout || opts.timeoutMs || 60000; + const startedAt = Date.now(); + + function tick () { + return fn().then(value => { + if (done(value)) { + return value; + } + if (Date.now() - startedAt >= timeout) { + throw new SandboxError('Sandbox poll timed out'); + } + return new Promise(resolve => setTimeout(resolve, interval)).then(tick); + }); + } + + return tick(); +} + +function basicAuth (user) { + user = user || DEFAULT_USER; + return `Basic ${Buffer.from(`${user}:`).toString('base64')}`; +} + +function fileSignature (path, operation, user, accessToken, expiration) { + const raw = `${path}:${operation}:${user}:${accessToken}:${expiration}`; + return `v1_${crypto.createHash('sha256').update(raw).digest('hex')}`; +} + +function rawRequest (requestUrl, options) { + options = options || {}; + return new Promise((resolve, reject) => { + urllib.request(requestUrl, options, (err, data, resp) => { + if (err) { + err.resp = resp; + reject(err); + return; + } + if (resp && Math.floor(resp.statusCode / 100) !== 2) { + reject(new SandboxError(`Sandbox envd request failed with status ${resp.statusCode}`, resp, data)); + return; + } + resolve({ + data, + resp + }); + }); + }); +} + +function parseJSON (data) { + if (Buffer.isBuffer(data)) { + data = data.toString(); + } + if (typeof data === 'string') { + return data ? JSON.parse(data) : {}; + } + return data || {}; +} + +function shellQuote (value) { + const quote = String.fromCharCode(39); + return quote + String(value).replace(/'/g, quote + '\\' + quote + quote) + quote; +} + +exports.normalizeEndpoint = normalizeEndpoint; +exports.encodePath = encodePath; +exports.appendQuery = appendQuery; +exports.timeoutSecondsFromOptions = timeoutSecondsFromOptions; +exports.copyDefined = copyDefined; +exports.poll = poll; +exports.basicAuth = basicAuth; +exports.fileSignature = fileSignature; +exports.rawRequest = rawRequest; +exports.parseJSON = parseJSON; +exports.shellQuote = shellQuote; diff --git a/qiniu/sandbox/volume.js b/qiniu/sandbox/volume.js new file mode 100644 index 0000000..fd7d4c1 --- /dev/null +++ b/qiniu/sandbox/volume.js @@ -0,0 +1,17 @@ +const { NotImplementedError } = require('./errors'); + +function Volume () {} + +Volume.prototype.create = function () { + return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); +}; + +Volume.prototype.delete = function () { + return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); +}; + +Volume.prototype.list = function () { + return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); +}; + +exports.Volume = Volume; diff --git a/test/sandbox.test.js b/test/sandbox.test.js new file mode 100644 index 0000000..364c389 --- /dev/null +++ b/test/sandbox.test.js @@ -0,0 +1,1451 @@ +const should = require('should'); +const http = require('http'); + +const qiniu = require('../index'); + +function startServer (handler) { + const requests = []; + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', chunk => { + chunks.push(chunk); + }); + req.on('end', () => { + const rawBody = Buffer.concat(chunks); + const record = { + method: req.method, + url: req.url, + headers: req.headers, + body: rawBody.toString(), + rawBody + }; + requests.push(record); + handler(record, res); + }); + }); + + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + resolve({ + server, + requests, + endpoint: `http://127.0.0.1:${server.address().port}` + }); + }); + }); +} + +function closeServer (server) { + return new Promise(resolve => server.close(resolve)); +} + +function parseUrl (value) { + return new URL(value, 'http://127.0.0.1'); +} + +function decodeConnectEnvelope (body) { + body[0].should.eql(0); + const length = body.readUInt32BE(1); + return JSON.parse(body.slice(5, 5 + length).toString()); +} + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +describe('test sandbox module', function () { + it('creates sandbox with E2B compatible options and API key auth', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_1', + domain: 'sbx.local', + envdVersion: '0.0.1', + envdAccessToken: 'token' + })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return client.createSandbox({ + template: 'base', + timeoutMs: 15000, + metadata: { + user: 'alice' + }, + envs: { + FOO: 'bar' + }, + injections: [ + { + type: 'qiniu', + key: 'value' + } + ] + }).then(ret => { + should.equal(ret.sandboxID, 'sbx_1'); + fixture.requests.length.should.eql(1); + fixture.requests[0].method.should.eql('POST'); + fixture.requests[0].url.should.eql('/sandboxes'); + fixture.requests[0].headers['x-api-key'].should.eql('sandbox-key'); + fixture.requests[0].headers.authorization.should.eql('Bearer sandbox-key'); + fixture.requests[0].headers['content-type'].should.eql('application/json'); + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'base', + timeout: 15, + metadata: { + user: 'alice' + }, + envVars: { + FOO: 'bar' + }, + injections: [ + { + type: 'qiniu', + key: 'value' + } + ] + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('keeps Qiniu sandbox extensions in create body', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_qiniu', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + mcp: { enabled: true }, + injections: [{ injectionRuleID: 'rule_1' }], + resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] + }).then(() => { + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'base', + mcp: { enabled: true }, + injections: [{ injectionRuleID: 'rule_1' }], + resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes E2B style Sandbox.create and kill helpers', function () { + return startServer((req, res) => { + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_2', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + return; + } + + if (req.method === 'DELETE' && req.url === '/sandboxes/sbx_2') { + res.statusCode = 204; + res.end(); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + template: 'base' + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_2'); + return sandbox.kill(); + }).then(() => { + fixture.requests.map(req => `${req.method} ${req.url}`).should.eql([ + 'POST /sandboxes', + 'DELETE /sandboxes/sbx_2' + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exports E2B style top-level Sandbox and client classes', function () { + qiniu.Sandbox.should.equal(qiniu.sandbox.Sandbox); + qiniu.SandboxClient.should.equal(qiniu.sandbox.SandboxClient); + qiniu.CommandExitError.should.equal(qiniu.sandbox.CommandExitError); + }); + + it('supports Sandbox.create(template, opts) overload', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_template', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create('nodejs', { + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + metadata: { + source: 'e2b-overload' + } + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_template'); + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'nodejs', + metadata: { + source: 'e2b-overload' + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes typed sandbox compatibility errors', function () { + const err = new qiniu.sandbox.CommandExitError({ + command: 'false', + exitCode: 1, + stdout: 'out', + stderr: 'err' + }); + + err.should.be.instanceOf(Error); + err.name.should.eql('CommandExitError'); + err.exitCode.should.eql(1); + err.stdout.should.eql('out'); + err.stderr.should.eql('err'); + new qiniu.sandbox.GitAuthError('bad credentials').name.should.eql('GitAuthError'); + new qiniu.sandbox.GitUpstreamError('missing upstream').name.should.eql('GitUpstreamError'); + new qiniu.sandbox.NotImplementedError('volume').name.should.eql('NotImplementedError'); + }); + + it('uses Qiniu AK/SK signing for injection rule APIs', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + id: 'rule_1', + name: 'openai', + injection: { + type: 'openai', + apiKey: 'secret' + } + })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + mac, + endpoint: fixture.endpoint + }); + + return client.createInjectionRule({ + name: 'openai', + injection: { + type: 'openai', + apiKey: 'secret' + } + }).then(() => { + fixture.requests[0].method.should.eql('POST'); + fixture.requests[0].url.should.eql('/injection-rules'); + should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); + should.not.exist(fixture.requests[0].headers['x-api-key']); + JSON.parse(fixture.requests[0].body).should.eql({ + name: 'openai', + injection: { + type: 'openai', + api_key: 'secret' + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('builds envd hosts and signed file urls', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_3', + info: { + domain: 'sandbox.example.com', + envdAccessToken: 'token' + } + }); + + sandbox.getHost(8080).should.eql('8080-sbx_3.sandbox.example.com'); + + const parsed = parseUrl(sandbox.downloadUrl('/home/user/a.txt', { + user: 'admin', + signatureExpiration: 60 + })); + parsed.protocol.should.eql('https:'); + parsed.host.should.eql('49983-sbx_3.sandbox.example.com'); + parsed.pathname.should.eql('/files'); + parsed.searchParams.get('path').should.eql('/home/user/a.txt'); + parsed.searchParams.get('username').should.eql('admin'); + parsed.searchParams.get('signature_expiration').should.eql('60'); + should(parsed.searchParams.get('signature')).startWith('v1_'); + }); + + it('reads and writes files through envd HTTP API', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/hello.txt'); + parsed.searchParams.get('username').should.eql('user'); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('hello'); + return; + } + + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/hello.txt'); + should(req.headers['content-type']).startWith('multipart/form-data; boundary='); + req.body.should.containEql('hello'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'hello.txt', path: '/hello.txt', type: 'file' }])); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_4', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.readText('/hello.txt').then(text => { + text.should.eql('hello'); + return sandbox.files.write('/hello.txt', 'hello'); + }).then(info => { + info.path.should.eql('/hello.txt'); + fixture.requests.map(req => `${req.method} ${parseUrl(req.url).pathname}`).should.eql([ + 'GET /files', + 'POST /files' + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('reads files as text, bytes, blob, and stream formats', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(Buffer.from('hello')); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_files', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/tmp/a.txt') + .then(text => { + text.should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'bytes' }); + }) + .then(bytes => { + Buffer.isBuffer(bytes).should.eql(true); + bytes.toString().should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'stream' }); + }) + .then(stream => { + (typeof stream.pipe).should.eql('function'); + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString())); + stream.on('error', reject); + }); + }) + .then(streamText => { + streamText.should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'blob' }); + }) + .then(blob => { + if (typeof global.Blob !== 'undefined') { + blob.should.be.instanceOf(global.Blob); + } else { + Buffer.isBuffer(blob).should.eql(true); + } + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses Connect RPC paths for filesystem metadata operations', function () { + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/filesystem.Filesystem/Stat') { + res.end(JSON.stringify({ + entry: { name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 } + })); + return; + } + if (req.url === '/filesystem.Filesystem/ListDir') { + res.end(JSON.stringify({ + entries: [{ name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 }] + })); + return; + } + if (req.url === '/filesystem.Filesystem/MakeDir') { + res.end(JSON.stringify({ + entry: { name: 'tmp', path: '/tmp', type: 'FILE_TYPE_DIRECTORY' } + })); + return; + } + if (req.url === '/filesystem.Filesystem/Move') { + res.end(JSON.stringify({ + entry: { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } + })); + return; + } + if (req.url === '/filesystem.Filesystem/Remove') { + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_5', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.getInfo('/hello.txt') + .then(info => { + info.type.should.eql('file'); + return sandbox.files.list('/'); + }) + .then(entries => { + entries[0].name.should.eql('hello.txt'); + return sandbox.files.makeDir('/tmp'); + }) + .then(info => { + info.type.should.eql('dir'); + return sandbox.files.rename('/a.txt', '/b.txt'); + }) + .then(info => { + info.path.should.eql('/b.txt'); + return sandbox.files.remove('/b.txt'); + }) + .then(() => { + fixture.requests.map(req => req.url).should.eql([ + '/filesystem.Filesystem/Stat', + '/filesystem.Filesystem/ListDir', + '/filesystem.Filesystem/MakeDir', + '/filesystem.Filesystem/Move', + '/filesystem.Filesystem/Remove' + ]); + fixture.requests[0].headers.authorization.should.eql('Basic dXNlcjo='); + fixture.requests[0].headers['x-access-token'].should.eql('token'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('runs commands and git operations through process RPC', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.cmd.should.eql('/bin/bash'); + body.process.args.should.eql(['-l', '-c', 'git status --porcelain=v1 -b']); + body.process.cwd.should.eql('/repo'); + req.headers['content-type'].should.eql('application/connect+json'); + req.headers['keepalive-ping-interval'].should.eql('50'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 101 } } }), + encodeConnectEnvelope({ event: { data: { stdout: '## main\\n M a.txt\\n?? b.txt\\n' } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.git.status('/repo').then(status => { + status.currentBranch.should.eql('main'); + status.changedFiles.should.eql(['a.txt']); + status.untrackedFiles.should.eql(['b.txt']); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('passes git config options to git commands', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.args.should.eql([ + '-l', + '-c', + 'git -c \'http.version=HTTP/1.1\' clone \'https://example.com/repo.git\' --depth \'1\' --branch \'main\' \'/repo\'' + ]); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 103 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6c', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.git.clone('https://example.com/repo.git', { + path: '/repo', + depth: 1, + branch: 'main', + config: { + 'http.version': 'HTTP/1.1' + } + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('decodes base64 process byte fields from Connect JSON', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 102 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('hello sandbox').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6b', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('cat /tmp/hello.txt') + .then(result => { + result.stdout.should.eql('hello sandbox'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('maps sandbox lifecycle and metrics APIs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/v2/sandboxes?metadata=user%3Dalice&state=running%2Cpaused&limit=10&nextToken=n1') { + res.statusCode = 200; + res.end(JSON.stringify([{ sandboxID: 'sbx_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_1', state: 'running' })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_1', envdAccessToken: 'token' })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/timeout') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/refreshes') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/pause') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/metrics?start=1&end=2') { + res.statusCode = 200; + res.end(JSON.stringify([{ cpuCount: 1 }])); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/logs?start=10&limit=20') { + res.statusCode = 200; + res.end(JSON.stringify({ logs: [] })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/metrics?sandbox_ids=sbx_1%2Csbx_2') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxes: [] })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return client.list({ + metadata: 'user=alice', + state: ['running', 'paused'], + limit: 10, + nextToken: 'n1' + }).then(() => client.getInfo('sbx_1')) + .then(() => client.connect('sbx_1', { timeoutMs: 20000 })) + .then(() => client.setTimeout('sbx_1', { timeoutMs: 30000 })) + .then(() => client.refreshSandbox('sbx_1', { duration: 60 })) + .then(() => client.pauseSandbox('sbx_1')) + .then(() => client.getMetrics('sbx_1', { start: 1, end: 2 })) + .then(() => client.getLogs('sbx_1', { start: 10, limit: 20 })) + .then(() => client.getSandboxesMetrics(['sbx_1', 'sbx_2'])) + .then(() => { + JSON.parse(fixture.requests[3].body).should.eql({ timeout: 30 }); + JSON.parse(fixture.requests[4].body).should.eql({ duration: 60 }); + fixture.requests.every(req => req.headers['x-api-key'] === 'sandbox-key').should.eql(true); + fixture.requests.every(req => req.headers.authorization === 'Bearer sandbox-key').should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('maps template, build, tag, and access-token APIs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/templates?teamID=team') { + res.statusCode = 200; + res.end(JSON.stringify([{ templateID: 'tpl_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/default-templates') { + res.statusCode = 200; + res.end(JSON.stringify([{ templateID: 'base' }])); + return; + } + if (req.method === 'POST' && req.url === '/v3/templates') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b1' })); + return; + } + if (req.method === 'POST' && req.url === '/v2/templates') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_2', buildID: 'b2' })); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1?limit=5&nextToken=n2') { + res.statusCode = 200; + res.end(JSON.stringify({ templateID: 'tpl_1' })); + return; + } + if (req.method === 'PATCH' && req.url === '/templates/tpl_1') { + res.statusCode = 200; + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === 'DELETE' && req.url === '/templates/tpl_1') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/files/hash') { + res.statusCode = 201; + res.end(JSON.stringify({ url: 'https://upload' })); + return; + } + if (req.method === 'POST' && req.url === '/v2/templates/tpl_1/builds/b1') { + res.statusCode = 202; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/status?logsOffset=1&limit=2&level=info') { + res.statusCode = 200; + res.end(JSON.stringify({ status: 'ready' })); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/logs?cursor=1&limit=2&direction=asc&level=info&source=builder') { + res.statusCode = 200; + res.end(JSON.stringify({ logs: [] })); + return; + } + if (req.method === 'POST' && req.url === '/templates/tags') { + res.statusCode = 201; + res.end(JSON.stringify({ tags: ['v1'] })); + return; + } + if (req.method === 'DELETE' && req.url === '/templates/tags') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/aliases/base') { + res.statusCode = 200; + res.end(JSON.stringify({ exists: true })); + return; + } + if (req.method === 'POST' && req.url === '/templates/tpl_1') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b3' })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + accessToken: 'access-token', + endpoint: fixture.endpoint + }); + + return client.listTemplates({ teamID: 'team' }) + .then(() => client.listDefaultTemplates()) + .then(() => client.createTemplate({ name: 'node:v1' })) + .then(() => client.createTemplateV2({ alias: 'old' })) + .then(() => client.getTemplate('tpl_1', { limit: 5, nextToken: 'n2' })) + .then(() => client.updateTemplate('tpl_1', { public: true })) + .then(() => client.deleteTemplate('tpl_1')) + .then(() => client.getTemplateFiles('tpl_1', 'hash')) + .then(() => client.startTemplateBuild('tpl_1', 'b1', { cpuCount: 2 })) + .then(() => client.getTemplateBuildStatus('tpl_1', 'b1', { logsOffset: 1, limit: 2, level: 'info' })) + .then(() => client.getTemplateBuildLogs('tpl_1', 'b1', { + cursor: 1, + limit: 2, + direction: 'asc', + level: 'info', + source: 'builder' + })) + .then(() => client.assignTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) + .then(() => client.deleteTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) + .then(() => client.getTemplateByAlias('base')) + .then(() => client.rebuildTemplate('tpl_1', { name: 'again' })) + .then(() => { + JSON.parse(fixture.requests[2].body).should.eql({ name: 'node:v1' }); + JSON.parse(fixture.requests[5].body).should.eql({ public: true }); + fixture.requests[14].headers.authorization.should.eql('Bearer access-token'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('builds templates through an E2B style Template facade', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + templateID: 'tpl_1', + buildID: 'bld_1', + status: 'building' + })); + }).then(fixture => { + const template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['git']) + .runCmd('node --version') + .copy('/src', '/app') + .setStartCmd('node server.js') + .setReadyCmd('curl -f http://localhost:3000/health'); + + return template.build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'node-template:test' + }).then(result => { + result.templateID.should.eql('tpl_1'); + const body = JSON.parse(fixture.requests[0].body); + body.name.should.eql('node-template:test'); + body.buildConfig.fromImage.should.eql('ubuntu:22.04'); + body.buildConfig.steps.should.eql([ + { type: 'apt', packages: ['git'] }, + { type: 'run', cmd: 'node --version' }, + { type: 'copy', src: '/src', dest: '/app' } + ]); + body.buildConfig.startCmd.should.eql('node server.js'); + body.buildConfig.readyCmd.should.eql('curl -f http://localhost:3000/health'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes network constants and maps updateNetwork to Qiniu API', function () { + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_net', + network: JSON.parse(req.body).network + })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_net', + client, + info: {} + }); + qiniu.sandbox.ALL_TRAFFIC.should.eql('0.0.0.0/0'); + return sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }) + .then(info => { + info.network.allowOut[0].should.eql('0.0.0.0/0'); + fixture.requests[0].method.should.eql('PATCH'); + fixture.requests[0].url.should.eql('/sandboxes/sbx_net'); + JSON.parse(fixture.requests[0].body).should.eql({ + network: { + allowOut: ['0.0.0.0/0'] + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns typed unsupported errors for E2B volume compatibility', function () { + const volume = new qiniu.sandbox.Volume(); + return volume.create().then(() => { + throw new Error('expected volume.create to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + err.message.should.containEql('Volume'); + }); + }); + + it('maps injection rule CRUD APIs with Qiniu signing', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/injection-rules') { + res.statusCode = 200; + res.end(JSON.stringify([{ id: 'rule_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/injection-rules/rule_1') { + res.statusCode = 200; + res.end(JSON.stringify({ id: 'rule_1' })); + return; + } + if (req.method === 'PUT' && req.url === '/injection-rules/rule_1') { + res.statusCode = 200; + res.end(JSON.stringify({ id: 'rule_1', name: 'updated' })); + return; + } + if (req.method === 'DELETE' && req.url === '/injection-rules/rule_1') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + mac, + endpoint: fixture.endpoint + }); + + return client.listInjectionRules() + .then(() => client.getInjectionRule('rule_1')) + .then(() => client.updateInjectionRule('rule_1', { name: 'updated' })) + .then(() => client.deleteInjectionRule('rule_1')) + .then(() => { + fixture.requests.length.should.eql(4); + fixture.requests.forEach(req => { + should(req.headers.authorization).startWith('Qiniu ak:'); + }); + JSON.parse(fixture.requests[2].body).should.eql({ name: 'updated' }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers filesystem writeFiles, bytes, exists false, and envd health', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(Buffer.from([1, 2, 3])); + return; + } + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path') === null ? true.should.eql(true) : false.should.eql(true); + req.body.should.containEql('/a.txt'); + req.body.should.containEql('/b.txt'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([ + { name: 'a.txt', path: '/a.txt', type: 'FILE_TYPE_FILE' }, + { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } + ])); + return; + } + if (req.method === 'POST' && req.url === '/filesystem.Filesystem/Stat') { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'not found' })); + return; + } + if (req.method === 'GET' && req.url === '/health') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 500; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_7', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/bin', { format: 'bytes' }) + .then(data => { + Buffer.isBuffer(data).should.eql(true); + data.length.should.eql(3); + return sandbox.files.writeFiles([ + { path: '/a.txt', data: 'a' }, + { path: '/b.txt', data: 'b' } + ]); + }) + .then(entries => { + entries.map(entry => entry.path).should.eql(['/a.txt', '/b.txt']); + return sandbox.files.exists('/missing.txt'); + }) + .then(exists => { + exists.should.eql(false); + return sandbox.isRunning(); + }) + .then(running => { + running.should.eql(true); + sandbox.getHost(1234).should.eql(''); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers command management, callbacks, background handles, pty, and git wrappers', function () { + const commandsSeen = []; + const fakeCommands = { + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ + exitCode: 0, + stdout: 'value\n', + stderr: '' + }); + }, + start: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ pid: 9, wait: function () {} }); + } + }; + const git = new qiniu.sandbox.Git(fakeCommands); + const pty = new qiniu.sandbox.Pty({ commands: fakeCommands }); + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 12 } } }), + encodeConnectEnvelope({ event: { data: { stdout: [111, 117, 116], stderr: [101, 114, 114] } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 2, error: 'boom' } } }) + ])); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/process.Process/List') { + res.end(JSON.stringify({ + processes: [ + { pid: 1, tag: 't', config: { cmd: 'bash', args: ['-l'], envs: { A: '1' }, cwd: '/w' } } + ] + })); + return; + } + res.end('{}'); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_8', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const seen = []; + + return sandbox.commands.run('echo hi', { + cwd: '/work', + envs: { A: '1' }, + tag: 'tag1', + stdin: true, + onStdout: data => seen.push('out:' + data), + onStderr: data => seen.push('err:' + data) + }).then(result => { + result.exitCode.should.eql(2); + result.error.should.eql('boom'); + seen.should.eql(['out:out', 'err:err']); + const firstStartBody = decodeConnectEnvelope(fixture.requests[0].rawBody); + firstStartBody.process.cwd.should.eql('/work'); + firstStartBody.process.envs.should.eql({ A: '1' }); + firstStartBody.tag.should.eql('tag1'); + firstStartBody.stdin.should.eql(true); + return sandbox.commands.run('sleep 1', { background: true }); + }).then(handle => { + handle.pid.should.eql(12); + return sandbox.commands.list(); + }).then(list => { + list[0].cwd.should.eql('/w'); + return sandbox.commands.sendStdin(12, 'hello'); + }).then(() => sandbox.commands.closeStdin(12)) + .then(() => sandbox.commands.kill(12)) + .then(() => handleGitAndPty(git, pty, commandsSeen)) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('branch --format') >= 0) { + return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); + } + if (cmd.indexOf('remote get-url origin') >= 0) { + return Promise.resolve({ stdout: 'https://github.com/acme/repo.git\n', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://github.com/acme/repo.git', '/repo', { + username: 'u', + password: 'p', + depth: 1, + branch: 'main' + }).then(() => git.branches('/repo')) + .then(branches => { + branches.should.eql([ + { name: 'main', current: true }, + { name: 'feature', current: false } + ]); + return git.reset('/repo', { hard: true, ref: 'HEAD~1' }); + }) + .then(() => git.restore('/repo', { staged: true, paths: ['a.txt'] })) + .then(() => git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git', { overwrite: true, fetch: true })) + .then(() => git.commit('/repo', 'msg', { + authorName: 'Alice', + authorEmail: 'alice@example.com', + allowEmpty: true + })) + .then(() => git.setConfig('/repo', 'user.name', 'Alice', { scope: 'global' })) + .then(() => { + const commandText = commandsSeen.map(item => item.cmd).join('\n'); + commandText.should.containEql('clone \'https://u:p@github.com/acme/repo.git\''); + commandText.should.containEql('remote set-url origin \'https://github.com/acme/repo.git\''); + commandText.should.containEql('branch --format'); + commandText.should.containEql('reset --hard \'HEAD~1\''); + commandText.should.containEql('restore --staged -- \'a.txt\''); + commandText.should.containEql('remote remove \'origin\''); + commandText.should.containEql('remote add \'origin\''); + commandText.should.containEql('fetch \'origin\''); + commandText.should.containEql('commit -m \'msg\' --author \'Alice \' --allow-empty'); + commandText.should.containEql('config --global \'user.name\' \'Alice\''); + }); + }); + + it('covers Sandbox.connect, Sandbox.list, wait polling, and stopped health checks', function () { + let infoCalls = 0; + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes/sbx_9/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' })); + return; + } + if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=1') { + res.statusCode = 200; + res.end(JSON.stringify([{ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' }])); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.end(JSON.stringify({ sandboxID: 'sbx_10', envdAccessToken: 'token' })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_10') { + infoCalls += 1; + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_10', state: infoCalls > 1 ? 'running' : 'pending' })); + return; + } + if (req.method === 'GET' && req.url === '/health') { + res.statusCode = 502; + res.end(JSON.stringify({ message: 'stopped' })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return qiniu.sandbox.Sandbox.connect('sbx_9', { + client, + timeout: 12 + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_9'); + sandbox.envdAccessToken.should.eql('token'); + return qiniu.sandbox.Sandbox.list({ client, limit: 1 }); + }).then(sandboxes => { + sandboxes[0].sandboxId.should.eql('sbx_9'); + return client.createAndWait({ template: 'base' }, { interval: 1, timeout: 100 }); + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_10'); + sandbox.envdAccessToken.should.eql('token'); + infoCalls.should.eql(2); + const stopped = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_11', + envdUrl: fixture.endpoint, + info: {} + }); + return stopped.isRunning(); + }).then(running => { + running.should.eql(false); + return closeServer(fixture.server); + }, err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports JSON fallback for process stream responses and poll timeout errors', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + events: [ + { event: { start: { pid: 22 } } }, + { event: { data: { stdout: 'ok' } } }, + { event: { end: { exitCode: 0 } } } + ] + })); + return; + } + if (req.url === '/sandboxes/sbx_pending') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_pending', state: 'pending' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pending', + envdUrl: fixture.endpoint, + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.commands.run('echo ok') + .then(result => { + result.stdout.should.eql('ok'); + return sandbox.waitForReady({ interval: 1, timeout: 5 }); + }) + .then(() => { + throw new Error('expected waitForReady timeout'); + }, err => { + err.message.should.eql('Sandbox poll timed out'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B command timeout aliases and optional exit throwing', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 77 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('out').toString('base64'), stderr: Buffer.from('err').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 7 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd', + envdUrl: fixture.endpoint, + envdAccessToken: 'token', + info: {} + }); + + return sandbox.commands.run('false', { + requestTimeoutMs: 12000 + }).then(result => { + result.exitCode.should.eql(7); + result.stdout.should.eql('out'); + result.stderr.should.eql('err'); + return sandbox.commands.run('false', { + requestTimeoutMs: 12000, + throwOnError: true + }); + }).then(() => { + throw new Error('expected command to throw'); + }, err => { + err.name.should.eql('CommandExitError'); + err.exitCode.should.eql(7); + err.stdout.should.eql('out'); + err.stderr.should.eql('err'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('normalizes snake_case sandbox info and camelCase injection inputs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.end(JSON.stringify({ + sandbox_id: 'sbx_snake', + sandbox_domain: 'snake.example.com' + })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_snake') { + res.statusCode = 200; + res.end(JSON.stringify({ + sandbox_id: 'sbx_snake', + domain: 'snake.example.com', + envd_access_token: 'snake-token', + envd_version: '1.2.3' + })); + return; + } + if (req.method === 'POST' && req.url === '/injection-rules') { + res.statusCode = 201; + res.end(JSON.stringify({ ruleID: 'rule_1' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key', + mac + }); + + return qiniu.sandbox.Sandbox.create({ client, template: 'base' }) + .then(sandbox => { + sandbox.sandboxId.should.eql('sbx_snake'); + sandbox.envdAccessToken.should.eql('snake-token'); + sandbox.envdVersion.should.eql('1.2.3'); + return client.createInjectionRule({ + name: 'qiniu', + injection: { + type: 'qiniu', + baseUrl: 'https://api.qnaigc.com', + apiKey: 'secret' + } + }); + }) + .then(() => { + JSON.parse(fixture.requests[2].body).should.eql({ + name: 'qiniu', + injection: { + type: 'qiniu', + base_url: 'https://api.qnaigc.com', + api_key: 'secret' + } + }); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); +}); + +function handleGitAndPty (git, pty, commandsSeen) { + return git.clone('https://example.com/repo.git', { path: '/repo' }) + .then(() => git.init('/repo')) + .then(() => git.add('/repo', { all: true })) + .then(() => git.commit('/repo', 'msg', { allowEmpty: true })) + .then(() => git.pull('/repo', { remote: 'origin', branch: 'main' })) + .then(() => git.push('/repo', { remote: 'origin', branch: 'main' })) + .then(() => git.createBranch('/repo', 'feature')) + .then(() => git.checkoutBranch('/repo', 'main')) + .then(() => git.deleteBranch('/repo', 'feature', { force: true })) + .then(() => git.remoteAdd('/repo', 'origin', 'https://example.com/repo.git')) + .then(() => git.remoteGet('/repo', 'origin')) + .then(value => { + value.should.eql('value'); + return git.setConfig('/repo', 'user.name', 'Alice'); + }) + .then(() => git.getConfig('/repo', 'user.name')) + .then(value => { + value.should.eql('value'); + return git.configureUser('/repo', 'Alice', 'alice@example.com'); + }) + .then(() => pty.create({ cmd: 'bash', cwd: '/repo' })) + .then(handle => { + handle.pid.should.eql(9); + commandsSeen[0].cmd.should.eql('git clone \'https://example.com/repo.git\' \'/repo\''); + commandsSeen[1].cmd.should.eql('git init'); + commandsSeen[1].opts.cwd.should.eql('/repo'); + commandsSeen.some(item => item.cmd.indexOf('git commit -m') === 0).should.eql(true); + commandsSeen[commandsSeen.length - 1].cmd.should.eql('bash'); + commandsSeen[commandsSeen.length - 1].opts.stdin.should.eql(true); + }); +} diff --git a/test/sandbox_integration.test.js b/test/sandbox_integration.test.js new file mode 100644 index 0000000..a592095 --- /dev/null +++ b/test/sandbox_integration.test.js @@ -0,0 +1,281 @@ +const fs = require('fs'); +const path = require('path'); +const should = require('should'); + +const qiniu = require('../index'); +const { shellQuote } = require('../qiniu/sandbox/util'); + +function loadDotEnvIfPresent () { + const filepath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(filepath)) { + return; + } + + fs.readFileSync(filepath, 'utf8') + .split(/\r?\n/) + .forEach(line => { + line = line.trim(); + if (!line || line[0] === '#') { + return; + } + const index = line.indexOf('='); + if (index < 0) { + return; + } + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ( + (value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === '\'' && value[value.length - 1] === '\'') + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + }); +} + +loadDotEnvIfPresent(); + +function truthy (value) { + return ['1', 'true', 'yes', 'on'].indexOf(String(value || '').toLowerCase()) >= 0; +} + +function integrationLog () { + if (!truthy(process.env.QINIU_SANDBOX_INTEGRATION_VERBOSE)) { + return; + } + const args = Array.prototype.slice.call(arguments); + args.unshift('[sandbox integration]'); + console.log.apply(console, args); +} + +function integrationConfig () { + return { + enabled: truthy(process.env.QINIU_SANDBOX_INTEGRATION), + endpoint: process.env.QINIU_SANDBOX_ENDPOINT, + apiKey: process.env.QINIU_SANDBOX_API_KEY, + template: process.env.QINIU_SANDBOX_TEMPLATE || 'base', + accessKey: process.env.QINIU_SANDBOX_ACCESS_KEY, + secretKey: process.env.QINIU_SANDBOX_SECRET_KEY, + testInjectionRules: truthy(process.env.QINIU_SANDBOX_TEST_INJECTION_RULES), + gitRepoUrl: process.env.GIT_REPO_URL, + gitUsername: process.env.GIT_USERNAME, + gitPassword: process.env.GIT_PASSWORD + }; +} + +const config = integrationConfig(); +const describeIntegration = config.enabled && config.apiKey ? describe : describe.skip; + +function authedGitUrl (repoUrl, username, password) { + const parsed = new URL(repoUrl); + parsed.username = username; + parsed.password = password; + return parsed.toString(); +} + +function scrubSecrets (text) { + text = String(text || ''); + [config.gitPassword, config.gitUsername].forEach(secret => { + if (secret) { + text = text.split(secret).join('[redacted]'); + text = text.split(encodeURIComponent(secret)).join('[redacted]'); + } + }); + return text.replace(/https?:\/\/[^@\s]+@/g, 'https://[redacted]@'); +} + +function hasGitCredentials () { + return Boolean(config.gitRepoUrl && config.gitUsername && config.gitPassword); +} + +function exerciseRemoteGitIfConfigured (sandbox, runID) { + if (!hasGitCredentials()) { + integrationLog('skip remote git: set GIT_REPO_URL, GIT_USERNAME, and GIT_PASSWORD to run'); + return Promise.resolve(); + } + + const cloneDir = `/tmp/${runID}-clone`; + const cloneUrl = authedGitUrl(config.gitRepoUrl, config.gitUsername, config.gitPassword); + const branch = `nodejs-sdk-it-${runID}`; + const pushedFile = `${cloneDir}/qiniu-nodejs-sdk-integration-${runID}.txt`; + const gitOptions = { + timeout: 120000, + config: { + 'http.version': 'HTTP/1.1' + } + }; + + integrationLog('cloning configured git repository', cloneDir); + return sandbox.git.clone(cloneUrl, Object.assign({}, gitOptions, { + path: cloneDir, + depth: 1 + })).then(result => { + if (result.exitCode !== 0) { + throw new Error(`git clone failed with exit ${result.exitCode}: ${scrubSecrets(result.stderr || result.stdout)}`); + } + result.exitCode.should.eql(0); + integrationLog('cloned configured git repository', cloneDir); + return sandbox.git.configureUser(cloneDir, 'qiniu-nodejs-sdk', 'qiniu-nodejs-sdk@example.com', gitOptions); + }).then(result => { + result.exitCode.should.eql(0); + return sandbox.git.createBranch(cloneDir, branch, gitOptions); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('created git branch', branch); + return sandbox.files.write(pushedFile, `sandbox integration ${runID}\n`); + }).then(entry => { + entry.path.should.eql(pushedFile); + return sandbox.git.add(cloneDir, { + files: [path.basename(pushedFile)], + timeout: gitOptions.timeout, + config: gitOptions.config + }); + }).then(result => { + result.exitCode.should.eql(0); + return sandbox.git.commit(cloneDir, `test: sandbox integration ${runID}`, gitOptions); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('committed git branch', branch); + return sandbox.git.push(cloneDir, Object.assign({}, gitOptions, { + remote: cloneUrl, + branch: `HEAD:refs/heads/${branch}` + })); + }).then(result => { + if (result.exitCode !== 0) { + throw new Error(`git push failed with exit ${result.exitCode}: ${scrubSecrets(result.stderr || result.stdout)}`); + } + result.exitCode.should.eql(0); + integrationLog('pushed git branch', branch); + return sandbox.commands.run(`git -C ${shellQuote(cloneDir)} remote set-url origin ${shellQuote(config.gitRepoUrl)}`); + }).then(result => { + result.exitCode.should.eql(0); + return sandbox.git.remoteGet(cloneDir, 'origin'); + }).then(remoteUrl => { + remoteUrl.should.eql(config.gitRepoUrl); + integrationLog('sanitized git remote url', cloneDir); + return sandbox.git.status(cloneDir); + }).then(status => { + status.raw.should.be.String(); + integrationLog('verified cloned git status', cloneDir); + }); +} + +describeIntegration('sandbox integration', function () { + this.timeout(600000); + + let sandbox; + + after(function () { + if (!sandbox) { + return null; + } + return sandbox.kill() + .catch(err => { + console.log('failed to cleanup sandbox', sandbox.sandboxId, err.message); + }); + }); + + it('creates a sandbox and exercises files, commands, and git', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: config.endpoint, + apiKey: config.apiKey + }); + const runID = `nodejs-sdk-${Date.now()}`; + const workdir = `/tmp/${runID}`; + const filePath = `${workdir}/hello.txt`; + + return qiniu.sandbox.Sandbox.create({ + client, + template: config.template, + timeout: 300, + metadata: { + sdk: 'qiniu-nodejs-sdk', + test: runID + } + }).then(created => { + sandbox = created; + sandbox.sandboxId.should.be.String(); + integrationLog('created sandbox', sandbox.sandboxId); + return sandbox.waitForReady({ + interval: 3000, + timeout: 180000 + }); + }).then(info => { + info.state.should.eql('running'); + integrationLog('sandbox ready', sandbox.sandboxId); + return sandbox.isRunning(); + }).then(running => { + running.should.eql(true); + integrationLog('envd health ok', sandbox.sandboxId); + return sandbox.commands.run(`mkdir -p ${workdir}`); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('created workdir', workdir); + return sandbox.files.write(filePath, 'hello sandbox'); + }).then(entry => { + entry.path.should.eql(filePath); + integrationLog('wrote file', filePath); + return sandbox.files.readText(filePath); + }).then(text => { + text.should.eql('hello sandbox'); + integrationLog('read file', filePath); + return sandbox.commands.run(`cat ${filePath}`); + }).then(result => { + result.exitCode.should.eql(0); + result.stdout.should.containEql('hello sandbox'); + integrationLog('ran cat command', filePath); + return sandbox.git.init(workdir); + }).then(result => { + result.exitCode.should.eql(0); + integrationLog('initialized git repo', workdir); + return sandbox.git.status(workdir); + }).then(status => { + status.raw.should.be.String(); + status.untrackedFiles.should.containEql('hello.txt'); + sandbox.getHost(8080).should.be.String(); + integrationLog('verified git status', workdir); + return exerciseRemoteGitIfConfigured(sandbox, runID); + }); + }); + + it('creates and deletes an injection rule when AK/SK integration is enabled', function () { + if (!config.testInjectionRules || !config.accessKey || !config.secretKey) { + this.skip(); + } + + const client = new qiniu.sandbox.SandboxClient({ + endpoint: config.endpoint, + apiKey: config.apiKey, + mac: new qiniu.auth.digest.Mac(config.accessKey, config.secretKey) + }); + const name = `nodejs-sdk-it-${Date.now()}`; + let ruleID; + + return client.createInjectionRule({ + name, + injection: { + type: 'openai', + apiKey: 'test-key' + } + }).then(rule => { + ruleID = rule.id || rule.ruleID || rule.ruleId; + should(ruleID).be.ok(); + return client.getInjectionRule(ruleID); + }).then(rule => { + rule.name.should.eql(name); + return client.updateInjectionRule(ruleID, { + name: name + '-updated' + }); + }).then(rule => { + rule.name.should.eql(name + '-updated'); + return client.deleteInjectionRule(ruleID); + }); + }); +}); + +if (!config.enabled || !config.apiKey) { + console.log('skip sandbox integration: set QINIU_SANDBOX_INTEGRATION=true and QINIU_SANDBOX_API_KEY to run'); +} From 86f9679d3a3faff6699cce86fd940e7494514165 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 14:54:25 +0800 Subject: [PATCH 02/48] test(sandbox): expand integration coverage and examples Add reusable sandbox environment configuration and product examples covering lifecycle, envd, git, resources, request injection, metrics, and templates. Expand sandbox tests for typed resources, Kodo signing, integration flows, and safer temporary git credentials. --- .env.example | 33 +++ examples/sandbox_common.js | 135 +++++++++++ examples/sandbox_create.js | 25 +++ examples/sandbox_envd.js | 68 ++++++ examples/sandbox_git.js | 177 +++++++++++++++ examples/sandbox_injection_rules.js | 85 +++++++ examples/sandbox_lifecycle.js | 58 +++++ examples/sandbox_list_connect.js | 39 ++++ examples/sandbox_metrics_logs.js | 37 ++++ examples/sandbox_request_injections.js | 61 +++++ examples/sandbox_resources.js | 121 ++++++++++ examples/sandbox_templates.js | 85 +++++++ index.d.ts | 19 +- qiniu/sandbox/client.js | 11 +- qiniu/sandbox/git.js | 7 +- test/sandbox.test.js | 296 ++++++++++++++++++++++++- test/sandbox_integration.test.js | 242 ++++++++++++++++---- test/sandbox_types.ts | 61 +++++ tsconfig.json | 1 + 19 files changed, 1516 insertions(+), 45 deletions(-) create mode 100644 .env.example create mode 100644 examples/sandbox_common.js create mode 100644 examples/sandbox_create.js create mode 100644 examples/sandbox_envd.js create mode 100644 examples/sandbox_git.js create mode 100644 examples/sandbox_injection_rules.js create mode 100644 examples/sandbox_lifecycle.js create mode 100644 examples/sandbox_list_connect.js create mode 100644 examples/sandbox_metrics_logs.js create mode 100644 examples/sandbox_request_injections.js create mode 100644 examples/sandbox_resources.js create mode 100644 examples/sandbox_templates.js create mode 100644 test/sandbox_types.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..20c939f --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Copy this file to .env in the project root before running sandbox examples +# or sandbox integration tests. + +# Sandbox API key auth. +QINIU_SANDBOX_API_KEY= + +# Optional custom endpoint. +QINIU_SANDBOX_ENDPOINT= + +# Optional template alias or ID. Defaults to base. +QINIU_SANDBOX_TEMPLATE=base + +# Required only for injection-rule and Kodo resource examples. +QINIU_SANDBOX_ACCESS_KEY= +QINIU_SANDBOX_SECRET_KEY= + +# Optional Git remote examples. +GIT_REPO_URL= +GIT_USERNAME= +GIT_PASSWORD= + +# Optional Git repository resource example. +GITHUB_TOKEN= +QINIU_SANDBOX_GIT_MOUNT_PATH=/workspace/repo + +# Optional Kodo resource example. +QINIU_SANDBOX_KODO_BUCKET= +QINIU_SANDBOX_KODO_MOUNT_PATH=/workspace/kodo +QINIU_SANDBOX_KODO_PREFIX= + +# Optional request injection examples. +QINIU_SANDBOX_HTTP_INJECTION_TOKEN=real_token +QINIU_SANDBOX_OPENAI_API_KEY= diff --git a/examples/sandbox_common.js b/examples/sandbox_common.js new file mode 100644 index 0000000..2e35760 --- /dev/null +++ b/examples/sandbox_common.js @@ -0,0 +1,135 @@ +const fs = require('fs'); +const path = require('path'); + +const qiniu = require('../index'); +const { shellQuote } = require('../qiniu/sandbox/util'); + +function loadDotEnvIfPresent () { + const files = [ + path.join(process.cwd(), '.env') + ]; + + files.forEach(filepath => { + if (!fs.existsSync(filepath)) { + return; + } + + fs.readFileSync(filepath, 'utf8') + .split(/\r?\n/) + .forEach(line => { + line = line.trim(); + if (!line || line[0] === '#') { + return; + } + const index = line.indexOf('='); + if (index < 0) { + return; + } + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ( + (value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === '\'' && value[value.length - 1] === '\'') + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + }); + }); +} + +function env (key, fallback) { + return process.env[key] || fallback; +} + +function requiredEnv (key) { + if (process.env[key]) { + return process.env[key]; + } + throw new Error(`Please set ${key}`); +} + +function sandboxEndpoint () { + return env('QINIU_SANDBOX_ENDPOINT'); +} + +function sandboxApiKey () { + return requiredEnv('QINIU_SANDBOX_API_KEY'); +} + +function sandboxTemplate () { + return env('QINIU_SANDBOX_TEMPLATE', 'base'); +} + +function sandboxClient (options) { + options = Object.assign({ + endpoint: sandboxEndpoint(), + apiKey: sandboxApiKey() + }, options || {}); + return new qiniu.sandbox.SandboxClient(options); +} + +function sandboxMac () { + const accessKey = requiredEnv('QINIU_SANDBOX_ACCESS_KEY'); + const secretKey = requiredEnv('QINIU_SANDBOX_SECRET_KEY'); + return new qiniu.auth.digest.Mac(accessKey, secretKey); +} + +function createSandboxAndWait (options, pollOptions) { + const client = options && options.client ? options.client : sandboxClient(); + const params = Object.assign({ + client, + template: sandboxTemplate(), + timeout: 300 + }, options || {}); + + return qiniu.sandbox.Sandbox.create(params).then(sandbox => { + console.log('Sandbox created:', sandbox.sandboxId); + return sandbox.waitForReady(Object.assign({ + interval: 3000, + timeout: 180000 + }, pollOptions || {})).then(info => { + console.log('Sandbox ready:', sandbox.sandboxId, info.state || ''); + return sandbox; + }); + }); +} + +function cleanupSandbox (sandbox) { + if (!sandbox) { + return Promise.resolve(); + } + return sandbox.kill() + .then(() => { + console.log('Sandbox killed:', sandbox.sandboxId); + }, err => { + console.log('Failed to kill sandbox:', sandbox.sandboxId, err.message); + }); +} + +function runExample (fn) { + loadDotEnvIfPresent(); + Promise.resolve() + .then(fn) + .catch(err => { + console.error(err && err.stack ? err.stack : err); + process.exitCode = 1; + }); +} + +module.exports = { + qiniu, + shellQuote, + env, + requiredEnv, + sandboxEndpoint, + sandboxApiKey, + sandboxTemplate, + sandboxClient, + sandboxMac, + createSandboxAndWait, + cleanupSandbox, + runExample +}; diff --git a/examples/sandbox_create.js b/examples/sandbox_create.js new file mode 100644 index 0000000..48b09d4 --- /dev/null +++ b/examples/sandbox_create.js @@ -0,0 +1,25 @@ +const { + qiniu, + sandboxClient, + sandboxTemplate, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return qiniu.sandbox.Sandbox.create(sandboxTemplate(), { + client, + timeout: 300, + metadata: { + example: 'sandbox_create' + } + }).then(created => { + sandbox = created; + console.log('Sandbox created:', sandbox.sandboxId); + console.log('Template:', sandbox.info.templateID || sandbox.info.template_id || sandboxTemplate()); + return cleanupSandbox(sandbox); + }); +}); diff --git a/examples/sandbox_envd.js b/examples/sandbox_envd.js new file mode 100644 index 0000000..62df866 --- /dev/null +++ b/examples/sandbox_envd.js @@ -0,0 +1,68 @@ +const { + shellQuote, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + const workdir = '/tmp/qiniu-nodejs-sdk-envd'; + const filePath = `${workdir}/hello.txt`; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_envd' + } + }).then(created => { + sandbox = created; + console.log('Public host for port 8080:', sandbox.getHost(8080)); + return sandbox.commands.run(`mkdir -p ${shellQuote(workdir)}`); + }).then(result => { + console.log('mkdir exit:', result.exitCode); + return sandbox.files.write(filePath, 'Hello from Qiniu Node.js SDK\n'); + }).then(entry => { + console.log('Wrote:', entry.path || entry); + return sandbox.files.readText(filePath); + }).then(text => { + console.log('ReadText:', JSON.stringify(text)); + return sandbox.files.writeFiles([ + { path: `${workdir}/batch-a.txt`, data: 'file A content\n' }, + { path: `${workdir}/batch-b.txt`, data: 'file B content\n' } + ]); + }).then(entries => { + console.log('WriteFiles:', entries.map(item => item.path)); + return sandbox.files.read(`${workdir}/batch-a.txt`, { format: 'bytes' }); + }).then(bytes => { + console.log('Read bytes:', bytes.length); + return sandbox.files.list(workdir); + }).then(entries => { + console.log('List:', entries.map(item => `${item.type}:${item.name}`)); + return sandbox.files.exists(filePath); + }).then(exists => { + console.log('Exists:', exists); + return sandbox.files.getInfo(filePath); + }).then(info => { + console.log('GetInfo:', info.name, info.size); + return sandbox.files.rename(`${workdir}/batch-b.txt`, `${workdir}/batch-b-renamed.txt`); + }).then(info => { + console.log('Renamed to:', info.path); + return sandbox.files.remove(`${workdir}/batch-b-renamed.txt`); + }).then(() => { + console.log('Removed renamed file'); + return sandbox.commands.run('echo $MY_VAR && pwd', { + cwd: workdir, + envs: { + MY_VAR: 'sandbox-value' + } + }); + }).then(result => { + console.log('Command exit:', result.exitCode); + console.log('stdout:\n' + result.stdout); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_git.js b/examples/sandbox_git.js new file mode 100644 index 0000000..1cb0e07 --- /dev/null +++ b/examples/sandbox_git.js @@ -0,0 +1,177 @@ +const path = require('path'); + +const { + env, + shellQuote, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +function remoteGitConfigured () { + return gitRepoUrl() && gitUsername() && gitPassword(); +} + +function gitRepoUrl () { + return env('GIT_REPO_URL'); +} + +function gitUsername () { + return env('GIT_USERNAME'); +} + +function gitPassword () { + return env('GIT_PASSWORD'); +} + +function assertGitOK (step, result) { + if (!result || result.exitCode !== 0) { + throw new Error(`${step} failed with exit ${result && result.exitCode}: ${(result && (result.stderr || result.stdout)) || ''}`); + } + console.log(`${step}:`, result.exitCode); + return result; +} + +runExample(() => { + let sandbox; + let defaultBranch = 'master'; + const repoPath = '/tmp/qiniu-nodejs-sdk-git/repo'; + const remotePath = '/tmp/qiniu-nodejs-sdk-git/remote.git'; + const clonePath = '/tmp/qiniu-nodejs-sdk-git/clone'; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_git' + } + }).then(created => { + sandbox = created; + return sandbox.commands.run('mkdir -p /tmp/qiniu-nodejs-sdk-git /tmp/qiniu-nodejs-sdk-git/repo /tmp/qiniu-nodejs-sdk-git/remote.git'); + }).then(() => { + return sandbox.git.init(repoPath); + }).then(result => { + assertGitOK('git init', result); + return sandbox.git.init(remotePath, { bare: true }); + }).then(result => { + assertGitOK('git init --bare', result); + return sandbox.git.configureUser(repoPath, 'Sandbox Demo', 'sandbox-demo@example.com'); + }).then(result => { + assertGitOK('configure user', result); + return sandbox.files.write(`${repoPath}/README.md`, '# sandbox git demo\n'); + }).then(() => { + return sandbox.git.add(repoPath, { all: true }); + }).then(result => { + assertGitOK('git add', result); + return sandbox.git.commit(repoPath, 'feat: initial commit', { + authorName: 'Sandbox Demo', + authorEmail: 'sandbox-demo@example.com' + }); + }).then(result => { + assertGitOK('commit', result); + return sandbox.git.status(repoPath); + }).then(status => { + defaultBranch = status.currentBranch || defaultBranch; + return sandbox.git.remoteAdd(repoPath, 'origin', remotePath, { overwrite: true }); + }).then(() => { + return sandbox.git.push(repoPath, { remote: 'origin', branch: defaultBranch }); + }).then(result => { + assertGitOK('push local bare remote', result); + return sandbox.git.clone(remotePath, { path: clonePath }); + }).then(result => { + assertGitOK('clone local bare remote', result); + return sandbox.git.branches(clonePath); + }).then(branches => { + console.log('branches:', branches); + return sandbox.git.configureUser(clonePath, 'Sandbox Demo', 'sandbox-demo@example.com'); + }).then(result => { + assertGitOK('configure clone user', result); + return sandbox.git.createBranch(clonePath, 'feature/example'); + }).then(result => { + assertGitOK('create branch', result); + return sandbox.files.write(`${clonePath}/feature.txt`, 'hello feature\n'); + }).then(() => { + return sandbox.git.add(clonePath, { files: ['feature.txt'] }); + }).then(result => { + assertGitOK('git add feature', result); + return sandbox.git.commit(clonePath, 'feat: add feature file', { allowEmpty: false }); + }).then(result => { + assertGitOK('commit feature', result); + return sandbox.git.status(clonePath); + }).then(status => { + console.log('status raw:\n' + status.raw); + return sandbox.git.checkoutBranch(clonePath, defaultBranch); + }).then(result => { + assertGitOK('checkout default branch', result); + return sandbox.git.deleteBranch(clonePath, 'feature/example', { force: true }); + }).then(result => { + assertGitOK('delete branch', result); + return sandbox.files.write(`${clonePath}/dirty.txt`, 'dirty\n'); + }).then(() => { + return sandbox.git.add(clonePath, { files: ['dirty.txt'] }); + }).then(result => { + assertGitOK('git add dirty', result); + return sandbox.git.restore(clonePath, { staged: true, paths: ['dirty.txt'] }); + }).then(result => { + assertGitOK('restore staged', result); + return sandbox.git.reset(clonePath, { hard: true, ref: 'HEAD' }); + }).then(result => { + assertGitOK('reset hard', result); + if (!remoteGitConfigured()) { + console.log('Skip HTTPS clone: set GIT_REPO_URL, GIT_USERNAME and GIT_PASSWORD.'); + return null; + } + + const remoteRepo = gitRepoUrl(); + const remoteClonePath = '/tmp/qiniu-nodejs-sdk-git/remote-clone'; + const remoteGitOptions = { + timeout: 120000, + config: { + 'http.version': 'HTTP/1.1' + } + }; + return sandbox.git.clone(remoteRepo, { + path: remoteClonePath, + depth: 1, + username: gitUsername(), + password: gitPassword(), + timeout: remoteGitOptions.timeout, + config: remoteGitOptions.config + }).then(cloneResult => { + assertGitOK('HTTPS clone', cloneResult); + return sandbox.git.remoteGet(remoteClonePath, 'origin'); + }).then(origin => { + console.log('sanitized origin:', origin); + const filename = `nodejs-sdk-example-${Date.now()}.txt`; + const branch = `nodejs-sdk-example-${Date.now()}`; + return sandbox.git.configureUser(remoteClonePath, 'Sandbox Demo', 'sandbox-demo@example.com') + .then(result => { + assertGitOK('configure remote clone user', result); + return sandbox.git.createBranch(remoteClonePath, branch); + }) + .then(result => { + assertGitOK('create remote push branch', result); + return sandbox.files.write(path.join(remoteClonePath, filename), 'sandbox git push example\n'); + }) + .then(() => sandbox.git.add(remoteClonePath, { files: [filename] })) + .then(result => { + assertGitOK('add remote push file', result); + return sandbox.git.commit(remoteClonePath, 'test: sandbox git example'); + }) + .then(result => { + assertGitOK('commit remote push file', result); + return sandbox.git.status(remoteClonePath); + }) + .then(status => { + console.log('remote clone status raw:\n' + status.raw); + }); + }); + }).then(() => { + return sandbox.commands.run(`find ${shellQuote('/tmp/qiniu-nodejs-sdk-git')} -maxdepth 2 -type d | sort`); + }).then(result => { + console.log(result.stdout); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_injection_rules.js b/examples/sandbox_injection_rules.js new file mode 100644 index 0000000..0b45f67 --- /dev/null +++ b/examples/sandbox_injection_rules.js @@ -0,0 +1,85 @@ +const { + qiniu, + env, + sandboxEndpoint, + sandboxApiKey, + sandboxMac, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: sandboxEndpoint(), + apiKey: sandboxApiKey(), + mac: sandboxMac() + }); + const ruleName = `nodejs-sdk-example-${Date.now()}`; + let ruleID; + let sandbox; + + return client.createInjectionRule({ + name: ruleName, + injection: { + type: 'http', + baseUrl: 'https://httpbin.org', + headers: { + Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'real_token')}` + } + } + }).then(rule => { + ruleID = rule.id || rule.ruleID || rule.rule_id; + console.log('Injection rule created:', ruleID); + return client.getInjectionRule(ruleID); + }).then(rule => { + console.log('Injection rule detail:', rule.name || rule); + return client.updateInjectionRule(ruleID, { + name: `${ruleName}-updated`, + injection: { + type: 'http', + baseUrl: 'https://httpbin.org', + headers: { + Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'updated_token')}`, + 'X-Sandbox-Example': 'qiniu-nodejs-sdk' + } + } + }); + }).then(updated => { + console.log('Injection rule updated:', updated.name || updated); + return client.listInjectionRules(); + }).then(rules => { + console.log('Injection rules:', Array.isArray(rules) ? rules.length : rules); + return createSandboxAndWait({ + client, + injections: [ + { + type: 'id', + id: ruleID + } + ], + metadata: { + example: 'sandbox_injection_rules' + } + }); + }).then(created => { + sandbox = created; + return sandbox.commands.run('curl --max-time 20 -sSL https://httpbin.org/bearer -H "Authorization: Bearer fake_token"', { + timeout: 30000 + }); + }).then(result => { + console.log('curl exit:', result.exitCode); + console.log(result.stdout); + return cleanupSandbox(sandbox); + }).then(() => { + return client.deleteInjectionRule(ruleID); + }).then(() => { + console.log('Injection rule deleted:', ruleID); + }, err => { + return cleanupSandbox(sandbox) + .then(() => ruleID ? client.deleteInjectionRule(ruleID).catch(() => null) : null) + .then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_lifecycle.js b/examples/sandbox_lifecycle.js new file mode 100644 index 0000000..adaf494 --- /dev/null +++ b/examples/sandbox_lifecycle.js @@ -0,0 +1,58 @@ +const { + qiniu, + sandboxClient, + sandboxTemplate, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return client.listTemplates({ limit: 5 }).then(templates => { + console.log('Templates:', Array.isArray(templates) ? templates.length : templates); + return qiniu.sandbox.Sandbox.create({ + client, + template: sandboxTemplate(), + timeout: 300, + metadata: { + example: 'sandbox_lifecycle' + }, + network: { + allowOut: [qiniu.sandbox.ALL_TRAFFIC] + } + }); + }).then(created => { + sandbox = created; + return sandbox.waitForReady({ + interval: 3000, + timeout: 180000 + }); + }).then(info => { + console.log('Sandbox ready:', sandbox.sandboxId, info.state || ''); + console.log('Host for port 8080:', sandbox.getHost(8080)); + return sandbox.isRunning(); + }).then(running => { + console.log('Is running:', running); + return sandbox.setTimeout({ timeout: 300 }); + }).then(() => { + console.log('Timeout updated'); + return sandbox.refresh({ duration: 300 }); + }).then(() => { + console.log('Sandbox refreshed'); + return sandbox.updateNetwork({ + allowOut: [qiniu.sandbox.ALL_TRAFFIC] + }).then(info => { + console.log('Network updated:', info.network || info); + }, err => { + console.log('Network update skipped:', err.message); + }); + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_list_connect.js b/examples/sandbox_list_connect.js new file mode 100644 index 0000000..21f1168 --- /dev/null +++ b/examples/sandbox_list_connect.js @@ -0,0 +1,39 @@ +const { + qiniu, + sandboxClient, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return qiniu.sandbox.Sandbox.create({ + client, + timeout: 300, + metadata: { + example: 'sandbox_list_connect' + } + }).then(created => { + sandbox = created; + console.log('Created:', sandbox.sandboxId); + return qiniu.sandbox.Sandbox.list({ + client, + limit: 10 + }); + }).then(items => { + console.log('List result:', Array.isArray(items) ? items.map(item => item.sandboxId || item.sandboxID) : items); + return qiniu.sandbox.Sandbox.connect(sandbox.sandboxId, { + client, + timeout: 300 + }); + }).then(connected => { + console.log('Connected:', connected.sandboxId); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_metrics_logs.js b/examples/sandbox_metrics_logs.js new file mode 100644 index 0000000..3f12ffa --- /dev/null +++ b/examples/sandbox_metrics_logs.js @@ -0,0 +1,37 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +function wait (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +runExample(() => { + let sandbox; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_metrics_logs' + } + }).then(created => { + sandbox = created; + return sandbox.commands.run('echo "hello metrics and logs"'); + }).then(result => { + console.log('Command exit:', result.exitCode); + return wait(5000); + }).then(() => { + return sandbox.getMetrics(); + }).then(metrics => { + console.log('Metrics:', metrics); + return sandbox.getLogs(); + }).then(logs => { + console.log('Logs:', logs); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_request_injections.js b/examples/sandbox_request_injections.js new file mode 100644 index 0000000..189dde7 --- /dev/null +++ b/examples/sandbox_request_injections.js @@ -0,0 +1,61 @@ +const { + env, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + + return createSandboxAndWait({ + injections: [ + { + type: 'http', + base_url: 'https://httpbin.org', + headers: { + Authorization: `Bearer ${env('QINIU_SANDBOX_HTTP_INJECTION_TOKEN', 'real_token')}` + } + } + ], + metadata: { + example: 'sandbox_request_injections' + } + }).then(created => { + sandbox = created; + return sandbox.commands.run('curl --max-time 20 -sSL https://httpbin.org/bearer -H "Authorization: Bearer fake_token"', { + timeout: 30000 + }); + }).then(result => { + console.log('HTTP injection curl exit:', result.exitCode); + console.log(result.stdout); + if (!env('QINIU_SANDBOX_OPENAI_API_KEY')) { + console.log('Skip OpenAI-compatible injection: set QINIU_SANDBOX_OPENAI_API_KEY to try it.'); + return null; + } + return cleanupSandbox(sandbox).then(() => { + return createSandboxAndWait({ + injections: [ + { + type: 'openai', + api_key: env('QINIU_SANDBOX_OPENAI_API_KEY') + } + ], + metadata: { + example: 'sandbox_openai_injection' + } + }); + }).then(created => { + sandbox = created; + return sandbox.commands.run('python3 - <<\'PY\'\nimport os\nprint("OpenAI-compatible request injection is configured outside the sandbox")\nPY'); + }).then(openaiResult => { + console.log(openaiResult.stdout); + }); + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_resources.js b/examples/sandbox_resources.js new file mode 100644 index 0000000..b473633 --- /dev/null +++ b/examples/sandbox_resources.js @@ -0,0 +1,121 @@ +const { + qiniu, + env, + sandboxEndpoint, + sandboxClient, + sandboxMac, + shellQuote, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +function runGitResourceExample () { + const repoUrl = env('GIT_REPO_URL'); + const token = env('GITHUB_TOKEN'); + if (!repoUrl || !token) { + console.log('Skip GitHub repository resource: set GIT_REPO_URL and GITHUB_TOKEN.'); + return Promise.resolve(); + } + + const client = sandboxClient(); + const mountPath = env('QINIU_SANDBOX_GIT_MOUNT_PATH', '/workspace/repo'); + let sandbox; + + return qiniu.sandbox.Sandbox.create({ + client, + template: env('QINIU_SANDBOX_TEMPLATE', 'base'), + timeout: 300, + resources: [ + { + type: 'github_repository', + url: repoUrl, + mount_path: mountPath, + authorization_token: token + } + ], + metadata: { + example: 'sandbox_resources_git' + } + }).then(created => { + sandbox = created; + return sandbox.waitForReady({ interval: 3000, timeout: 180000 }); + }).then(() => { + console.log('Git resource sandbox ready:', sandbox.sandboxId); + return sandbox.commands.run(`ls -la ${shellQuote(mountPath)} | head -20`); + }).then(result => { + console.log(result.stdout); + const filename = `sandbox-resource-write-test-${Date.now()}.txt`; + return sandbox.files.write(`${mountPath}/${filename}`, `sandbox resource write test\nsandbox=${sandbox.sandboxId}\n`) + .then(() => sandbox.git.configureUser(mountPath, 'Sandbox Resource Demo', 'sandbox-resource-demo@example.com')) + .then(() => sandbox.git.add(mountPath, { files: [filename] })) + .then(() => sandbox.git.commit(mountPath, 'test: update sandbox resource write file')) + .then(() => sandbox.git.status(mountPath)) + .then(status => { + console.log(status.raw); + }); + }).then(() => cleanupSandbox(sandbox), err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +} + +function runKodoResourceExample () { + const bucket = env('QINIU_SANDBOX_KODO_BUCKET'); + if (!bucket) { + console.log('Skip Kodo resource: set QINIU_SANDBOX_KODO_BUCKET.'); + return Promise.resolve(); + } + + const client = new qiniu.sandbox.SandboxClient({ + endpoint: sandboxEndpoint(), + mac: sandboxMac() + }); + const mountPath = env('QINIU_SANDBOX_KODO_MOUNT_PATH', '/workspace/kodo'); + const resource = { + type: 'kodo', + bucket, + mount_path: mountPath + }; + if (env('QINIU_SANDBOX_KODO_PREFIX')) { + resource.prefix = env('QINIU_SANDBOX_KODO_PREFIX'); + } + + let sandbox; + const testFile = `sandbox-resource-write-test-${Date.now()}.txt`; + const testPath = `${mountPath}/${testFile}`; + const prefix = env('QINIU_SANDBOX_KODO_PREFIX'); + const objectKey = prefix ? `${prefix.replace(/\/+$/, '')}/${testFile}` : testFile; + return qiniu.sandbox.Sandbox.create({ + client, + template: env('QINIU_SANDBOX_TEMPLATE', 'base'), + timeout: 300, + resources: [resource], + metadata: { + example: 'sandbox_resources_kodo' + } + }).then(created => { + sandbox = created; + return sandbox.waitForReady({ interval: 3000, timeout: 180000 }); + }).then(() => { + console.log('Kodo resource sandbox ready:', sandbox.sandboxId); + return sandbox.commands.run(`ls -la ${shellQuote(mountPath)} | head -20`); + }).then(result => { + console.log(result.stdout); + return sandbox.commands.run(`sh -c "echo sandbox-kodo-write-test > ${shellQuote(testPath)} && cat ${shellQuote(testPath)}"`); + }).then(writeResult => { + if (writeResult.exitCode !== 0) { + throw new Error(`Kodo write failed: ${writeResult.stderr || writeResult.stdout}`); + } + console.log('Read Kodo resource file:', JSON.stringify(writeResult.stdout)); + console.log('Kodo resource file kept in bucket:', `${bucket}/${objectKey}`); + }).then(() => cleanupSandbox(sandbox), err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +} + +runExample(() => { + return runGitResourceExample().then(runKodoResourceExample); +}); diff --git a/examples/sandbox_templates.js b/examples/sandbox_templates.js new file mode 100644 index 0000000..63c2554 --- /dev/null +++ b/examples/sandbox_templates.js @@ -0,0 +1,85 @@ +const { + qiniu, + env, + sandboxClient, + sandboxTemplate, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient({ + accessToken: env('QINIU_SANDBOX_ACCESS_TOKEN') + }); + const templateName = `nodejs-sdk-example-${Date.now()}`; + let templateID; + let buildID; + + return client.listDefaultTemplates().then(defaultTemplates => { + console.log('Default templates:', Array.isArray(defaultTemplates) ? defaultTemplates.length : defaultTemplates); + return client.listTemplates({ limit: 10 }); + }).then(templates => { + console.log('Templates:', Array.isArray(templates) ? templates.map(item => item.templateID || item.template_id || item.name) : templates); + const first = Array.isArray(templates) && templates[0]; + if (!first) { + return null; + } + const id = first.templateID || first.template_id || first.id || sandboxTemplate(); + return client.getTemplate(id).then(detail => { + console.log('First template detail:', detail.templateID || detail.template_id || id); + }); + }).then(() => { + return qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['curl', 'git']) + .runCmd('echo "hello from template build"') + .setReadyCmd('echo ready') + .build({ + client, + name: templateName, + tags: ['nodejs-sdk-example'] + }); + }).then(result => { + templateID = result.templateID || result.template_id || result.id; + buildID = result.buildID || result.build_id; + console.log('Template created:', templateID, 'build:', buildID); + if (!templateID || !buildID) { + return null; + } + return client.getTemplateBuildStatus(templateID, buildID) + .then(status => { + console.log('Build status:', status.status || status); + return client.getTemplateBuildLogs(templateID, buildID, { limit: 5 }); + }) + .then(logs => { + console.log('Build logs:', logs.logs || logs); + return client.assignTemplateTags({ + target: `${templateName}:v1`, + tags: ['latest'] + }); + }) + .then(tags => { + console.log('Assigned tags:', tags); + return client.deleteTemplateTags({ + name: templateName, + tags: ['latest'] + }); + }) + .then(() => { + return client.getTemplateFiles(templateID, 'example-hash'); + }) + .then(fileInfo => { + console.log('Template file info:', fileInfo); + }, err => { + console.log('Template file lookup skipped:', err.message); + }); + }).then(() => { + if (!templateID) { + return null; + } + return client.deleteTemplate(templateID).then(() => { + console.log('Template deleted:', templateID); + }, err => { + console.log('Failed to delete template:', templateID, err.message); + }); + }); +}); diff --git a/index.d.ts b/index.d.ts index 0e1da7b..7b421e8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -53,6 +53,23 @@ export declare namespace sandbox { httpsAgent?: HttpsAgent; } + interface GitRepositoryResource { + type: 'github_repository'; + url: string; + mount_path: string; + authorization_token?: string; + } + + interface KodoResource { + type: 'kodo'; + bucket: string; + mount_path: string; + prefix?: string; + read_only?: boolean; + } + + type SandboxResource = GitRepositoryResource | KodoResource; + interface SandboxCreateOptions extends SandboxClientOptions { template?: string; templateID?: string; @@ -68,7 +85,7 @@ export declare namespace sandbox { envs?: {[key: string]: string}; mcp?: any; injections?: any[]; - resources?: any[]; + resources?: SandboxResource[]; client?: SandboxClient; } diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index c471934..6b03cfa 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -40,6 +40,12 @@ function normalizeSandboxCreateOptions (opts) { return body; } +function hasKodoResource (body) { + return Array.isArray(body.resources) && body.resources.some(resource => { + return resource && resource.type === 'kodo'; + }); +} + function normalizeInjection (injection) { if (!injection || typeof injection !== 'object' || Array.isArray(injection)) { return injection; @@ -149,6 +155,7 @@ SandboxClient.prototype._request = function (method, path, options) { urllibOptions.content = JSON.stringify(body); urllibOptions.contentType = 'application/json'; } else { + urllibOptions.contentType = urllibOptions.headers['Content-Type']; urllibOptions.headers['Content-Length'] = '0'; } @@ -184,8 +191,10 @@ SandboxClient.prototype.listSandboxesV2 = function (opts) { }; SandboxClient.prototype.createSandbox = function (opts) { + const body = normalizeSandboxCreateOptions(opts); return this._request('POST', '/sandboxes', { - body: normalizeSandboxCreateOptions(opts) + authType: hasKodoResource(body) && this.mac ? 'qiniu' : undefined, + body }); }; diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index a1c881d..1766983 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -281,7 +281,12 @@ Git.prototype._runGitWithTemporaryAuth = function (repoPath, args, opts) { return this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(authUrl(repoUrl, opts))], opts); }).then(() => this._runGit(repoPath, args, opts)) .then(result => this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(stripAuth(originalUrl))], opts) - .then(() => result)); + .then(() => result), err => this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(stripAuth(originalUrl))], opts) + .then(() => { + throw err; + }, () => { + throw err; + })); }; function parseGitStatus (stdout) { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 364c389..f22cbe5 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -152,6 +152,47 @@ describe('test sandbox module', function () { }); }); + it('uses Qiniu AK/SK signing when creating sandbox with Kodo resources', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_kodo', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key', + mac + }); + + return client.createSandbox({ + template: 'base', + resources: [ + { + type: 'kodo', + bucket: 'bucket', + mount_path: '/workspace/kodo', + read_only: true + } + ] + }).then(() => { + should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); + should.not.exist(fixture.requests[0].headers['x-api-key']); + JSON.parse(fixture.requests[0].body).resources[0].type.should.eql('kodo'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('exposes E2B style Sandbox.create and kill helpers', function () { return startServer((req, res) => { if (req.method === 'POST' && req.url === '/sandboxes') { @@ -711,6 +752,40 @@ describe('test sandbox module', function () { }); }); + it('surfaces sandbox API string errors and default connect timeout', function () { + return startServer((req, res) => { + if (req.method === 'POST' && req.url === '/sandboxes/sbx_default/connect') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_default' })); + return; + } + res.statusCode = 418; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify('teapot')); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + + return client.connectSandbox('sbx_default') + .then(() => client.getSandbox('sbx_error')) + .then(() => { + throw new Error('expected string error'); + }, err => { + JSON.parse(fixture.requests[0].body).should.eql({ timeout: 15 }); + err.name.should.eql('SandboxError'); + err.message.should.containEql('teapot'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('maps template, build, tag, and access-token APIs', function () { return startServer((req, res) => { res.setHeader('Content-Type', 'application/json'); @@ -873,6 +948,31 @@ describe('test sandbox module', function () { }); }); + it('supports Template.fromTemplate in builder payloads', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_child', buildID: 'bld_child' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromTemplate('base-template') + .runCmd('echo child') + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'child-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromTemplate.should.eql('base-template'); + body.buildConfig.steps[0].cmd.should.eql('echo child'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('exposes network constants and maps updateNetwork to Qiniu API', function () { return startServer((req, res) => { res.statusCode = 200; @@ -917,6 +1017,16 @@ describe('test sandbox module', function () { }, err => { err.name.should.eql('NotImplementedError'); err.message.should.containEql('Volume'); + return volume.delete(); + }).then(() => { + throw new Error('expected volume.delete to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + return volume.list(); + }).then(() => { + throw new Error('expected volume.list to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); }); }); @@ -1136,7 +1246,7 @@ describe('test sandbox module', function () { if (cmd.indexOf('branch --format') >= 0) { return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); } - if (cmd.indexOf('remote get-url origin') >= 0) { + if (cmd.indexOf('remote get-url') >= 0) { return Promise.resolve({ stdout: 'https://github.com/acme/repo.git\n', exitCode: 0 }); } return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); @@ -1179,6 +1289,112 @@ describe('test sandbox module', function () { }); }); + it('cleans temporary git credentials when push fails', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://github.com/acme/repo.git\n', + exitCode: 0 + }); + } + if (cmd.indexOf('git push') >= 0) { + return Promise.reject(new Error('push failed')); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.push('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + throw new Error('expected git push to fail'); + }, err => { + err.message.should.eql('push failed'); + commandsSeen.map(item => item.cmd).should.eql([ + 'git remote get-url \'origin\'', + 'git remote set-url \'origin\' \'https://u:p@github.com/acme/repo.git\'', + 'git push \'origin\' \'main\'', + 'git remote set-url \'origin\' \'https://github.com/acme/repo.git\'' + ]); + }); + }); + + it('surfaces git upstream and validation errors on auth helpers', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); + } + }); + + return git.push('/repo', { + username: 'u', + password: 'p' + }).then(() => { + throw new Error('expected missing upstream'); + }, err => { + err.name.should.eql('GitUpstreamError'); + return git.clone('https://github.com/acme/repo.git', '/repo', { + username: 'u' + }); + }).then(() => { + throw new Error('expected missing password'); + }, err => { + err.name.should.eql('GitAuthError'); + return git.commit('/repo', 'msg', { + authorName: 'Alice' + }); + }).then(() => { + throw new Error('expected missing author email'); + }, err => { + err.name.should.eql('GitAuthError'); + }); + }); + + it('keeps original git push error when credential cleanup fails', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://github.com/acme/repo.git\n', + exitCode: 0 + }); + } + if (cmd.indexOf('git push') >= 0) { + return Promise.reject(new Error('push failed')); + } + if (cmd.indexOf('remote set-url') >= 0 && commandsSeen.length > 3) { + return Promise.reject(new Error('cleanup failed')); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.push('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + throw new Error('expected git push to fail'); + }, err => { + err.message.should.eql('push failed'); + commandsSeen.map(item => item.cmd).should.eql([ + 'git remote get-url \'origin\'', + 'git remote set-url \'origin\' \'https://u:p@github.com/acme/repo.git\'', + 'git push \'origin\' \'main\'', + 'git remote set-url \'origin\' \'https://github.com/acme/repo.git\'' + ]); + }); + }); + it('covers Sandbox.connect, Sandbox.list, wait polling, and stopped health checks', function () { let infoCalls = 0; return startServer((req, res) => { @@ -1248,6 +1464,32 @@ describe('test sandbox module', function () { }); }); + it('rethrows non-502 envd health errors', function () { + return startServer((req, res) => { + res.statusCode = 500; + res.end('broken'); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_health_error', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.isRunning().then(() => { + throw new Error('expected health error'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.containEql('500'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports JSON fallback for process stream responses and poll timeout errors', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -1299,6 +1541,58 @@ describe('test sandbox module', function () { }); }); + it('supports process stream JSON array and single event fallback responses', function () { + let calls = 0; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + calls += 1; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (calls === 1) { + res.end(JSON.stringify([ + { event: { start: { pid: 31 } } }, + { event: { data: { stdout: 'array' } } }, + { event: { end: { exitCode: 0 } } } + ])); + return; + } + res.end(JSON.stringify({ + event: { + end: { + exitCode: 0 + } + } + })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_json_fallback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('echo array') + .then(result => { + result.pid.should.eql(31); + result.stdout.should.eql('array'); + return sandbox.commands.run('true'); + }) + .then(result => { + result.exitCode.should.eql(0); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports E2B command timeout aliases and optional exit throwing', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { diff --git a/test/sandbox_integration.test.js b/test/sandbox_integration.test.js index a592095..7a78b4b 100644 --- a/test/sandbox_integration.test.js +++ b/test/sandbox_integration.test.js @@ -6,46 +6,42 @@ const qiniu = require('../index'); const { shellQuote } = require('../qiniu/sandbox/util'); function loadDotEnvIfPresent () { - const filepath = path.join(process.cwd(), '.env'); - if (!fs.existsSync(filepath)) { - return; - } + [ + path.join(process.cwd(), '.env') + ].forEach(filepath => { + if (!fs.existsSync(filepath)) { + return; + } - fs.readFileSync(filepath, 'utf8') - .split(/\r?\n/) - .forEach(line => { - line = line.trim(); - if (!line || line[0] === '#') { - return; - } - const index = line.indexOf('='); - if (index < 0) { - return; - } - const key = line.slice(0, index).trim(); - let value = line.slice(index + 1).trim(); - if ( - (value[0] === '"' && value[value.length - 1] === '"') || - (value[0] === '\'' && value[value.length - 1] === '\'') - ) { - value = value.slice(1, -1); - } - if (process.env[key] === undefined) { - process.env[key] = value; - } - }); + fs.readFileSync(filepath, 'utf8') + .split(/\r?\n/) + .forEach(line => { + line = line.trim(); + if (!line || line[0] === '#') { + return; + } + const index = line.indexOf('='); + if (index < 0) { + return; + } + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ( + (value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === '\'' && value[value.length - 1] === '\'') + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + }); + }); } loadDotEnvIfPresent(); -function truthy (value) { - return ['1', 'true', 'yes', 'on'].indexOf(String(value || '').toLowerCase()) >= 0; -} - function integrationLog () { - if (!truthy(process.env.QINIU_SANDBOX_INTEGRATION_VERBOSE)) { - return; - } const args = Array.prototype.slice.call(arguments); args.unshift('[sandbox integration]'); console.log.apply(console, args); @@ -53,13 +49,14 @@ function integrationLog () { function integrationConfig () { return { - enabled: truthy(process.env.QINIU_SANDBOX_INTEGRATION), endpoint: process.env.QINIU_SANDBOX_ENDPOINT, apiKey: process.env.QINIU_SANDBOX_API_KEY, template: process.env.QINIU_SANDBOX_TEMPLATE || 'base', accessKey: process.env.QINIU_SANDBOX_ACCESS_KEY, secretKey: process.env.QINIU_SANDBOX_SECRET_KEY, - testInjectionRules: truthy(process.env.QINIU_SANDBOX_TEST_INJECTION_RULES), + kodoBucket: process.env.QINIU_SANDBOX_KODO_BUCKET, + kodoPrefix: process.env.QINIU_SANDBOX_KODO_PREFIX, + kodoMountPath: process.env.QINIU_SANDBOX_KODO_MOUNT_PATH || '/mnt/qiniu-nodejs-sdk-it', gitRepoUrl: process.env.GIT_REPO_URL, gitUsername: process.env.GIT_USERNAME, gitPassword: process.env.GIT_PASSWORD @@ -67,7 +64,7 @@ function integrationConfig () { } const config = integrationConfig(); -const describeIntegration = config.enabled && config.apiKey ? describe : describe.skip; +const describeIntegration = config.apiKey ? describe : describe.skip; function authedGitUrl (repoUrl, username, password) { const parsed = new URL(repoUrl); @@ -91,6 +88,18 @@ function hasGitCredentials () { return Boolean(config.gitRepoUrl && config.gitUsername && config.gitPassword); } +function hasQiniuCredentials () { + return Boolean(config.accessKey && config.secretKey); +} + +function sandboxClient (opts) { + opts = opts || {}; + return new qiniu.sandbox.SandboxClient(Object.assign({ + endpoint: config.endpoint, + apiKey: config.apiKey + }, opts)); +} + function exerciseRemoteGitIfConfigured (sandbox, runID) { if (!hasGitCredentials()) { integrationLog('skip remote git: set GIT_REPO_URL, GIT_USERNAME, and GIT_PASSWORD to run'); @@ -241,8 +250,8 @@ describeIntegration('sandbox integration', function () { }); }); - it('creates and deletes an injection rule when AK/SK integration is enabled', function () { - if (!config.testInjectionRules || !config.accessKey || !config.secretKey) { + it('creates and deletes an injection rule when AK/SK is configured', function () { + if (!hasQiniuCredentials()) { this.skip(); } @@ -274,8 +283,159 @@ describeIntegration('sandbox integration', function () { return client.deleteInjectionRule(ruleID); }); }); + + it('creates a sandbox with an injection rule when AK/SK is configured', function () { + if (!hasQiniuCredentials()) { + this.skip(); + } + + const client = sandboxClient({ + mac: new qiniu.auth.digest.Mac(config.accessKey, config.secretKey) + }); + const name = `nodejs-sdk-create-it-${Date.now()}`; + let ruleID; + let createdSandbox; + + return client.createInjectionRule({ + name, + injection: { + type: 'openai', + apiKey: 'test-key' + } + }).then(rule => { + ruleID = rule.id || rule.ruleID || rule.ruleId; + should(ruleID).be.ok(); + return qiniu.sandbox.Sandbox.create({ + client, + template: config.template, + timeout: 120, + injections: [ + { + type: 'id', + id: ruleID + } + ], + metadata: { + sdk: 'qiniu-nodejs-sdk', + test: 'injection-create' + } + }); + }).then(created => { + createdSandbox = created; + return createdSandbox.waitForReady({ + interval: 3000, + timeout: 180000 + }); + }).then(info => { + info.state.should.eql('running'); + integrationLog('created sandbox with injection rule', createdSandbox.sandboxId); + }).finally(() => { + const cleanup = []; + if (createdSandbox) { + cleanup.push(createdSandbox.kill().catch(() => null)); + } + if (ruleID) { + cleanup.push(client.deleteInjectionRule(ruleID).catch(() => null)); + } + return Promise.all(cleanup); + }); + }); + + it('creates a sandbox with a Kodo resource mount when bucket is configured', function () { + if (!hasQiniuCredentials() || !config.kodoBucket) { + this.skip(); + } + + const client = new qiniu.sandbox.SandboxClient({ + endpoint: config.endpoint, + mac: new qiniu.auth.digest.Mac(config.accessKey, config.secretKey) + }); + const runID = `nodejs-sdk-kodo-${Date.now()}`; + let kodoSandbox; + const resource = { + type: 'kodo', + bucket: config.kodoBucket, + mount_path: config.kodoMountPath + }; + if (config.kodoPrefix) { + resource.prefix = config.kodoPrefix; + } + + return qiniu.sandbox.Sandbox.create({ + client, + template: config.template, + timeout: 300, + resources: [resource], + metadata: { + sdk: 'qiniu-nodejs-sdk', + test: runID + } + }).then(created => { + kodoSandbox = created; + return kodoSandbox.waitForReady({ + interval: 3000, + timeout: 240000 + }); + }).then(info => { + info.state.should.eql('running'); + return kodoSandbox.commands.run(`test -d ${shellQuote(config.kodoMountPath)}`); + }).then(result => { + result.exitCode.should.eql(0); + const filePath = path.posix.join(config.kodoMountPath, `${runID}.txt`); + return kodoSandbox.commands.run(`sh -c "echo ${shellQuote(runID)} > ${shellQuote(filePath)} && cat ${shellQuote(filePath)}"`) + .then(writeResult => { + writeResult.exitCode.should.eql(0); + writeResult.stdout.should.containEql(runID); + return kodoSandbox.commands.run(`rm -f ${shellQuote(filePath)}`); + }) + .then(removeResult => { + removeResult.exitCode.should.eql(0); + }); + }).finally(() => { + if (kodoSandbox) { + return kodoSandbox.kill().catch(() => null); + } + return null; + }); + }); + + it('creates, inspects, and deletes a template', function () { + const client = sandboxClient(); + const name = `nodejs-sdk-it-${Date.now()}`; + let templateID; + let buildID; + const template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .runCmd('echo qiniu-nodejs-sdk'); + + return template.build({ + client, + name, + tags: ['nodejs-sdk-it'] + }).then(result => { + templateID = result.templateID || result.templateId || result.id || name; + buildID = result.buildID || result.buildId; + should(templateID).be.ok(); + if (!buildID) { + return result; + } + return client.getTemplateBuildStatus(templateID, buildID) + .then(status => { + should(status).be.ok(); + return client.getTemplateBuildLogs(templateID, buildID, { limit: 5 }); + }); + }).then(result => { + should(result).be.ok(); + integrationLog('template integration completed', templateID, buildID); + }).finally(() => { + if (templateID) { + return client.deleteTemplate(templateID).catch(() => null); + } + return null; + }); + }); }); -if (!config.enabled || !config.apiKey) { - console.log('skip sandbox integration: set QINIU_SANDBOX_INTEGRATION=true and QINIU_SANDBOX_API_KEY to run'); +if (!config.apiKey) { + console.log('skip sandbox integration: set QINIU_SANDBOX_API_KEY to run'); } diff --git a/test/sandbox_types.ts b/test/sandbox_types.ts new file mode 100644 index 0000000..44e6b7a --- /dev/null +++ b/test/sandbox_types.ts @@ -0,0 +1,61 @@ +import * as qiniu from '../index'; + +async function useSandboxTypes () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'https://sandbox.example.com', + apiKey: 'key' + }); + + const kodoResource: qiniu.sandbox.KodoResource = { + type: 'kodo', + bucket: 'bucket', + mount_path: '/mnt/kodo', + prefix: 'prefix', + read_only: true + }; + const gitResource: qiniu.sandbox.GitRepositoryResource = { + type: 'github_repository', + url: 'https://github.com/qiniu/nodejs-sdk', + mount_path: '/workspace/repo', + authorization_token: 'token' + }; + + const sandbox = await qiniu.Sandbox.create('base', { + client, + resources: [kodoResource, gitResource], + network: { + allowOut: [qiniu.sandbox.ALL_TRAFFIC] + } + }); + + const bytes: Buffer = await sandbox.files.read('/tmp/a.bin', { format: 'bytes' }); + const text: string = await sandbox.files.read('/tmp/a.txt', { format: 'text' }); + const stream: NodeJS.ReadableStream = await sandbox.files.read('/tmp/a.txt', { format: 'stream' }); + await sandbox.commands.run('false', { + requestTimeoutMs: 1000, + throwOnError: true + }); + await sandbox.git.clone('https://github.com/qiniu/nodejs-sdk', '/repo', { + depth: 1, + username: 'u', + password: 'p' + }); + await sandbox.git.push('/repo', { + remote: 'origin', + branch: 'main', + username: 'u', + password: 'p' + }); + const template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['git']) + .runCmd('git --version'); + await template.build({ client, name: 'typed-template:test' }); + await sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }); + await qiniu.CommandExitError; + bytes.length; + text.length; + stream.read; +} + +void useSandboxTypes; diff --git a/tsconfig.json b/tsconfig.json index 9b8e4fc..b9bc9d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -108,5 +108,6 @@ }, "include": [ "index.d.ts", + "test/sandbox_types.ts", ] } From 133d6dcc4f86b0e360b1333bcb0b5012efa8df72 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 16:04:40 +0800 Subject: [PATCH 03/48] feat(sandbox): improve sandbox runtime APIs Add paginator, snapshot, MCP, PTY, and filesystem transfer helpers for sandbox runtime usage. Cover the new APIs with focused tests and runnable sandbox examples. --- examples/sandbox_filesystem_encoding.js | 34 +++ examples/sandbox_paginator_snapshot_mcp.js | 67 ++++++ examples/sandbox_pty.js | 68 ++++++ index.d.ts | 48 +++- index.js | 2 + qiniu/sandbox/client.js | 32 ++- qiniu/sandbox/commands.js | 9 + qiniu/sandbox/filesystem.js | 59 ++++- qiniu/sandbox/index.js | 2 + qiniu/sandbox/pty.js | 260 ++++++++++++++++++++- qiniu/sandbox/sandbox.js | 136 ++++++++++- test/sandbox.test.js | 232 ++++++++++++++++++ 12 files changed, 928 insertions(+), 21 deletions(-) create mode 100644 examples/sandbox_filesystem_encoding.js create mode 100644 examples/sandbox_paginator_snapshot_mcp.js create mode 100644 examples/sandbox_pty.js diff --git a/examples/sandbox_filesystem_encoding.js b/examples/sandbox_filesystem_encoding.js new file mode 100644 index 0000000..55d25c0 --- /dev/null +++ b/examples/sandbox_filesystem_encoding.js @@ -0,0 +1,34 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + const filePath = '/tmp/qiniu-nodejs-sdk-encoding.txt'; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_filesystem_encoding' + } + }).then(created => { + sandbox = created; + return sandbox.files.write(filePath, 'hello with octet-stream and gzip\n', { + useOctetStream: true, + gzip: true + }); + }).then(entry => { + console.log('Wrote:', entry.path || entry); + return sandbox.files.read(filePath, { + gzip: true + }); + }).then(text => { + console.log('Read:', JSON.stringify(text)); + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_paginator_snapshot_mcp.js b/examples/sandbox_paginator_snapshot_mcp.js new file mode 100644 index 0000000..1c61257 --- /dev/null +++ b/examples/sandbox_paginator_snapshot_mcp.js @@ -0,0 +1,67 @@ +const { + qiniu, + sandboxClient, + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + const client = sandboxClient(); + let sandbox; + + return createSandboxAndWait({ + client, + metadata: { + example: 'sandbox_paginator_snapshot_mcp' + } + }).then(created => { + sandbox = created; + + const paginator = qiniu.sandbox.Sandbox.list({ + client, + limit: 5, + query: { + metadata: { + example: 'sandbox_paginator_snapshot_mcp' + }, + state: ['running'] + } + }); + + return paginator.nextItems().then(items => { + console.log('First page:', items.map(item => item.sandboxId)); + console.log('Has next page:', paginator.hasNext); + console.log('Next token:', paginator.nextToken || ''); + }); + }).then(() => { + return sandbox.createSnapshot({ + name: `nodejs-sdk-example-${Date.now()}` + }).then(snapshot => { + console.log('Snapshot created:', snapshot.snapshotId || snapshot.snapshotID || snapshot); + }, err => { + console.log('Snapshot skipped:', err.message); + }); + }).then(() => { + return sandbox.listSnapshots({ + limit: 5 + }).nextItems().then(snapshots => { + console.log('Snapshots:', snapshots.map(item => item.snapshotId || item.snapshotID || item.id)); + }, err => { + console.log('List snapshots skipped:', err.message); + }); + }).then(() => { + console.log('MCP URL:', sandbox.getMcpUrl()); + return sandbox.getMcpToken().then(token => { + console.log('MCP token:', token ? '' : ''); + }, err => { + console.log('MCP token skipped:', err.message); + }); + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/examples/sandbox_pty.js b/examples/sandbox_pty.js new file mode 100644 index 0000000..37533f1 --- /dev/null +++ b/examples/sandbox_pty.js @@ -0,0 +1,68 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + let handle; + const output = []; + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_pty' + } + }).then(created => { + sandbox = created; + return sandbox.pty.create({ + cols: 80, + rows: 24, + onData: data => { + output.push(Buffer.from(data).toString()); + } + }); + }).then(createdHandle => { + handle = createdHandle; + console.log('PTY pid:', handle.pid); + return sandbox.pty.sendInput(handle.pid, 'echo hello from pty\n'); + }, err => { + console.log('PTY create skipped:', err.message); + return null; + }).then(() => { + if (!handle) { + return null; + } + return sandbox.pty.resize(handle.pid, { + cols: 100, + rows: 30 + }); + }).then(() => { + if (!handle) { + return null; + } + return sandbox.pty.sendInput(handle.pid, 'exit\n'); + }).then(() => { + if (!handle) { + return null; + } + return handle.wait(); + }).then(result => { + if (result) { + console.log('PTY exit:', result.exitCode); + } + console.log('PTY output:\n' + output.join('')); + return cleanupSandbox(sandbox); + }, err => { + if (handle) { + return handle.disconnect().then(() => { + console.log('PTY input/update skipped:', err.message); + console.log('PTY output:\n' + output.join('')); + return cleanupSandbox(sandbox); + }); + } + return cleanupSandbox(sandbox).then(() => { + throw err; + }); + }); +}); diff --git a/index.d.ts b/index.d.ts index 7b421e8..3795e8e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -106,6 +106,8 @@ export declare namespace sandbox { user?: string; signatureExpiration?: number; signature_expiration?: number; + gzip?: boolean; + useOctetStream?: boolean; } interface EntryInfo { @@ -152,6 +154,32 @@ export declare namespace sandbox { raw: string; } + interface SnapshotInfo { + snapshotId?: string; + snapshotID?: string; + [key: string]: any; + } + + class SandboxPaginator { + readonly hasNext: boolean; + readonly nextToken?: string; + nextItems(options?: SandboxClientOptions): Promise; + then( + onfulfilled?: ((value: Sandbox[]) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null + ): Promise; + } + + class SnapshotPaginator { + readonly hasNext: boolean; + readonly nextToken?: string; + nextItems(options?: SandboxClientOptions): Promise; + then( + onfulfilled?: ((value: SnapshotInfo[]) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null + ): Promise; + } + class Filesystem { read(path: string, options?: FileUrlOptions & {format?: string}): Promise; readText(path: string, options?: FileUrlOptions): Promise; @@ -173,7 +201,8 @@ export declare namespace sandbox { pid: number; result?: CommandResult; wait(): Promise; - kill(): Promise; + kill(): Promise; + disconnect(): Promise; } class Commands { @@ -210,7 +239,11 @@ export declare namespace sandbox { } class Pty { - create(options?: CommandOptions & {cmd?: string}): Promise; + create(options?: CommandOptions & {cmd?: string; args?: string[]; cols?: number; rows?: number; onData?: (data: Buffer) => void}): Promise; + connect(pid: number, options?: CommandOptions & {onData?: (data: Buffer) => void}): Promise; + sendInput(pid: number, data: string | Buffer, options?: {user?: string; requestTimeoutMs?: number}): Promise; + resize(pid: number, size: {cols: number; rows: number}, options?: {user?: string; requestTimeoutMs?: number}): Promise; + kill(pid: number, options?: {user?: string; requestTimeoutMs?: number}): Promise; } const DEFAULT_ENDPOINT: string; @@ -314,6 +347,9 @@ export declare namespace sandbox { rebuildTemplate(templateID: string, options?: any): Promise; startTemplateBuild(templateID: string, buildID: string, options?: any): Promise; waitForBuild(templateID: string, buildID: string, options?: PollOptions): Promise; + createSnapshot(sandboxID: string, options?: any): Promise; + listSnapshots(options?: any): Promise; + deleteSnapshot(snapshotID: string): Promise; } class Sandbox { @@ -334,7 +370,7 @@ export declare namespace sandbox { static create(options?: SandboxCreateOptions): Promise; static create(template: string, options?: SandboxCreateOptions): Promise; static connect(sandboxID: string, options?: SandboxConnectOptions): Promise; - static list(options?: SandboxClientOptions & {client?: SandboxClient}): Promise; + static list(options?: SandboxClientOptions & {client?: SandboxClient; limit?: number; nextToken?: string; query?: any}): SandboxPaginator; kill(): Promise; setTimeout(timeoutOrOptions: number | {timeout?: number; timeoutMs?: number}): Promise; @@ -344,6 +380,10 @@ export declare namespace sandbox { getInfo(): Promise; getMetrics(options?: any): Promise; getLogs(options?: any): Promise; + createSnapshot(options?: any): Promise; + listSnapshots(options?: any): SnapshotPaginator; + getMcpUrl(): string; + getMcpToken(): Promise; waitForReady(options?: PollOptions): Promise; isRunning(): Promise; getHost(port: number): string; @@ -359,6 +399,8 @@ export declare namespace sandbox { export declare const Sandbox: typeof sandbox.Sandbox; export declare const SandboxClient: typeof sandbox.SandboxClient; +export declare const SandboxPaginator: typeof sandbox.SandboxPaginator; +export declare const SnapshotPaginator: typeof sandbox.SnapshotPaginator; export declare const CommandExitError: typeof sandbox.CommandExitError; export declare const SandboxError: typeof sandbox.SandboxError; diff --git a/index.js b/index.js index 8f2b38e..b8111fe 100644 --- a/index.js +++ b/index.js @@ -34,5 +34,7 @@ module.exports = { module.exports.Sandbox = module.exports.sandbox.Sandbox; module.exports.SandboxClient = module.exports.sandbox.SandboxClient; +module.exports.SandboxPaginator = module.exports.sandbox.SandboxPaginator; +module.exports.SnapshotPaginator = module.exports.sandbox.SnapshotPaginator; module.exports.CommandExitError = module.exports.sandbox.CommandExitError; module.exports.SandboxError = module.exports.sandbox.SandboxError; diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 6b03cfa..edbd0ff 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -75,6 +75,22 @@ function normalizeInjectionRuleOptions (opts) { return opts; } +function normalizeSandboxListOptions (opts) { + opts = Object.assign({}, opts || {}); + const query = opts.query; + delete opts.query; + + if (query && query.metadata) { + Object.keys(query.metadata).forEach(key => { + opts[`metadata[${key}]`] = query.metadata[key]; + }); + } + if (query && query.state) { + opts.state = query.state; + } + return opts; +} + function normalizeClientOptions (opts) { opts = opts || {}; const mac = opts.mac || (opts.accessKey || opts.secretKey @@ -187,7 +203,7 @@ SandboxClient.prototype.listSandboxes = function (opts) { }; SandboxClient.prototype.listSandboxesV2 = function (opts) { - return this._request('GET', appendQuery('/v2/sandboxes', opts)); + return this._request('GET', appendQuery('/v2/sandboxes', normalizeSandboxListOptions(opts))); }; SandboxClient.prototype.createSandbox = function (opts) { @@ -259,6 +275,20 @@ SandboxClient.prototype.getSandboxMetrics = function (sandboxID, opts) { return this._request('GET', appendQuery(`/sandboxes/${encodePath(sandboxID)}/metrics`, opts)); }; +SandboxClient.prototype.createSnapshot = function (sandboxID, opts) { + return this._request('POST', `/sandboxes/${encodePath(sandboxID)}/snapshots`, { + body: opts || {} + }); +}; + +SandboxClient.prototype.listSnapshots = function (opts) { + return this._request('GET', appendQuery('/snapshots', opts)); +}; + +SandboxClient.prototype.deleteSnapshot = function (snapshotID) { + return this._request('DELETE', `/snapshots/${encodePath(snapshotID)}`, { empty: true }); +}; + SandboxClient.prototype.createTemplate = function (opts) { return this._request('POST', '/v3/templates', { body: opts || {} }); }; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 4d01edc..7a8fdb6 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -64,6 +64,7 @@ function commandResultFromEvents (events, callbacks) { if (data) { const out = data.stdout !== undefined ? bytesToString(data.stdout) : ''; const err = data.stderr !== undefined ? bytesToString(data.stderr) : ''; + const pty = data.pty !== undefined ? data.pty : undefined; if (out) { stdout += out; if (callbacks.onStdout) { @@ -76,6 +77,9 @@ function commandResultFromEvents (events, callbacks) { callbacks.onStderr(err); } } + if (pty !== undefined && callbacks.onData) { + callbacks.onData(Buffer.isBuffer(pty) ? pty : Buffer.from(Array.isArray(pty) ? pty : bytesToString(pty))); + } } if (end) { exitCode = end.exitCode === undefined ? 0 : end.exitCode; @@ -109,6 +113,10 @@ CommandHandle.prototype.kill = function () { return this.commands.kill(this.pid); }; +CommandHandle.prototype.disconnect = function () { + return Promise.resolve(); +}; + function Commands (sandbox) { this.sandbox = sandbox; } @@ -192,3 +200,4 @@ Commands.prototype.kill = function (pid, opts) { exports.Commands = Commands; exports.CommandHandle = CommandHandle; +exports.commandResultFromEvents = commandResultFromEvents; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index a9d8b4c..39042df 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -1,6 +1,27 @@ const { connectRPC } = require('./envd'); const { rawRequest } = require('./util'); const { Readable } = require('stream'); +const zlib = require('zlib'); + +function versionGte (version, minimum) { + if (!version) { + return true; + } + const left = String(version).split('.').map(value => parseInt(value, 10) || 0); + const right = String(minimum).split('.').map(value => parseInt(value, 10) || 0); + const length = Math.max(left.length, right.length); + for (let i = 0; i < length; i++) { + const a = left[i] || 0; + const b = right[i] || 0; + if (a > b) { + return true; + } + if (a < b) { + return false; + } + } + return true; +} function normalizeFileType (type) { if (type === 'FILE_TYPE_DIRECTORY' || type === 'DIRECTORY' || type === 'dir') { @@ -54,10 +75,14 @@ function formatReadResult (data, opts) { Filesystem.prototype.read = function (path, opts) { opts = opts || {}; + const headers = {}; + if (opts.gzip) { + headers['Accept-Encoding'] = 'gzip'; + } return rawRequest(this.sandbox.downloadUrl(path, opts), { method: 'GET', dataType: 'buffer', - headers: {} + headers }).then(({ data }) => formatReadResult(data, opts)); }; @@ -72,20 +97,44 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { const path = pathOrFiles; const opts = maybeOpts || {}; + const supportsEncodedUpload = versionGte(this.sandbox.envdVersion, '0.5.7'); + if (opts.useOctetStream && supportsEncodedUpload) { + const headers = { + 'Content-Type': 'application/octet-stream' + }; + let content = Buffer.isBuffer(dataOrOpts) ? dataOrOpts : Buffer.from(String(dataOrOpts || '')); + if (opts.gzip && supportsEncodedUpload) { + headers['Content-Encoding'] = 'gzip'; + content = zlib.gzipSync(content); + } + + return rawRequest(this.sandbox.uploadUrl(path, opts), { + method: 'POST', + content, + dataType: 'json', + headers + }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); + } + const boundary = `qiniu-sandbox-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const body = multipartBody(boundary, [{ + let body = multipartBody(boundary, [{ field: 'file', filename: path, data: dataOrOpts }]); + const headers = { + 'Content-Type': `multipart/form-data; boundary=${boundary}` + }; + if (opts.gzip && supportsEncodedUpload) { + headers['Content-Encoding'] = 'gzip'; + body = zlib.gzipSync(body); + } return rawRequest(this.sandbox.uploadUrl(path, opts), { method: 'POST', content: body, dataType: 'json', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}` - } + headers }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); }; diff --git a/qiniu/sandbox/index.js b/qiniu/sandbox/index.js index 1d78ecd..dfd3966 100644 --- a/qiniu/sandbox/index.js +++ b/qiniu/sandbox/index.js @@ -9,6 +9,8 @@ module.exports = { ALL_TRAFFIC: network.ALL_TRAFFIC, SandboxClient: require('./client').SandboxClient, Sandbox: require('./sandbox').Sandbox, + SandboxPaginator: require('./sandbox').SandboxPaginator, + SnapshotPaginator: require('./sandbox').SnapshotPaginator, Filesystem: require('./filesystem').Filesystem, Commands: require('./commands').Commands, CommandHandle: require('./commands').CommandHandle, diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index bb87dd9..10bb3db 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -1,3 +1,174 @@ +const { connectRPC } = require('./envd'); +const { envdHeaders } = require('./envd'); +const http = require('http'); +const https = require('https'); + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function eventPayload (message) { + return message.event || message; +} + +function dataToBuffer (value) { + if (Buffer.isBuffer(value)) { + return value; + } + if (Array.isArray(value)) { + return Buffer.from(value); + } + if (typeof value === 'string') { + const decoded = Buffer.from(value, 'base64'); + if (decoded.length && decoded.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) { + return decoded; + } + return Buffer.from(value); + } + return Buffer.from(String(value || '')); +} + +function LivePtyHandle (pty, pid, request, waitPromise) { + this.pty = pty; + this.pid = pid; + this._request = request; + this._waitPromise = waitPromise; + this.stdout = ''; + this.stderr = ''; +} + +LivePtyHandle.prototype.wait = function () { + return this._waitPromise; +}; + +LivePtyHandle.prototype.kill = function () { + return this.pty.kill(this.pid); +}; + +LivePtyHandle.prototype.disconnect = function () { + if (this._request) { + this._request.destroy(); + } + return Promise.resolve(); +}; + +function connectLivePty (sandbox, procedure, body, opts, pty) { + opts = opts || {}; + return new Promise((resolve, reject) => { + const target = new URL(sandbox.envdUrl() + procedure); + const transport = target.protocol === 'https:' ? https : http; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json', + 'Keepalive-Ping-Interval': '50' + }, envdHeaders(sandbox, opts.user)); + const req = transport.request({ + method: 'POST', + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.pathname + target.search, + headers + }); + + let settled = false; + let handle; + let responseBuffer = Buffer.alloc(0); + let result = { + exitCode: -1, + stdout: '', + stderr: '' + }; + let resolveWait; + let rejectWait; + const waitPromise = new Promise((resolve, reject) => { + resolveWait = resolve; + rejectWait = reject; + }); + + function fail (err) { + if (!settled) { + settled = true; + reject(err); + } + rejectWait(err); + } + + function handleMessage (message) { + const event = eventPayload(message); + if (event.start && !handle) { + handle = new LivePtyHandle(pty, event.start.pid, req, waitPromise); + settled = true; + resolve(handle); + } + if (event.data) { + if (event.data.pty !== undefined && opts.onData) { + opts.onData(dataToBuffer(event.data.pty)); + } + if (event.data.stdout !== undefined) { + const out = dataToBuffer(event.data.stdout).toString(); + result.stdout += out; + if (handle) { + handle.stdout += out; + } + } + if (event.data.stderr !== undefined) { + const err = dataToBuffer(event.data.stderr).toString(); + result.stderr += err; + if (handle) { + handle.stderr += err; + } + } + } + if (event.end) { + result = Object.assign(result, { + exitCode: event.end.exitCode === undefined ? 0 : event.end.exitCode, + error: event.end.error || '' + }); + resolveWait(result); + } + } + + req.on('response', res => { + if (res.statusCode < 200 || res.statusCode >= 300) { + fail(new Error(`Sandbox envd request failed with status ${res.statusCode}`)); + res.resume(); + return; + } + res.on('data', chunk => { + responseBuffer = Buffer.concat([responseBuffer, chunk]); + while (responseBuffer.length >= 5) { + const flags = responseBuffer[0]; + const length = responseBuffer.readUInt32BE(1); + if (responseBuffer.length < 5 + length) { + break; + } + const payload = responseBuffer.slice(5, 5 + length).toString(); + responseBuffer = responseBuffer.slice(5 + length); + if (!(flags & 2) && payload) { + handleMessage(JSON.parse(payload)); + } + } + }); + res.on('end', () => { + if (!settled) { + fail(new Error('PTY stream ended before process start')); + return; + } + if (result.exitCode === -1) { + result.exitCode = 0; + } + resolveWait(result); + }); + }); + req.on('error', fail); + req.end(encodeConnectEnvelope(body)); + }); +} + function Pty (sandbox) { this.sandbox = sandbox; this.commands = sandbox.commands; @@ -5,9 +176,92 @@ function Pty (sandbox) { Pty.prototype.create = function (opts) { opts = opts || {}; - return this.commands.start(opts.cmd || '/bin/bash', Object.assign({}, opts, { - stdin: true - })); + if (!opts.cols && !opts.rows && !opts.onData) { + return this.commands.start(opts.cmd || '/bin/bash', Object.assign({}, opts, { + stdin: true + })); + } + + const envs = Object.assign({}, opts.envs || {}); + if (!envs.TERM) { + envs.TERM = 'xterm-256color'; + } + if (!envs.LANG) { + envs.LANG = 'C.UTF-8'; + } + if (!envs.LC_ALL) { + envs.LC_ALL = 'C.UTF-8'; + } + + const body = { + process: { + cmd: opts.cmd || '/bin/bash', + args: opts.args || ['-i', '-l'], + envs + }, + pty: { + size: { + cols: opts.cols, + rows: opts.rows + } + } + }; + if (opts.cwd) { + body.process.cwd = opts.cwd; + } + + return connectLivePty(this.sandbox, '/process.Process/Start', body, { + user: opts.user, + onData: opts.onData + }, this); +}; + +Pty.prototype.connect = function (pid, opts) { + opts = opts || {}; + return connectLivePty(this.sandbox, '/process.Process/Connect', { + process: { + selector: { pid } + } + }, { + user: opts.user, + onData: opts.onData + }, this); +}; + +Pty.prototype.sendInput = function (pid, data, opts) { + return connectRPC(this.sandbox, '/process.Process/SendInput', { + process: { + selector: { pid } + }, + input: { + pty: Buffer.isBuffer(data) ? data.toString('base64') : Buffer.from(data).toString('base64') + } + }, opts).then(() => null); +}; + +Pty.prototype.resize = function (pid, size, opts) { + return connectRPC(this.sandbox, '/process.Process/Update', { + process: { + selector: { pid } + }, + pty: { + size + } + }, opts).then(() => null); +}; + +Pty.prototype.kill = function (pid, opts) { + return connectRPC(this.sandbox, '/process.Process/SendSignal', { + process: { + selector: { pid } + }, + signal: 'SIGNAL_SIGKILL' + }, opts).then(() => true, err => { + if ((err.response && err.response.statusCode === 404) || (err.resp && err.resp.statusCode === 404)) { + return false; + } + throw err; + }); }; exports.Pty = Pty; diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index df5f38d..f2f85e6 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -14,6 +14,104 @@ function getInfoValue (info, camelKey, snakeKey) { return info && (info[camelKey] !== undefined ? info[camelKey] : info[snakeKey]); } +function normalizeItems (data) { + if (Array.isArray(data)) { + return { + items: data + }; + } + data = data || {}; + return { + items: data.items || data.sandboxes || data.snapshots || [], + nextToken: data.nextToken || data.next_token + }; +} + +function normalizeSnapshot (info) { + info = info || {}; + if (info.snapshotId === undefined && info.snapshotID !== undefined) { + info.snapshotId = info.snapshotID; + } + if (info.snapshotID === undefined && info.snapshotId !== undefined) { + info.snapshotID = info.snapshotId; + } + return info; +} + +function SandboxPaginator (opts) { + opts = opts || {}; + this.client = opts.client || new SandboxClient(opts); + this.opts = Object.assign({}, opts); + delete this.opts.client; + this._nextToken = opts.nextToken; + this._hasNext = true; +} + +Object.defineProperty(SandboxPaginator.prototype, 'nextToken', { + get: function () { + return this._nextToken; + } +}); + +Object.defineProperty(SandboxPaginator.prototype, 'hasNext', { + get: function () { + return !!this._nextToken || this._hasNext; + } +}); + +SandboxPaginator.prototype.nextItems = function (opts) { + const requestOpts = Object.assign({}, this.opts, opts || {}); + if (this._nextToken && requestOpts.nextToken === undefined) { + requestOpts.nextToken = this._nextToken; + } + return this.client.listSandboxesV2(requestOpts).then(data => { + const page = normalizeItems(data); + this._nextToken = page.nextToken; + this._hasNext = !!page.nextToken; + return page.items.map(info => new Sandbox({ client: this.client, info })); + }); +}; + +SandboxPaginator.prototype.then = function (resolve, reject) { + return this.nextItems().then(resolve, reject); +}; + +function SnapshotPaginator (client, opts) { + this.client = client; + this.opts = Object.assign({}, opts || {}); + this._nextToken = this.opts.nextToken; + this._hasNext = true; +} + +Object.defineProperty(SnapshotPaginator.prototype, 'nextToken', { + get: function () { + return this._nextToken; + } +}); + +Object.defineProperty(SnapshotPaginator.prototype, 'hasNext', { + get: function () { + return !!this._nextToken || this._hasNext; + } +}); + +SnapshotPaginator.prototype.nextItems = function (opts) { + const requestOpts = Object.assign({}, this.opts, opts || {}); + if (this._nextToken && requestOpts.nextToken === undefined) { + requestOpts.nextToken = this._nextToken; + } + return this.client.listSnapshots(requestOpts).then(data => { + const page = normalizeItems(data); + this._nextToken = page.nextToken; + this._hasNext = !!page.nextToken; + return page.items.map(normalizeSnapshot); + }); +}; + +SnapshotPaginator.prototype.then = function (resolve, reject) { + return this.nextItems().then(resolve, reject); +}; + function Sandbox (opts) { opts = opts || {}; this.client = opts.client || new SandboxClient(opts); @@ -58,15 +156,7 @@ Sandbox.connect = function (sandboxID, opts) { }; Sandbox.list = function (opts) { - const client = opts && opts.client ? opts.client : new SandboxClient(opts); - const params = Object.assign({}, opts || {}); - delete params.client; - return client.listSandboxesV2(params).then(items => { - if (!Array.isArray(items)) { - return items; - } - return items.map(info => new Sandbox({ client, info })); - }); + return new SandboxPaginator(opts); }; Sandbox.prototype.kill = function () { @@ -122,6 +212,32 @@ Sandbox.prototype.getLogs = function (opts) { return this.client.getSandboxLogs(this.sandboxId, opts); }; +Sandbox.prototype.createSnapshot = function (opts) { + return this.client.createSnapshot(this.sandboxId, opts).then(normalizeSnapshot); +}; + +Sandbox.prototype.listSnapshots = function (opts) { + return new SnapshotPaginator(this.client, Object.assign({}, opts || {}, { + sandboxId: this.sandboxId + })); +}; + +Sandbox.prototype.getMcpUrl = function () { + return `https://${this.getHost(50005)}/mcp`; +}; + +Sandbox.prototype.getMcpToken = function () { + if (this.mcpToken) { + return Promise.resolve(this.mcpToken); + } + return this.files.read('/etc/mcp-gateway/.token', { + user: 'root' + }).then(token => { + this.mcpToken = token; + return token; + }); +}; + Sandbox.prototype.waitForReady = function (opts) { return poll(() => this.getInfo(), opts, info => info && info.state === 'running') .then(info => { @@ -190,3 +306,5 @@ Sandbox.prototype.batchUploadUrl = function (user) { }; exports.Sandbox = Sandbox; +exports.SandboxPaginator = SandboxPaginator; +exports.SnapshotPaginator = SnapshotPaginator; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index f22cbe5..c6445e9 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1709,6 +1709,238 @@ describe('test sandbox module', function () { }); }); }); + + it('supports E2B style sandbox paginator, snapshots, and MCP helpers', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=2&nextToken=n1&metadata%5Buser%5D=alice&state=running') { + res.statusCode = 200; + res.end(JSON.stringify({ + items: [{ sandboxID: 'sbx_page', domain: 'page.example.com' }], + nextToken: 'n2' + })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_page/snapshots') { + res.statusCode = 201; + res.end(JSON.stringify({ snapshotID: 'snap_1', snapshotId: 'snap_1' })); + return; + } + if (req.method === 'GET' && req.url === '/snapshots?limit=1&sandboxId=sbx_page') { + res.statusCode = 200; + res.end(JSON.stringify({ + items: [{ snapshotID: 'snap_1', snapshotId: 'snap_1' }], + nextToken: 'snap_next' + })); + return; + } + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files' && parsed.searchParams.get('path') === '/etc/mcp-gateway/.token') { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('mcp-token'); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + const paginator = qiniu.sandbox.Sandbox.list({ + client, + limit: 2, + nextToken: 'n1', + query: { + metadata: { user: 'alice' }, + state: ['running'] + } + }); + + return paginator.nextItems().then(items => { + items[0].sandboxId.should.eql('sbx_page'); + paginator.hasNext.should.eql(true); + paginator.nextToken.should.eql('n2'); + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_page', + envdUrl: fixture.endpoint, + info: { + domain: 'page.example.com', + envdAccessToken: 'token' + }, + client + }); + sandbox.getMcpUrl().should.eql('https://50005-sbx_page.page.example.com/mcp'); + return sandbox.getMcpToken().then(token => { + token.should.eql('mcp-token'); + return sandbox.createSnapshot({ name: 'snap' }); + }).then(snapshot => { + snapshot.snapshotId.should.eql('snap_1'); + return sandbox.listSnapshots({ limit: 1 }).nextItems(); + }); + }).then(snapshots => { + snapshots[0].snapshotId.should.eql('snap_1'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B style PTY connect, input, resize, and kill operations', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start' || req.url === '/process.Process/Connect') { + const body = decodeConnectEnvelope(req.rawBody); + if (req.url === '/process.Process/Start') { + body.pty.size.should.eql({ cols: 80, rows: 24 }); + body.process.envs.TERM.should.eql('xterm-256color'); + } else { + body.process.selector.pid.should.eql(44); + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 44 } } }), + encodeConnectEnvelope({ event: { data: { pty: [111, 107] } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + if (req.url === '/process.Process/SendInput' || req.url === '/process.Process/Update' || req.url === '/process.Process/SendSignal') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const data = []; + + return sandbox.pty.create({ + cols: 80, + rows: 24, + onData: chunk => data.push(Buffer.from(chunk).toString()) + }).then(handle => { + handle.pid.should.eql(44); + return handle.wait(); + }).then(() => sandbox.pty.connect(44, { + onData: chunk => data.push(Buffer.from(chunk).toString()) + })).then(handle => handle.wait()) + .then(() => sandbox.pty.sendInput(44, Buffer.from('ls\n'))) + .then(() => sandbox.pty.resize(44, { cols: 100, rows: 30 })) + .then(() => sandbox.pty.kill(44)) + .then(killed => { + killed.should.eql(true); + data.should.eql(['ok', 'ok']); + const sendBody = JSON.parse(fixture.requests[2].body); + sendBody.input.pty.should.eql(Buffer.from('ls\n').toString('base64')); + const resizeBody = JSON.parse(fixture.requests[3].body); + resizeBody.pty.size.should.eql({ cols: 100, rows: 30 }); + const killBody = JSON.parse(fixture.requests[4].body); + killBody.signal.should.eql('SIGNAL_SIGKILL'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports filesystem gzip and octet-stream write compatibility options', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + req.headers['accept-encoding'].should.eql('gzip'); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('zip'); + return; + } + if (req.method === 'POST' && parsed.pathname === '/files') { + req.headers['content-type'].should.eql('application/octet-stream'); + req.headers['content-encoding'].should.eql('gzip'); + parsed.searchParams.get('path').should.eql('/zip.txt'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_zip', + envdUrl: fixture.endpoint, + info: { + envdVersion: '0.5.7', + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/zip.txt', { gzip: true }) + .then(text => { + text.should.eql('zip'); + return sandbox.files.write('/zip.txt', 'zip', { + gzip: true, + useOctetStream: true + }); + }) + .then(info => { + info.path.should.eql('/zip.txt'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('falls back to multipart uploads when envd does not support octet-stream', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + should(req.headers['content-type']).startWith('multipart/form-data; boundary='); + should.not.exist(req.headers['content-encoding']); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_zip_old', + envdUrl: fixture.endpoint, + info: { + envdVersion: '0.5.5', + envdAccessToken: 'token' + } + }); + + return sandbox.files.write('/zip.txt', 'zip', { + gzip: true, + useOctetStream: true + }).then(info => { + info.path.should.eql('/zip.txt'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); }); function handleGitAndPty (git, pty, commandsSeen) { From 221831023f48269c2297c4e2072ff4d4829e6347 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 17:47:04 +0800 Subject: [PATCH 04/48] feat(sandbox): align runtime and template APIs Add E2B-style sandbox runtime helpers, filesystem watching, Git option signatures, and expanded template builder APIs while preserving existing Qiniu call forms. Update type coverage and sandbox examples for the aligned APIs. --- examples/sandbox_templates.js | 10 + examples/sandbox_watch_dir.js | 67 ++++++ index.d.ts | 89 +++++++- index.js | 4 + qiniu/sandbox/commands.js | 18 ++ qiniu/sandbox/constants.js | 1 + qiniu/sandbox/errors.js | 4 + qiniu/sandbox/filesystem.js | 215 +++++++++++++++++++- qiniu/sandbox/git.js | 122 ++++++++--- qiniu/sandbox/index.js | 9 + qiniu/sandbox/sandbox.js | 9 + qiniu/sandbox/template.js | 313 +++++++++++++++++++++++++++- test/sandbox.test.js | 371 ++++++++++++++++++++++++++++++++++ test/sandbox_types.ts | 55 +++++ 14 files changed, 1248 insertions(+), 39 deletions(-) create mode 100644 examples/sandbox_watch_dir.js diff --git a/examples/sandbox_templates.js b/examples/sandbox_templates.js index 63c2554..0c3ba10 100644 --- a/examples/sandbox_templates.js +++ b/examples/sandbox_templates.js @@ -28,6 +28,16 @@ runExample(() => { console.log('First template detail:', detail.templateID || detail.template_id || id); }); }).then(() => { + const advanced = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nWORKDIR /app\nENV NODE_ENV=production\nRUN npm ci') + .copyItems([{ src: 'package.json', dest: '/app/' }]) + .setEnvs({ PORT: '3000' }) + .npmInstall('tsx', { dev: true }) + .pipInstall(undefined, { g: false }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk', { depth: 1 }) + .makeDir('/app/data') + .makeSymlink('/usr/bin/node', '/usr/local/bin/node', { force: true }); + console.log('Advanced builder steps:', advanced.buildConfig.steps.length); return qiniu.sandbox.Template() .fromImage('ubuntu:22.04') .aptInstall(['curl', 'git']) diff --git a/examples/sandbox_watch_dir.js b/examples/sandbox_watch_dir.js new file mode 100644 index 0000000..ab4af77 --- /dev/null +++ b/examples/sandbox_watch_dir.js @@ -0,0 +1,67 @@ +const { + createSandboxAndWait, + cleanupSandbox, + runExample +} = require('./sandbox_common'); + +runExample(() => { + let sandbox; + let handle; + const watchedDir = '/tmp/qiniu-watch-dir'; + const watchedFile = `${watchedDir}/hello.txt`; + + function waitForEvent () { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timed out waiting for filesystem event')); + }, 10000); + + sandbox.files.watchDir(watchedDir, event => { + console.log('Watch event:', event.type, event.name); + if (event.name === 'hello.txt') { + clearTimeout(timer); + resolve(event); + } + }, { + recursive: true, + requestTimeoutMs: 10000, + onExit: err => { + if (err) { + console.log('Watch exited:', err.message); + } + } + }).then(created => { + handle = created; + return sandbox.files.write(watchedFile, 'hello watch\n'); + }).catch(err => { + clearTimeout(timer); + reject(err); + }); + }); + } + + return createSandboxAndWait({ + metadata: { + example: 'sandbox_watch_dir' + } + }).then(created => { + sandbox = created; + return sandbox.files.makeDir(watchedDir); + }).then(() => { + return waitForEvent(); + }).then(event => { + console.log('Received event:', event.type); + return handle ? handle.stop() : null; + }).then(() => { + return cleanupSandbox(sandbox); + }, err => { + if (handle) { + return handle.stop().then(() => { + console.log('WatchDir skipped:', err.message); + return cleanupSandbox(sandbox); + }); + } + console.log('WatchDir skipped:', err.message); + return cleanupSandbox(sandbox); + }); +}); diff --git a/index.d.ts b/index.d.ts index 3795e8e..39ed7ba 100644 --- a/index.d.ts +++ b/index.d.ts @@ -123,6 +123,20 @@ export declare namespace sandbox { symlinkTarget?: string; } + interface FilesystemEvent { + name: string; + type: 'create' | 'write' | 'remove' | 'rename' | 'chmod'; + } + + interface WatchOptions { + user?: string; + recursive?: boolean; + timeout?: number; + timeoutMs?: number; + requestTimeoutMs?: number; + onExit?: (err?: Error) => void | Promise; + } + interface CommandResult { pid?: number; exitCode: number; @@ -154,6 +168,20 @@ export declare namespace sandbox { raw: string; } + interface GitConfigOptions extends CommandOptions { + path?: string; + scope?: 'local' | 'global' | 'system'; + } + + interface TemplateCopyItem { + src: string | string[]; + dest: string; + forceUpload?: true; + user?: string; + mode?: number; + resolveSymlinks?: boolean; + } + interface SnapshotInfo { snapshotId?: string; snapshotID?: string; @@ -195,6 +223,11 @@ export declare namespace sandbox { remove(path: string, options?: {user?: string}): Promise; rename(oldPath: string, newPath: string, options?: {user?: string}): Promise; move(oldPath: string, newPath: string, options?: {user?: string}): Promise; + watchDir(path: string, onEvent: (event: FilesystemEvent) => void | Promise, options?: WatchOptions): Promise; + } + + class WatchHandle { + stop(): Promise; } class CommandHandle { @@ -209,6 +242,7 @@ export declare namespace sandbox { run(command: string, options?: CommandOptions & {background?: false}): Promise; run(command: string, options: CommandOptions & {background: true}): Promise; start(command: string, options?: CommandOptions): Promise; + connect(pid: number, options?: CommandOptions): Promise; list(options?: {user?: string}): Promise; sendStdin(pid: number, data: string | Buffer, options?: {user?: string}): Promise; closeStdin(pid: number, options?: {user?: string}): Promise; @@ -228,13 +262,16 @@ export declare namespace sandbox { checkoutBranch(repoPath: string, branch: string, options?: CommandOptions): Promise; deleteBranch(repoPath: string, branch: string, options?: CommandOptions & {force?: boolean}): Promise; branches(repoPath: string, options?: CommandOptions): Promise>; - reset(repoPath: string, options?: CommandOptions & {hard?: boolean; soft?: boolean; mixed?: boolean; ref?: string}): Promise; + reset(repoPath: string, options?: CommandOptions & {hard?: boolean; soft?: boolean; mixed?: boolean; ref?: string; mode?: 'soft' | 'mixed' | 'hard' | 'merge' | 'keep'; target?: string; paths?: string[]}): Promise; restore(repoPath: string, options?: CommandOptions & {staged?: boolean; worktree?: boolean; source?: string; paths?: string[]; files?: string[]}): Promise; remoteAdd(repoPath: string, name: string, repoUrl: string, options?: CommandOptions & {overwrite?: boolean; fetch?: boolean}): Promise; remoteGet(repoPath: string, name: string, options?: CommandOptions): Promise; setConfig(repoPath: string, key: string, value: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; + setConfig(key: string, value: string, options?: GitConfigOptions): Promise; getConfig(repoPath: string, key: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; + getConfig(key: string, options?: GitConfigOptions): Promise; configureUser(repoPath: string, name: string, email: string, options?: CommandOptions): Promise; + configureUser(name: string, email: string, options?: GitConfigOptions): Promise; dangerouslyAuthenticate(repoPath: string, remote: string, username: string, password: string, options?: CommandOptions): Promise; } @@ -247,6 +284,19 @@ export declare namespace sandbox { } const DEFAULT_ENDPOINT: string; + const DEFAULT_TEMPLATE: string; + const DEFAULT_SANDBOX_TIMEOUT_MS: number; + const FileType: { + FILE: 'file'; + DIR: 'dir'; + }; + const FilesystemEventType: { + CREATE: 'create'; + WRITE: 'write'; + REMOVE: 'remove'; + RENAME: 'rename'; + CHMOD: 'chmod'; + }; const ALL_TRAFFIC: string; class SandboxError extends Error { @@ -263,16 +313,36 @@ export declare namespace sandbox { class TimeoutError extends Error {} class NotImplementedError extends Error {} + class InvalidArgumentError extends Error {} + class NotFoundError extends Error {} + class FileNotFoundError extends Error {} + class SandboxNotFoundError extends Error {} class GitAuthError extends Error {} class GitUpstreamError extends Error {} class TemplateBuildError extends Error {} interface TemplateBuilder { - fromImage(image: string): this; + fromImage(image: string, credentials?: {username: string; password: string}): this; + fromAWSRegistry(image: string, credentials: {accessKeyId: string; secretAccessKey: string; region: string}): this; + fromGCPRegistry(image: string, credentials: {serviceAccountJSON: string | object}): this; fromTemplate(templateID: string): this; - aptInstall(packages: string | string[]): this; - runCmd(command: string): this; - copy(src: string, dest: string): this; + fromDockerfile(dockerfileContentOrPath: string): this; + aptInstall(packages: string | string[], options?: {noInstallRecommends?: boolean; fixMissing?: boolean}): this; + runCmd(command: string | string[], options?: {user?: string}): this; + copy(src: string | string[], dest: string, options?: {forceUpload?: true; user?: string; mode?: number; resolveSymlinks?: boolean}): this; + copyItems(items: TemplateCopyItem[]): this; + remove(path: string | string[], options?: {force?: boolean; recursive?: boolean; user?: string}): this; + rename(src: string, dest: string, options?: {force?: boolean; user?: string}): this; + makeDir(path: string | string[], options?: {mode?: number; user?: string}): this; + makeSymlink(src: string, dest: string, options?: {force?: boolean; user?: string}): this; + setWorkdir(workdir: string): this; + setUser(user: string): this; + pipInstall(packages?: string | string[], options?: {g?: boolean}): this; + npmInstall(packages?: string | string[], options?: {g?: boolean; dev?: boolean}): this; + bunInstall(packages?: string | string[], options?: {g?: boolean; dev?: boolean}): this; + gitClone(url: string, path?: string, options?: {branch?: string; depth?: number; user?: string}): this; + setEnvs(envs: {[key: string]: string}): this; + skipCache(): this; setStartCmd(command: string): this; setReadyCmd(command: string): this; build(options?: SandboxClientOptions & {client?: SandboxClient; name?: string; alias?: string; tags?: string[]}): Promise; @@ -377,6 +447,11 @@ export declare namespace sandbox { refresh(options?: {duration?: number}): Promise; updateNetwork(network: any): Promise; pause(): Promise; + /** + * @deprecated use pause() instead + */ + betaPause(): Promise; + connect(options?: SandboxConnectOptions): Promise; getInfo(): Promise; getMetrics(options?: any): Promise; getLogs(options?: any): Promise; @@ -401,6 +476,10 @@ export declare const Sandbox: typeof sandbox.Sandbox; export declare const SandboxClient: typeof sandbox.SandboxClient; export declare const SandboxPaginator: typeof sandbox.SandboxPaginator; export declare const SnapshotPaginator: typeof sandbox.SnapshotPaginator; +export declare const DEFAULT_SANDBOX_TIMEOUT_MS: typeof sandbox.DEFAULT_SANDBOX_TIMEOUT_MS; +export declare const FileType: typeof sandbox.FileType; +export declare const FilesystemEventType: typeof sandbox.FilesystemEventType; +export declare const WatchHandle: typeof sandbox.WatchHandle; export declare const CommandExitError: typeof sandbox.CommandExitError; export declare const SandboxError: typeof sandbox.SandboxError; diff --git a/index.js b/index.js index b8111fe..31b67fc 100644 --- a/index.js +++ b/index.js @@ -36,5 +36,9 @@ module.exports.Sandbox = module.exports.sandbox.Sandbox; module.exports.SandboxClient = module.exports.sandbox.SandboxClient; module.exports.SandboxPaginator = module.exports.sandbox.SandboxPaginator; module.exports.SnapshotPaginator = module.exports.sandbox.SnapshotPaginator; +module.exports.DEFAULT_SANDBOX_TIMEOUT_MS = module.exports.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS; +module.exports.FileType = module.exports.sandbox.FileType; +module.exports.FilesystemEventType = module.exports.sandbox.FilesystemEventType; +module.exports.WatchHandle = module.exports.sandbox.WatchHandle; module.exports.CommandExitError = module.exports.sandbox.CommandExitError; module.exports.SandboxError = module.exports.sandbox.SandboxError; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 7a8fdb6..8aa6c9f 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -170,6 +170,24 @@ Commands.prototype.list = function (opts) { }); }; +Commands.prototype.connect = function (pid, opts) { + opts = opts || {}; + return connectStreamRPC(this.sandbox, '/process.Process/Connect', { + process: { + selector: { pid } + } + }, { + user: opts.user, + keepalive: true, + timeout: requestTimeout(opts), + timeoutMs: requestTimeout(opts), + requestTimeoutMs: requestTimeout(opts) + }).then(events => { + const result = commandResultFromEvents(events, opts); + return new CommandHandle(this, result.pid || pid, result, opts); + }); +}; + Commands.prototype.sendStdin = function (pid, data, opts) { return connectRPC(this.sandbox, '/process.Process/SendInput', { process: { diff --git a/qiniu/sandbox/constants.js b/qiniu/sandbox/constants.js index 7b96b01..464c56e 100644 --- a/qiniu/sandbox/constants.js +++ b/qiniu/sandbox/constants.js @@ -1,4 +1,5 @@ exports.DEFAULT_ENDPOINT = 'https://cn-yangzhou-1-sandbox.qiniuapi.com'; exports.DEFAULT_TEMPLATE = 'base'; +exports.DEFAULT_SANDBOX_TIMEOUT_MS = 300000; exports.ENVD_PORT = 49983; exports.DEFAULT_USER = 'user'; diff --git a/qiniu/sandbox/errors.js b/qiniu/sandbox/errors.js index 4795770..6af0a0a 100644 --- a/qiniu/sandbox/errors.js +++ b/qiniu/sandbox/errors.js @@ -44,6 +44,10 @@ exports.SandboxError = SandboxError; exports.CommandExitError = CommandExitError; exports.TimeoutError = defineError('TimeoutError'); exports.NotImplementedError = defineError('NotImplementedError'); +exports.InvalidArgumentError = defineError('InvalidArgumentError'); +exports.NotFoundError = defineError('NotFoundError'); +exports.FileNotFoundError = defineError('FileNotFoundError'); +exports.SandboxNotFoundError = defineError('SandboxNotFoundError'); exports.GitAuthError = defineError('GitAuthError'); exports.GitUpstreamError = defineError('GitUpstreamError'); exports.TemplateBuildError = defineError('TemplateBuildError'); diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 39042df..15c0cb3 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -1,7 +1,25 @@ -const { connectRPC } = require('./envd'); +const { connectRPC, envdHeaders } = require('./envd'); +const { SandboxError } = require('./errors'); const { rawRequest } = require('./util'); const { Readable } = require('stream'); const zlib = require('zlib'); +const http = require('http'); +const https = require('https'); + +const FileType = { + FILE: 'file', + DIR: 'dir' +}; + +const FilesystemEventType = { + CREATE: 'create', + WRITE: 'write', + REMOVE: 'remove', + RENAME: 'rename', + CHMOD: 'chmod' +}; + +const ENVD_VERSION_RECURSIVE_WATCH = '0.1.4'; function versionGte (version, minimum) { if (!version) { @@ -40,6 +58,75 @@ function normalizeEntry (entry) { }); } +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function eventPayload (message) { + return message.event || message; +} + +function normalizeWatchEventPayload (message) { + const event = eventPayload(message); + if (!event) { + return {}; + } + if (event.case) { + const result = {}; + result[event.case] = event.value || {}; + return result; + } + return event; +} + +function normalizeFilesystemEventType (type) { + if (type === 1 || type === 'EVENT_TYPE_CREATE' || type === 'CREATE' || type === 'create') { + return FilesystemEventType.CREATE; + } + if (type === 2 || type === 'EVENT_TYPE_WRITE' || type === 'WRITE' || type === 'write') { + return FilesystemEventType.WRITE; + } + if (type === 3 || type === 'EVENT_TYPE_REMOVE' || type === 'REMOVE' || type === 'remove') { + return FilesystemEventType.REMOVE; + } + if (type === 4 || type === 'EVENT_TYPE_RENAME' || type === 'RENAME' || type === 'rename') { + return FilesystemEventType.RENAME; + } + if (type === 5 || type === 'EVENT_TYPE_CHMOD' || type === 'CHMOD' || type === 'chmod') { + return FilesystemEventType.CHMOD; + } + return undefined; +} + +function WatchHandle (request, onExit) { + this._request = request; + this._onExit = onExit; + this._stopped = false; +} + +WatchHandle.prototype.stop = function () { + this._stopped = true; + if (this._request) { + this._request.destroy(); + this._request = null; + } + return Promise.resolve(); +}; + +WatchHandle.prototype._finish = function (err) { + if (this._stopped) { + return; + } + this._stopped = true; + if (this._onExit) { + this._onExit(err); + } +}; + function multipartBody (boundary, parts) { const chunks = []; parts.forEach(part => { @@ -202,5 +289,131 @@ Filesystem.prototype.rename = function (oldPath, newPath, opts) { Filesystem.prototype.move = Filesystem.prototype.rename; +Filesystem.prototype.watchDir = function (path, onEvent, opts) { + opts = opts || {}; + if (opts.recursive && !versionGte(this.sandbox.envdVersion, ENVD_VERSION_RECURSIVE_WATCH)) { + return Promise.reject(new SandboxError('You need to update the template to use recursive watching.')); + } + + return watchDir(this.sandbox, path, onEvent, opts); +}; + exports.Filesystem = Filesystem; +exports.FileType = FileType; +exports.FilesystemEventType = FilesystemEventType; +exports.WatchHandle = WatchHandle; exports.normalizeEntry = normalizeEntry; + +function watchDir (sandbox, path, onEvent, opts) { + return new Promise((resolve, reject) => { + const target = new URL(sandbox.envdUrl() + '/filesystem.Filesystem/WatchDir'); + const transport = target.protocol === 'https:' ? https : http; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json', + 'Keepalive-Ping-Interval': '50' + }, envdHeaders(sandbox, opts.user)); + const req = transport.request({ + method: 'POST', + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.pathname + target.search, + headers + }); + + let settled = false; + let handle; + let responseBuffer = Buffer.alloc(0); + let startTimer; + + function cleanupStartTimer () { + if (startTimer) { + clearTimeout(startTimer); + startTimer = null; + } + } + + function fail (err) { + cleanupStartTimer(); + if (!settled) { + settled = true; + reject(err); + return; + } + if (handle) { + handle._finish(err); + } + } + + function handleMessage (message) { + const event = normalizeWatchEventPayload(message); + if (event.start && !handle) { + cleanupStartTimer(); + handle = new WatchHandle(req, opts.onExit); + settled = true; + resolve(handle); + return; + } + if (event.filesystem && onEvent) { + const type = normalizeFilesystemEventType(event.filesystem.type); + if (type) { + onEvent({ + name: event.filesystem.name, + type + }); + } + } + } + + const startTimeout = opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; + if (startTimeout) { + startTimer = setTimeout(() => { + fail(new SandboxError('Sandbox filesystem watch start timed out')); + req.destroy(); + }, startTimeout); + } + + req.on('response', res => { + if (res.statusCode < 200 || res.statusCode >= 300) { + fail(new SandboxError(`Sandbox envd request failed with status ${res.statusCode}`, res)); + res.resume(); + return; + } + res.on('data', chunk => { + responseBuffer = Buffer.concat([responseBuffer, chunk]); + while (responseBuffer.length >= 5) { + const flags = responseBuffer[0]; + const length = responseBuffer.readUInt32BE(1); + if (responseBuffer.length < 5 + length) { + break; + } + const payload = responseBuffer.slice(5, 5 + length).toString(); + responseBuffer = responseBuffer.slice(5 + length); + if (!(flags & 2) && payload) { + handleMessage(JSON.parse(payload)); + } + } + }); + res.on('end', () => { + cleanupStartTimer(); + if (!settled) { + fail(new Error('WatchDir stream ended before start event')); + return; + } + if (handle) { + handle._finish(); + } + }); + }); + req.on('error', err => { + if (handle && handle._stopped) { + return; + } + fail(err); + }); + req.end(encodeConnectEnvelope({ + path, + recursive: !!opts.recursive + })); + }); +} diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 1766983..460df54 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -50,6 +50,59 @@ function configScopeArg (opts) { return null; } +function pathFromOpts (opts) { + return opts && opts.path; +} + +function normalizeConfigCall (args) { + if (typeof args[2] === 'object' && args[2] !== null) { + return { + repoPath: pathFromOpts(args[2]), + key: args[0], + value: args[1], + opts: Object.assign({}, args[2]) + }; + } + return { + repoPath: args[0], + key: args[1], + value: args[2], + opts: Object.assign({}, args[3] || {}) + }; +} + +function normalizeGetConfigCall (args) { + if (typeof args[1] === 'object' && args[1] !== null) { + return { + repoPath: pathFromOpts(args[1]), + key: args[0], + opts: Object.assign({}, args[1]) + }; + } + return { + repoPath: args[0], + key: args[1], + opts: Object.assign({}, args[2] || {}) + }; +} + +function normalizeConfigureUserCall (args) { + if (typeof args[2] === 'object' && args[2] !== null) { + return { + repoPath: pathFromOpts(args[2]), + name: args[0], + email: args[1], + opts: Object.assign({}, args[2]) + }; + } + return { + repoPath: args[0], + name: args[1], + email: args[2], + opts: Object.assign({}, args[3] || {}) + }; +} + Git.prototype._runGit = function (repoPath, args, opts) { opts = Object.assign({}, opts || {}); if (repoPath) { @@ -173,38 +226,41 @@ Git.prototype.remoteGet = function (repoPath, name, opts) { .then(result => result.exitCode ? undefined : result.stdout.trim()); }; -Git.prototype.setConfig = function (repoPath, key, value, opts) { +Git.prototype.setConfig = function () { + const normalized = normalizeConfigCall(arguments); + const opts = normalized.opts; + delete opts.path; const scope = configScopeArg(opts); const args = ['config']; if (scope) { args.push(scope); } - args.push(shellQuote(key), shellQuote(value)); - return this._runGit(repoPath, args, opts); + args.push(shellQuote(normalized.key), shellQuote(normalized.value)); + return this._runGit(normalized.repoPath, args, opts); }; -Git.prototype.getConfig = function (repoPath, key, opts) { +Git.prototype.getConfig = function () { + const normalized = normalizeGetConfigCall(arguments); + const opts = normalized.opts; + delete opts.path; const scope = configScopeArg(opts); const args = ['config']; if (scope) { args.push(scope); } - args.push('--get', shellQuote(key)); - return this._runGit(repoPath, args, opts) + args.push('--get', shellQuote(normalized.key)); + return this._runGit(normalized.repoPath, args, opts) .then(result => result.stdout.trim()); }; -Git.prototype.configureUser = function (repoPath, name, email, opts) { - return this._runGit(repoPath, [ - 'config', - 'user.name', - shellQuote(name), - '&&', - 'git', - 'config', - 'user.email', - shellQuote(email) - ], opts); +Git.prototype.configureUser = function () { + const normalized = normalizeConfigureUserCall(arguments); + const opts = normalized.opts; + if (normalized.repoPath && opts.path === undefined) { + opts.path = normalized.repoPath; + } + return this.setConfig('user.name', normalized.name, opts) + .then(() => this.setConfig('user.email', normalized.email, opts)); }; Git.prototype.branches = function (repoPath, opts) { @@ -220,15 +276,18 @@ Git.prototype.branches = function (repoPath, opts) { Git.prototype.reset = function (repoPath, opts) { opts = opts || {}; const args = ['reset']; - if (opts.hard) { - args.push('--hard'); - } else if (opts.soft) { - args.push('--soft'); - } else if (opts.mixed) { - args.push('--mixed'); + const mode = opts.mode || (opts.hard ? 'hard' : null) || (opts.soft ? 'soft' : null) || (opts.mixed ? 'mixed' : null); + if (mode) { + args.push(`--${mode}`); } - if (opts.ref) { - args.push(shellQuote(opts.ref)); + const target = opts.target || opts.ref; + if (target) { + args.push(shellQuote(target)); + } + const paths = opts.paths || opts.files || []; + if (paths.length) { + args.push('--'); + paths.forEach(path => args.push(shellQuote(path))); } return this._runGit(repoPath, args, opts); }; @@ -236,12 +295,19 @@ Git.prototype.reset = function (repoPath, opts) { Git.prototype.restore = function (repoPath, opts) { opts = opts || {}; const args = ['restore']; - if (opts.staged) { - args.push('--staged'); + const staged = opts.staged; + let worktree = opts.worktree; + if (staged === undefined && worktree === undefined) { + worktree = true; + } else if (staged === true && worktree === undefined) { + worktree = false; } - if (opts.worktree) { + if (worktree) { args.push('--worktree'); } + if (staged) { + args.push('--staged'); + } if (opts.source) { args.push('--source', shellQuote(opts.source)); } diff --git a/qiniu/sandbox/index.js b/qiniu/sandbox/index.js index dfd3966..996694f 100644 --- a/qiniu/sandbox/index.js +++ b/qiniu/sandbox/index.js @@ -5,6 +5,8 @@ const network = require('./network'); module.exports = { DEFAULT_ENDPOINT: constants.DEFAULT_ENDPOINT, + DEFAULT_TEMPLATE: constants.DEFAULT_TEMPLATE, + DEFAULT_SANDBOX_TIMEOUT_MS: constants.DEFAULT_SANDBOX_TIMEOUT_MS, DEFAULT_USER: constants.DEFAULT_USER, ALL_TRAFFIC: network.ALL_TRAFFIC, SandboxClient: require('./client').SandboxClient, @@ -12,6 +14,9 @@ module.exports = { SandboxPaginator: require('./sandbox').SandboxPaginator, SnapshotPaginator: require('./sandbox').SnapshotPaginator, Filesystem: require('./filesystem').Filesystem, + FileType: require('./filesystem').FileType, + FilesystemEventType: require('./filesystem').FilesystemEventType, + WatchHandle: require('./filesystem').WatchHandle, Commands: require('./commands').Commands, CommandHandle: require('./commands').CommandHandle, Git: require('./git').Git, @@ -22,6 +27,10 @@ module.exports = { CommandExitError: errors.CommandExitError, TimeoutError: errors.TimeoutError, NotImplementedError: errors.NotImplementedError, + InvalidArgumentError: errors.InvalidArgumentError, + NotFoundError: errors.NotFoundError, + FileNotFoundError: errors.FileNotFoundError, + SandboxNotFoundError: errors.SandboxNotFoundError, GitAuthError: errors.GitAuthError, GitUpstreamError: errors.GitUpstreamError, TemplateBuildError: errors.TemplateBuildError, diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index f2f85e6..b198187 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -179,6 +179,15 @@ Sandbox.prototype.pause = function () { return this.client.pauseSandbox(this.sandboxId); }; +Sandbox.prototype.betaPause = Sandbox.prototype.pause; + +Sandbox.prototype.connect = function (opts) { + return this.client.connectSandbox(this.sandboxId, opts).then(info => { + this.updateInfo(info); + return this; + }); +}; + Sandbox.prototype.getInfo = function () { return this.client.getSandbox(this.sandboxId); }; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 92f5ab1..7ebe0fb 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -1,4 +1,5 @@ const { SandboxClient } = require('./client'); +const fs = require('fs'); function Template () { if (!(this instanceof Template)) { @@ -7,27 +8,172 @@ function Template () { this.buildConfig = { steps: [] }; + this._forceNextLayer = false; } -Template.prototype.fromImage = function (image) { +Template.prototype.fromImage = function (image, credentials) { this.buildConfig.fromImage = image; + delete this.buildConfig.fromTemplate; + if (credentials) { + this.buildConfig.fromImageRegistry = { + type: 'registry', + username: credentials.username, + password: credentials.password + }; + } + if (this._forceNextLayer) { + this.buildConfig.force = true; + } + return this; +}; + +Template.prototype.fromAWSRegistry = function (image, credentials) { + this.buildConfig.fromImage = image; + delete this.buildConfig.fromTemplate; + this.buildConfig.fromImageRegistry = { + type: 'aws', + awsAccessKeyId: credentials.accessKeyId, + awsSecretAccessKey: credentials.secretAccessKey, + awsRegion: credentials.region + }; + if (this._forceNextLayer) { + this.buildConfig.force = true; + } + return this; +}; + +Template.prototype.fromGCPRegistry = function (image, credentials) { + this.buildConfig.fromImage = image; + delete this.buildConfig.fromTemplate; + const serviceAccountJSON = credentials.serviceAccountJSON; + this.buildConfig.fromImageRegistry = { + type: 'gcp', + serviceAccountJson: typeof serviceAccountJSON === 'string' + ? serviceAccountJSON + : JSON.stringify(serviceAccountJSON) + }; + if (this._forceNextLayer) { + this.buildConfig.force = true; + } return this; }; Template.prototype.fromTemplate = function (templateID) { this.buildConfig.fromTemplate = templateID; + delete this.buildConfig.fromImage; + delete this.buildConfig.fromImageRegistry; + if (this._forceNextLayer) { + this.buildConfig.force = true; + } + return this; +}; + +function padOctal (value) { + let text = Number(value).toString(8); + while (text.length < 4) { + text = `0${text}`; + } + return text; +} + +function asArray (value) { + return Array.isArray(value) ? value : [value]; +} + +function addStep (template, type, args, extra) { + const step = Object.assign({ + type, + args: args.map(value => String(value)) + }, extra || {}); + if (template._forceNextLayer && step.force === undefined) { + step.force = true; + } + template.buildConfig.steps.push(step); + return template; +} + +function runShellStep (template, command, options) { + const args = [Array.isArray(command) ? command.join(' && ') : command]; + if (options && options.user) { + args.push(options.user); + } + return addStep(template, 'RUN', args); +} + +function parseEnvArgs (value) { + const args = []; + const pattern = /([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)/g; + let match; + while ((match = pattern.exec(value))) { + let envValue = match[2]; + if ( + (envValue[0] === '"' && envValue[envValue.length - 1] === '"') || + (envValue[0] === '\'' && envValue[envValue.length - 1] === '\'') + ) { + envValue = envValue.slice(1, -1); + } + args.push(match[1], envValue); + } + return args; +} + +Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { + const content = fs.existsSync(dockerfileContentOrPath) + ? fs.readFileSync(dockerfileContentOrPath, 'utf8') + : dockerfileContentOrPath; + content.split(/\r?\n/).forEach(line => { + line = line.trim(); + if (!line || line[0] === '#') { + return; + } + const match = line.match(/^([A-Z]+)\s+(.+)$/i); + if (!match) { + return; + } + const instruction = match[1].toUpperCase(); + const rest = match[2].trim(); + if (instruction === 'FROM') { + this.fromImage(rest.split(/\s+/)[0]); + } else if (instruction === 'RUN') { + this.runCmd(rest); + } else if (instruction === 'WORKDIR') { + this.setWorkdir(rest); + } else if (instruction === 'USER') { + this.setUser(rest); + } else if (instruction === 'ENV') { + const args = parseEnvArgs(rest); + if (args.length) { + addStep(this, 'ENV', args); + } + } else if (instruction === 'COPY' || instruction === 'ADD') { + const parts = rest.split(/\s+/); + if (parts.length >= 2) { + this.copy(parts.slice(0, -1), parts[parts.length - 1]); + } + } + }); return this; }; -Template.prototype.aptInstall = function (packages) { +Template.prototype.aptInstall = function (packages, options) { + if (options) { + const packageList = asArray(packages); + return this.runCmd([ + 'apt-get update', + `DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y ${options.noInstallRecommends ? '--no-install-recommends ' : ''}${options.fixMissing ? '--fix-missing ' : ''}${packageList.join(' ')}` + ], { user: 'root' }); + } this.buildConfig.steps.push({ type: 'apt', - packages: Array.isArray(packages) ? packages : [packages] + packages: asArray(packages) }); return this; }; -Template.prototype.runCmd = function (cmd) { +Template.prototype.runCmd = function (cmd, options) { + if (Array.isArray(cmd) || options || this._forceNextLayer) { + return runShellStep(this, cmd, options); + } this.buildConfig.steps.push({ type: 'run', cmd @@ -35,7 +181,26 @@ Template.prototype.runCmd = function (cmd) { return this; }; -Template.prototype.copy = function (src, dest) { +Template.prototype.copy = function (src, dest, options) { + if (Array.isArray(src) || options) { + asArray(src).forEach(item => { + const args = [ + item, + dest, + options && options.user ? options.user : '', + options && options.mode ? padOctal(options.mode) : '' + ]; + const extra = {}; + if (options && options.forceUpload) { + extra.forceUpload = options.forceUpload; + } + if (options && options.resolveSymlinks !== undefined) { + extra.resolveSymlinks = options.resolveSymlinks; + } + addStep(this, 'COPY', args, extra); + }); + return this; + } this.buildConfig.steps.push({ type: 'copy', src, @@ -44,6 +209,144 @@ Template.prototype.copy = function (src, dest) { return this; }; +Template.prototype.copyItems = function (items) { + items.forEach(item => { + this.copy(item.src, item.dest, { + forceUpload: item.forceUpload, + user: item.user, + mode: item.mode, + resolveSymlinks: item.resolveSymlinks + }); + }); + return this; +}; + +Template.prototype.remove = function (path, options) { + options = options || {}; + const args = ['rm']; + if (options.recursive) { + args.push('-r'); + } + if (options.force) { + args.push('-f'); + } + args.push.apply(args, asArray(path)); + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.rename = function (src, dest, options) { + options = options || {}; + const args = ['mv', src, dest]; + if (options.force) { + args.push('-f'); + } + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.makeDir = function (path, options) { + options = options || {}; + const args = ['mkdir', '-p']; + if (options.mode) { + args.push(`-m ${padOctal(options.mode)}`); + } + args.push.apply(args, asArray(path)); + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.makeSymlink = function (src, dest, options) { + options = options || {}; + const args = ['ln', '-s']; + if (options.force) { + args.push('-f'); + } + args.push(src, dest); + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.setWorkdir = function (workdir) { + return addStep(this, 'WORKDIR', [workdir]); +}; + +Template.prototype.setUser = function (user) { + return addStep(this, 'USER', [user]); +}; + +Template.prototype.pipInstall = function (packages, options) { + options = options || {}; + const args = ['pip', 'install']; + if (options.g === false) { + args.push('--user'); + } + if (packages) { + args.push.apply(args, asArray(packages)); + } else { + args.push('.'); + } + return this.runCmd(args.join(' '), { user: options.g === false ? undefined : 'root' }); +}; + +Template.prototype.npmInstall = function (packages, options) { + options = options || {}; + const args = ['npm', 'install']; + if (options.g) { + args.push('-g'); + } + if (options.dev) { + args.push('--save-dev'); + } + if (packages) { + args.push.apply(args, asArray(packages)); + } + return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); +}; + +Template.prototype.bunInstall = function (packages, options) { + options = options || {}; + const args = ['bun', 'install']; + if (options.g) { + args.push('-g'); + } + if (options.dev) { + args.push('--dev'); + } + if (packages) { + args.push.apply(args, asArray(packages)); + } + return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); +}; + +Template.prototype.gitClone = function (url, path, options) { + options = options || {}; + const args = ['git', 'clone', url]; + if (options.branch) { + args.push('--branch', options.branch, '--single-branch'); + } + if (options.depth) { + args.push('--depth', options.depth); + } + if (path) { + args.push(path); + } + return this.runCmd(args.join(' '), { user: options.user }); +}; + +Template.prototype.setEnvs = function (envs) { + const keys = Object.keys(envs || {}); + if (!keys.length) { + return this; + } + const args = []; + keys.forEach(key => { + args.push(key, envs[key]); + }); + return addStep(this, 'ENV', args); +}; + +Template.prototype.skipCache = function () { + this._forceNextLayer = true; + return this; +}; + Template.prototype.setStartCmd = function (cmd) { this.buildConfig.startCmd = cmd; return this; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index c6445e9..7f43180 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -549,6 +549,101 @@ describe('test sandbox module', function () { }); }); + it('watches directory changes and returns a stoppable handle after start event', function () { + let watchResponse; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + const body = decodeConnectEnvelope(req.rawBody); + body.path.should.eql('/workspace'); + body.recursive.should.eql(true); + req.headers['content-type'].should.eql('application/connect+json'); + req.headers['keepalive-ping-interval'].should.eql('50'); + watchResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: {} } })); + setTimeout(() => { + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + })); + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'written.txt', + type: 2 + } + } + })); + res.write(encodeConnectEnvelope({ event: { keepalive: {} } })); + }, 10); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const events = []; + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', event => { + events.push(event); + }, { + recursive: true, + requestTimeoutMs: 1000 + }).then(handle => { + (typeof handle.stop).should.eql('function'); + return new Promise(resolve => setTimeout(resolve, 40)).then(() => { + events.should.eql([ + { name: 'created.txt', type: qiniu.sandbox.FilesystemEventType.CREATE }, + { name: 'written.txt', type: qiniu.sandbox.FilesystemEventType.WRITE } + ]); + return handle.stop(); + }); + }).then(() => { + if (watchResponse) { + watchResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (watchResponse) { + watchResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects recursive directory watching on envd versions without support', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_old_watch', + envdUrl: 'http://127.0.0.1:9', + info: { + envdVersion: '0.1.3' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject'); + }, err => { + err.message.should.match(/recursive watching/i); + }); + }); + it('runs commands and git operations through process RPC', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -973,6 +1068,119 @@ describe('test sandbox module', function () { }); }); + it('supports E2B style Template filesystem, env, package, and git helpers', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_helpers', buildID: 'bld_helpers' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .copyItems([ + { src: 'app.js', dest: '/app/', user: 'root', mode: 0o755 }, + { src: ['package.json', 'package-lock.json'], dest: '/app/' } + ]) + .remove(['/tmp/cache', '/tmp/old'], { recursive: true, force: true, user: 'root' }) + .rename('/tmp/a', '/tmp/b', { force: true }) + .makeDir(['/app/data', '/app/logs'], { mode: 0o755 }) + .makeSymlink('/usr/bin/node', '/usr/local/bin/node', { force: true, user: 'root' }) + .setWorkdir('/app') + .setUser('node') + .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) + .pipInstall(['numpy', 'pandas'], { g: false }) + .npmInstall('typescript', { dev: true }) + .npmInstall('tsx', { g: true }) + .bunInstall(['elysia'], { dev: true }) + .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk', { + branch: 'sandbox', + depth: 1, + user: 'root' + }) + .runCmd(['echo one', 'echo two'], { user: 'root' }) + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'helper-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['app.js', '/app/', 'root', '0755'] }, + { type: 'COPY', args: ['package.json', '/app/', '', ''] }, + { type: 'COPY', args: ['package-lock.json', '/app/', '', ''] }, + { type: 'RUN', args: ['rm -r -f /tmp/cache /tmp/old', 'root'] }, + { type: 'RUN', args: ['mv /tmp/a /tmp/b -f'] }, + { type: 'RUN', args: ['mkdir -p -m 0755 /app/data /app/logs'] }, + { type: 'RUN', args: ['ln -s -f /usr/bin/node /usr/local/bin/node', 'root'] }, + { type: 'WORKDIR', args: ['/app'] }, + { type: 'USER', args: ['node'] }, + { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '8080'] }, + { type: 'RUN', args: ['pip install --user numpy pandas'] }, + { type: 'RUN', args: ['npm install --save-dev typescript'] }, + { type: 'RUN', args: ['npm install -g tsx', 'root'] }, + { type: 'RUN', args: ['bun install --dev elysia'] }, + { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing curl', 'root'] }, + { type: 'RUN', args: ['git clone https://github.com/qiniu/nodejs-sdk.git --branch sandbox --single-branch --depth 1 /src/sdk', 'root'] }, + { type: 'RUN', args: ['echo one && echo two', 'root'] } + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports Template registry, Dockerfile, and skipCache helpers', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_dockerfile', buildID: 'bld_dockerfile' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .skipCache() + .fromImage('registry.example.com/private/app:latest', { + username: 'alice', + password: 'secret' + }) + .runCmd('echo forced') + .fromDockerfile('FROM node:22\nWORKDIR /app\nENV NODE_ENV=production PORT=3000\nRUN npm ci\nCOPY package.json /app/\nUSER node') + .fromAWSRegistry('123456789.dkr.ecr.us-west-2.amazonaws.com/app:latest', { + accessKeyId: 'ak', + secretAccessKey: 'sk', + region: 'us-west-2' + }) + .fromGCPRegistry('gcr.io/project/app:latest', { + serviceAccountJSON: { project_id: 'project' } + }) + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'dockerfile-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromImage.should.eql('gcr.io/project/app:latest'); + body.buildConfig.fromImageRegistry.should.eql({ + type: 'gcp', + serviceAccountJson: JSON.stringify({ project_id: 'project' }) + }); + body.buildConfig.force.should.eql(true); + body.buildConfig.steps.should.eql([ + { type: 'RUN', args: ['echo forced'], force: true }, + { type: 'WORKDIR', args: ['/app'], force: true }, + { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '3000'], force: true }, + { type: 'RUN', args: ['npm ci'], force: true }, + { type: 'COPY', args: ['package.json', '/app/', '', ''], force: true }, + { type: 'USER', args: ['node'], force: true } + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('exposes network constants and maps updateNetwork to Qiniu API', function () { return startServer((req, res) => { res.statusCode = 200; @@ -1941,6 +2149,169 @@ describe('test sandbox module', function () { }); }); }); + + it('exports sandbox constants and typed helpers aligned with common runtime names', function () { + qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(300000); + qiniu.sandbox.FileType.FILE.should.eql('file'); + qiniu.sandbox.FileType.DIR.should.eql('dir'); + qiniu.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS); + qiniu.FileType.should.equal(qiniu.sandbox.FileType); + new qiniu.sandbox.InvalidArgumentError('bad arg').name.should.eql('InvalidArgumentError'); + new qiniu.sandbox.FileNotFoundError('missing').name.should.eql('FileNotFoundError'); + }); + + it('supports instance connect and betaPause aliases', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ + sandboxID: 'sbx_alias', + domain: 'alias.example.com', + envdAccessToken: 'token2', + envdVersion: '0.5.7' + })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/pause') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_alias', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.connect({ timeoutMs: 30000 }) + .then(connected => { + connected.should.equal(sandbox); + sandbox.envdAccessToken.should.eql('token2'); + sandbox.envdVersion.should.eql('0.5.7'); + sandbox.domain.should.eql('alias.example.com'); + return sandbox.betaPause(); + }) + .then(paused => { + should(paused).equal(null); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports commands.connect with E2B style command handle semantics', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Connect') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.selector.pid.should.eql(55); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 55 } } }), + encodeConnectEnvelope({ event: { data: { stdout: 'connected' } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_connect_cmd', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.connect(55, { + requestTimeoutMs: 9000 + }).then(handle => { + handle.pid.should.eql(55); + return handle.wait(); + }).then(result => { + result.stdout.should.eql('connected'); + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B style git option signatures for config and restore helpers', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); + } + }); + + return git.setConfig('user.name', 'Alice', { + path: '/repo', + scope: 'local' + }).then(() => git.getConfig('user.name', { + path: '/repo', + scope: 'local' + })).then(value => { + value.should.eql('Alice'); + return git.configureUser('Alice', 'alice@example.com', { + path: '/repo', + scope: 'local' + }); + }).then(() => git.reset('/repo', { + mode: 'hard', + target: 'HEAD~1', + paths: ['a.txt'] + })).then(() => git.restore('/repo', { + paths: ['a.txt'] + })).then(() => { + commandsSeen.map(item => item.cmd).should.eql([ + 'git config --local \'user.name\' \'Alice\'', + 'git config --local --get \'user.name\'', + 'git config --local \'user.name\' \'Alice\'', + 'git config --local \'user.email\' \'alice@example.com\'', + 'git reset --hard \'HEAD~1\' -- \'a.txt\'', + 'git restore --worktree -- \'a.txt\'' + ]); + commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); + }); + }); + + it('keeps legacy git configureUser repo path when delegating to config helpers', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.configureUser('/repo', 'Alice', 'alice@example.com', { + config: { + 'http.version': 'HTTP/1.1' + } + }).then(result => { + result.exitCode.should.eql(0); + commandsSeen.map(item => item.cmd).should.eql([ + 'git -c \'http.version=HTTP/1.1\' config \'user.name\' \'Alice\'', + 'git -c \'http.version=HTTP/1.1\' config \'user.email\' \'alice@example.com\'' + ]); + commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); + }); + }); }); function handleGitAndPty (git, pty, commandsSeen) { diff --git a/test/sandbox_types.ts b/test/sandbox_types.ts index 44e6b7a..1e3f054 100644 --- a/test/sandbox_types.ts +++ b/test/sandbox_types.ts @@ -28,9 +28,31 @@ async function useSandboxTypes () { } }); + const timeoutMs: number = qiniu.DEFAULT_SANDBOX_TIMEOUT_MS; + const fileType: 'file' = qiniu.FileType.FILE; + const eventType: 'write' = qiniu.FilesystemEventType.WRITE; + await sandbox.connect({ timeoutMs }); + await sandbox.betaPause(); const bytes: Buffer = await sandbox.files.read('/tmp/a.bin', { format: 'bytes' }); const text: string = await sandbox.files.read('/tmp/a.txt', { format: 'text' }); const stream: NodeJS.ReadableStream = await sandbox.files.read('/tmp/a.txt', { format: 'stream' }); + const watchHandle = await sandbox.files.watchDir('/tmp', event => { + const eventName: string = event.name; + const eventKind: 'create' | 'write' | 'remove' | 'rename' | 'chmod' = event.type; + eventName.length; + eventKind.length; + }, { + recursive: true, + requestTimeoutMs: 1000, + onExit: err => { + if (err) { + err.message.length; + } + } + }); + await watchHandle.stop(); + const handle = await sandbox.commands.connect(123); + await handle.disconnect(); await sandbox.commands.run('false', { requestTimeoutMs: 1000, throwOnError: true @@ -46,16 +68,49 @@ async function useSandboxTypes () { username: 'u', password: 'p' }); + await sandbox.git.setConfig('user.name', 'Alice', { path: '/repo', scope: 'local' }); + const gitUser: string = await sandbox.git.getConfig('user.name', { path: '/repo', scope: 'local' }); + await sandbox.git.configureUser('Alice', 'alice@example.com', { path: '/repo', scope: 'local' }); + await sandbox.git.reset('/repo', { mode: 'hard', target: 'HEAD~1', paths: ['a.txt'] }); + await sandbox.git.restore('/repo', { paths: ['a.txt'] }); const template = qiniu.sandbox.Template() .fromImage('ubuntu:22.04') .aptInstall(['git']) .runCmd('git --version'); + qiniu.sandbox.Template() + .skipCache() + .fromImage('registry.example.com/app:latest', { username: 'u', password: 'p' }) + .fromAWSRegistry('123456789.dkr.ecr.us-west-2.amazonaws.com/app:latest', { + accessKeyId: 'ak', + secretAccessKey: 'sk', + region: 'us-west-2' + }) + .fromGCPRegistry('gcr.io/project/app:latest', { + serviceAccountJSON: { project_id: 'project' } + }) + .fromDockerfile('FROM node:22\nRUN npm ci') + .copyItems([{ src: ['package.json'], dest: '/app/', mode: 0o644 }]) + .remove('/tmp/cache', { recursive: true, force: true }) + .rename('/tmp/a', '/tmp/b') + .makeDir(['/app/data'], { mode: 0o755 }) + .makeSymlink('/usr/bin/node', '/usr/local/bin/node', { force: true }) + .setWorkdir('/app') + .setUser('node') + .setEnvs({ NODE_ENV: 'production' }) + .pipInstall(['numpy'], { g: false }) + .npmInstall('typescript', { dev: true }) + .bunInstall(undefined, { g: true }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk', { branch: 'sandbox', depth: 1 }) + .runCmd(['echo one', 'echo two'], { user: 'root' }); await template.build({ client, name: 'typed-template:test' }); await sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }); await qiniu.CommandExitError; bytes.length; text.length; stream.read; + fileType.length; + eventType.length; + gitUser.length; } void useSandboxTypes; From 04381538d9b92dd8302ddc598588ab090d15811a Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 17:52:36 +0800 Subject: [PATCH 05/48] docs(sandbox): remove stale compatibility plan --- .../plans/2026-06-08-sandbox-e2b-compat.md | 690 ------------------ 1 file changed, 690 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md diff --git a/docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md b/docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md deleted file mode 100644 index 43441a1..0000000 --- a/docs/superpowers/plans/2026-06-08-sandbox-e2b-compat.md +++ /dev/null @@ -1,690 +0,0 @@ -# Sandbox E2B Compatibility Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bring the Qiniu Node.js `sandbox` module much closer to E2B JS SDK ergonomics while preserving Qiniu-specific Sandbox APIs such as injection rules, repository resources, and AK/SK authentication. - -**Architecture:** Keep the existing split under `qiniu/sandbox/`, add focused compatibility files instead of growing a single module, and expose both `qiniu.sandbox.*` and E2B-style top-level exports where safe. Implement real behavior where Qiniu OpenAPI or envd supports it, and return typed compatibility errors for E2B APIs that require unsupported backend products such as persistent Volumes. - -**Tech Stack:** CommonJS Node.js SDK, `urllib`, Mocha, `should`, TypeScript declaration file `index.d.ts`, Qiniu Sandbox OpenAPI, envd Connect JSON RPC. - ---- - -## File Structure - -- Modify `index.js`: add E2B-style top-level `Sandbox`, `SandboxClient`, and selected error exports. -- Modify `index.d.ts`: declare E2B-compatible overloads, errors, Git/file/template/network helpers, and Qiniu extensions. -- Modify `qiniu/sandbox/index.js`: export new compatibility classes without breaking existing namespace imports. -- Modify `qiniu/sandbox/errors.js`: expand typed errors: `CommandExitError`, `TimeoutError`, `NotImplementedError`, `GitAuthError`, `GitUpstreamError`, `TemplateBuildError`. -- Modify `qiniu/sandbox/sandbox.js`: support `Sandbox.create(template, opts)`, instance `connect`, `updateNetwork`, snapshots/MCP helpers where OpenAPI supports them, and typed unsupported errors where it does not. -- Modify `qiniu/sandbox/client.js`: add any missing control-plane wrappers found in `spec/openapi-public.yml`, especially sandbox network and template build helpers. -- Modify `qiniu/sandbox/commands.js`: align option names (`requestTimeoutMs`, `signal`) and add E2B-like command failure semantics without breaking existing callers. -- Modify `qiniu/sandbox/filesystem.js`: support read/write formats (`text`, `bytes`, `blob`, `stream`) and add watch compatibility if envd streaming can be represented. -- Modify `qiniu/sandbox/git.js`: add E2B-compatible Git auth, branch, reset, restore, credential cleanup, config scopes, and typed Git errors. -- Create `qiniu/sandbox/template.js`: E2B-style `Template` builder facade mapped to Qiniu template create/build endpoints. -- Create `qiniu/sandbox/network.js`: constants and helpers for Qiniu/E2B network config, including `ALL_TRAFFIC`. -- Create `qiniu/sandbox/volume.js`: explicit unsupported compatibility class unless Qiniu OpenAPI adds a matching volume backend. -- Modify `test/sandbox.test.js`: add unit tests for every compatibility behavior, using fake control-plane/envd servers as existing tests do. -- Modify `test/sandbox_integration.test.js`: extend only with non-destructive real checks; keep slow/destructive template builds behind explicit env flags. - ---- - -### Task 1: E2B-Style Entry Points And Create Overload - -**Files:** -- Modify: `index.js` -- Modify: `qiniu/sandbox/index.js` -- Modify: `qiniu/sandbox/sandbox.js` -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` - -- [ ] **Step 1: Write the failing tests** - -Add these tests to `test/sandbox.test.js`: - -```js -it('exports E2B style top-level Sandbox and client classes', function () { - qiniu.Sandbox.should.equal(qiniu.sandbox.Sandbox); - qiniu.SandboxClient.should.equal(qiniu.sandbox.SandboxClient); - qiniu.CommandExitError.should.equal(qiniu.sandbox.CommandExitError); -}); - -it('supports Sandbox.create(template, opts) overload', function () { - var requests = []; - var server = createSandboxApiServer(function (req) { - requests.push(req); - return { sandboxID: 'sbx-template', templateID: 'nodejs', envdAccessToken: 'token' }; - }); - - return server.listenAsync().then(function () { - return qiniu.sandbox.Sandbox.create('nodejs', { - apiKey: 'test-key', - apiUrl: server.url, - metadata: { source: 'e2b-overload' } - }); - }).then(function (sandbox) { - sandbox.sandboxId.should.equal('sbx-template'); - requests[0].body.templateID.should.equal('nodejs'); - requests[0].body.metadata.source.should.equal('e2b-overload'); - }).finally(function () { - return server.closeAsync(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "E2B style top-level\\|create\\(template"` - -Expected: fail because `qiniu.Sandbox` or `Sandbox.create(template, opts)` is not implemented. - -- [ ] **Step 3: Implement the minimal code** - -Implement `Sandbox.create` argument normalization: - -```js -Sandbox.create = function (templateOrOpts, maybeOpts) { - var opts = typeof templateOrOpts === 'string' - ? Object.assign({}, maybeOpts || {}, { templateID: templateOrOpts }) - : (templateOrOpts || {}); - var client = new SandboxClient(opts); - return client.createSandbox(opts).then(function (info) { - return new Sandbox(info, client); - }); -}; -``` - -Export top-level aliases from `index.js` after loading `qiniu/sandbox.js`: - -```js -var sandbox = require('./qiniu/sandbox.js'); -exports.sandbox = sandbox; -exports.Sandbox = sandbox.Sandbox; -exports.SandboxClient = sandbox.SandboxClient; -exports.CommandExitError = sandbox.CommandExitError; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "E2B style top-level\\|create\\(template"` - -Expected: both tests pass. - ---- - -### Task 2: Typed Errors - -**Files:** -- Modify: `qiniu/sandbox/errors.js` -- Modify: `qiniu/sandbox/index.js` -- Modify: `index.js` -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` - -- [ ] **Step 1: Write the failing tests** - -Add: - -```js -it('exposes typed sandbox compatibility errors', function () { - var err = new qiniu.sandbox.CommandExitError({ - command: 'false', - exitCode: 1, - stdout: 'out', - stderr: 'err' - }); - - err.should.be.instanceOf(Error); - err.name.should.equal('CommandExitError'); - err.exitCode.should.equal(1); - err.stdout.should.equal('out'); - err.stderr.should.equal('err'); - - new qiniu.sandbox.GitAuthError('bad credentials').name.should.equal('GitAuthError'); - new qiniu.sandbox.GitUpstreamError('missing upstream').name.should.equal('GitUpstreamError'); - new qiniu.sandbox.NotImplementedError('volume').name.should.equal('NotImplementedError'); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "typed sandbox compatibility errors"` - -Expected: fail because error classes are missing. - -- [ ] **Step 3: Implement the minimal code** - -Use a small base helper in `qiniu/sandbox/errors.js`: - -```js -function defineError(name) { - function TypedSandboxError(message, props) { - Error.call(this); - this.name = name; - this.message = message || name; - if (props) Object.assign(this, props); - if (Error.captureStackTrace) Error.captureStackTrace(this, TypedSandboxError); - } - TypedSandboxError.prototype = Object.create(Error.prototype); - TypedSandboxError.prototype.constructor = TypedSandboxError; - return TypedSandboxError; -} - -var SandboxError = defineError('SandboxError'); -function CommandExitError(result) { - SandboxError.call(this, 'Command exited with code ' + result.exitCode, result); - this.name = 'CommandExitError'; -} -CommandExitError.prototype = Object.create(SandboxError.prototype); -CommandExitError.prototype.constructor = CommandExitError; -``` - -Export `CommandExitError`, `TimeoutError`, `NotImplementedError`, `GitAuthError`, `GitUpstreamError`, and `TemplateBuildError`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "typed sandbox compatibility errors"` - -Expected: pass. - ---- - -### Task 3: Command Compatibility - -**Files:** -- Modify: `qiniu/sandbox/commands.js` -- Modify: `qiniu/sandbox/envd.js` -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` - -- [ ] **Step 1: Write the failing tests** - -Add: - -```js -it('supports E2B command timeout aliases and optional exit throwing', function () { - var calls = []; - var sandbox = createFakeSandbox({ - rpc: function (service, method, body, opts) { - calls.push({ service: service, method: method, body: body, opts: opts }); - return Promise.resolve({ - process: { pid: 123 }, - event: { end: { exitCode: 2 } }, - stdout: Buffer.from('out').toString('base64'), - stderr: Buffer.from('err').toString('base64') - }); - } - }); - - return sandbox.commands.run('false', { - requestTimeoutMs: 12000, - throwOnError: true - }).then(function () { - throw new Error('expected command to throw'); - }, function (err) { - err.name.should.equal('CommandExitError'); - err.exitCode.should.equal(2); - err.stdout.should.equal('out'); - err.stderr.should.equal('err'); - calls[0].opts.timeout.should.equal(12000); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "command timeout aliases"` - -Expected: fail because `requestTimeoutMs` and `throwOnError` semantics are missing. - -- [ ] **Step 3: Implement the minimal code** - -Normalize timeout options: - -```js -function requestTimeout(opts) { - return opts && (opts.requestTimeoutMs || opts.timeoutMs || opts.timeout); -} -``` - -After command completion: - -```js -if (opts && opts.throwOnError && result.exitCode) { - throw new CommandExitError(result); -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "command timeout aliases"` - -Expected: pass. - ---- - -### Task 4: Filesystem Format Compatibility - -**Files:** -- Modify: `qiniu/sandbox/filesystem.js` -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` - -- [ ] **Step 1: Write the failing tests** - -Add: - -```js -it('reads files as text, bytes, blob, and stream formats', function () { - var sandbox = createFakeSandbox({ - fileRead: function () { - return Promise.resolve(Buffer.from('hello')); - } - }); - - return sandbox.files.read('/tmp/a.txt').then(function (text) { - text.should.equal('hello'); - return sandbox.files.read('/tmp/a.txt', { format: 'bytes' }); - }).then(function (bytes) { - Buffer.isBuffer(bytes).should.equal(true); - bytes.toString().should.equal('hello'); - return sandbox.files.read('/tmp/a.txt', { format: 'stream' }); - }).then(function (stream) { - (typeof stream.pipe).should.equal('function'); - return sandbox.files.read('/tmp/a.txt', { format: 'blob' }); - }).then(function (blob) { - if (typeof Blob !== 'undefined') { - blob.should.be.instanceOf(Blob); - } else { - Buffer.isBuffer(blob).should.equal(true); - } - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "reads files as text"` - -Expected: fail because non-text formats are incomplete. - -- [ ] **Step 3: Implement the minimal code** - -Convert the existing file read buffer: - -```js -var Readable = require('stream').Readable; - -function formatReadResult(buffer, opts) { - var format = opts && opts.format || 'text'; - if (format === 'bytes') return buffer; - if (format === 'stream') return Readable.from([buffer]); - if (format === 'blob' && typeof Blob !== 'undefined') return new Blob([buffer]); - if (format === 'blob') return buffer; - return buffer.toString(); -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "reads files as text"` - -Expected: pass. - ---- - -### Task 5: Git Advanced Operations And Auth - -**Files:** -- Modify: `qiniu/sandbox/git.js` -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` -- Test: `test/sandbox_integration.test.js` - -- [ ] **Step 1: Write the failing unit tests** - -Add: - -```js -it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { - var commands = []; - var sandbox = createFakeSandbox({ - run: function (cmd) { - commands.push(cmd); - if (cmd.indexOf('branch --format') >= 0) return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); - if (cmd.indexOf('remote get-url origin') >= 0) return Promise.resolve({ stdout: 'https://github.com/acme/repo.git\n', exitCode: 0 }); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return sandbox.git.clone('https://github.com/acme/repo.git', '/repo', { - username: 'u', - password: 'p', - depth: 1, - branch: 'main' - }).then(function () { - return sandbox.git.branches('/repo'); - }).then(function (branches) { - branches.should.eql([{ name: 'main', current: true }, { name: 'feature', current: false }]); - return sandbox.git.reset('/repo', { hard: true, ref: 'HEAD~1' }); - }).then(function () { - return sandbox.git.restore('/repo', { staged: true, paths: ['a.txt'] }); - }).then(function () { - commands.join('\n').should.containEql('clone --depth 1 --branch main'); - commands.join('\n').should.containEql('remote set-url origin https://github.com/acme/repo.git'); - commands.join('\n').should.containEql('reset --hard HEAD~1'); - commands.join('\n').should.containEql('restore --staged -- a.txt'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "git auth, branches"` - -Expected: fail because methods/options are missing or incomplete. - -- [ ] **Step 3: Implement the minimal code** - -Add helpers: - -```js -function authedUrl(url, opts) { - if (!opts || !opts.username || !opts.password) return url; - return url.replace(/^https:\/\//, 'https://' + encodeURIComponent(opts.username) + ':' + encodeURIComponent(opts.password) + '@'); -} - -function stripAuth(url) { - return url.replace(/^https:\/\/[^/@]+:[^/@]+@/, 'https://'); -} -``` - -After clone/push/pull with credentials, restore `origin` to the stripped URL unless `dangerouslyStoreCredentials` is true. - -Add `branches`, `reset`, `restore`, `dangerouslyAuthenticate`, `remoteAdd` overwrite/fetch options, `commit` author options, and config scopes using the current `_runGit()` path. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "git auth, branches"` - -Expected: pass. - ---- - -### Task 6: Template Builder Facade - -**Files:** -- Create: `qiniu/sandbox/template.js` -- Modify: `qiniu/sandbox/client.js` -- Modify: `qiniu/sandbox/index.js` -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` - -- [ ] **Step 1: Write the failing tests** - -Add: - -```js -it('builds templates through an E2B style Template facade', function () { - var requests = []; - var server = createSandboxApiServer(function (req) { - requests.push(req); - return { templateID: 'tpl_1', buildID: 'bld_1', status: 'building' }; - }); - - return server.listenAsync().then(function () { - var template = qiniu.sandbox.Template() - .fromImage('ubuntu:22.04') - .aptInstall(['git']) - .runCmd('node --version') - .setStartCmd('node server.js') - .setReadyCmd('curl -f http://localhost:3000/health'); - - return template.build({ - apiKey: 'test-key', - apiUrl: server.url, - name: 'node-template:test' - }); - }).then(function (result) { - result.templateID.should.equal('tpl_1'); - requests[0].body.name.should.equal('node-template:test'); - requests[0].body.cpuCount.should.be.undefined(); - requests[0].body.buildConfig.fromImage.should.equal('ubuntu:22.04'); - requests[0].body.buildConfig.steps[0].type.should.equal('apt'); - requests[0].body.buildConfig.steps[1].type.should.equal('run'); - }).finally(function () { - return server.closeAsync(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Template facade"` - -Expected: fail because `Template` is missing. - -- [ ] **Step 3: Implement the minimal code** - -Create a chainable facade: - -```js -function Template() { - if (!(this instanceof Template)) return new Template(); - this.buildConfig = { steps: [] }; -} -Template.prototype.fromImage = function (image) { this.buildConfig.fromImage = image; return this; }; -Template.prototype.fromTemplate = function (templateID) { this.buildConfig.fromTemplate = templateID; return this; }; -Template.prototype.aptInstall = function (packages) { this.buildConfig.steps.push({ type: 'apt', packages: packages }); return this; }; -Template.prototype.runCmd = function (cmd) { this.buildConfig.steps.push({ type: 'run', cmd: cmd }); return this; }; -Template.prototype.copy = function (src, dest) { this.buildConfig.steps.push({ type: 'copy', src: src, dest: dest }); return this; }; -Template.prototype.setStartCmd = function (cmd) { this.buildConfig.startCmd = cmd; return this; }; -Template.prototype.setReadyCmd = function (cmd) { this.buildConfig.readyCmd = cmd; return this; }; -Template.prototype.build = function (opts) { - var client = new SandboxClient(opts); - return client.createTemplateV3(Object.assign({}, opts, { buildConfig: this.buildConfig })); -}; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Template facade"` - -Expected: pass. - ---- - -### Task 7: Network, Snapshot, MCP, And Unsupported Volume Compatibility - -**Files:** -- Create: `qiniu/sandbox/network.js` -- Create: `qiniu/sandbox/volume.js` -- Modify: `qiniu/sandbox/sandbox.js` -- Modify: `qiniu/sandbox/client.js` -- Modify: `qiniu/sandbox/index.js` -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` - -- [ ] **Step 1: Write the failing tests** - -Add: - -```js -it('exposes network constants and maps updateNetwork to Qiniu API', function () { - var requests = []; - var server = createSandboxApiServer(function (req) { - requests.push(req); - return { sandboxID: 'sbx-net', network: req.body.network }; - }); - - return server.listenAsync().then(function () { - var client = new qiniu.sandbox.SandboxClient({ apiKey: 'test-key', apiUrl: server.url }); - var sandbox = new qiniu.sandbox.Sandbox({ sandboxID: 'sbx-net' }, client); - qiniu.sandbox.ALL_TRAFFIC.should.equal('0.0.0.0/0'); - return sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }); - }).then(function () { - requests[0].method.should.equal('PATCH'); - requests[0].url.should.containEql('/sandboxes/sbx-net'); - requests[0].body.network.allowOut[0].should.equal('0.0.0.0/0'); - }).finally(function () { - return server.closeAsync(); - }); -}); - -it('returns typed unsupported errors for E2B volume compatibility', function () { - var volume = new qiniu.sandbox.Volume(); - return volume.create().then(function () { - throw new Error('expected volume.create to fail'); - }, function (err) { - err.name.should.equal('NotImplementedError'); - err.message.should.containEql('Volume'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "network constants\\|volume compatibility"` - -Expected: fail because helpers are missing. - -- [ ] **Step 3: Implement the minimal code** - -Add: - -```js -exports.ALL_TRAFFIC = '0.0.0.0/0'; -``` - -Map `sandbox.updateNetwork(network)` to the existing sandbox update endpoint with body `{ network: network }`. - -Create `Volume` with methods that reject: - -```js -Volume.prototype.create = function () { - return Promise.reject(new NotImplementedError('Volume is not supported by Qiniu Sandbox OpenAPI')); -}; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "network constants\\|volume compatibility"` - -Expected: pass. - ---- - -### Task 8: Qiniu Extensions And Integration Gates - -**Files:** -- Modify: `qiniu/sandbox/client.js` -- Modify: `qiniu/sandbox/sandbox.js` -- Modify: `index.d.ts` -- Modify: `test/sandbox_integration.test.js` - -- [ ] **Step 1: Write the failing tests** - -Add unit coverage for Qiniu-only sandbox creation body fields: - -```js -it('keeps Qiniu sandbox extensions in create body', function () { - var requests = []; - var server = createSandboxApiServer(function (req) { - requests.push(req); - return { sandboxID: 'sbx-qiniu', templateID: 'base' }; - }); - - return server.listenAsync().then(function () { - return qiniu.sandbox.Sandbox.create({ - apiKey: 'test-key', - apiUrl: server.url, - mcp: { enabled: true }, - injections: [{ injectionRuleID: 'rule_1' }], - resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] - }); - }).then(function () { - requests[0].body.mcp.enabled.should.equal(true); - requests[0].body.injections[0].injectionRuleID.should.equal('rule_1'); - requests[0].body.resources[0].type.should.equal('github_repository'); - }).finally(function () { - return server.closeAsync(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Qiniu sandbox extensions"` - -Expected: fail if any extension field is dropped. - -- [ ] **Step 3: Implement the minimal code** - -Extend the allowed create body field list in `SandboxClient.createSandbox()` to pass through `mcp`, `injections`, and `resources`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js --grep "Qiniu sandbox extensions"` - -Expected: pass. - ---- - -### Task 9: Type Declarations And Verification - -**Files:** -- Modify: `index.d.ts` -- Test: `test/sandbox.test.js` -- Test: `test/sandbox_integration.test.js` - -- [ ] **Step 1: Add declaration coverage through `npm run check-type`** - -Update `index.d.ts` with: - -```ts -export const Sandbox: typeof sandbox.Sandbox -export const SandboxClient: typeof sandbox.SandboxClient -export const CommandExitError: typeof sandbox.CommandExitError -``` - -Add overloads: - -```ts -static create(opts?: SandboxCreateOptions): Promise -static create(template: string, opts?: SandboxCreateOptions): Promise -``` - -Declare `Template`, `Volume`, `ALL_TRAFFIC`, command `requestTimeoutMs`, file read formats, and Git methods from Tasks 3-7. - -- [ ] **Step 2: Run full unit tests** - -Run: `./node_modules/.bin/mocha -t 300000 test/sandbox.test.js` - -Expected: all sandbox unit tests pass. - -- [ ] **Step 3: Run type check** - -Run: `npm run check-type` - -Expected: pass. - -- [ ] **Step 4: Run focused lint** - -Run: `./node_modules/.bin/eslint qiniu/sandbox test/sandbox.test.js test/sandbox_integration.test.js` - -Expected: pass. - -- [ ] **Step 5: Run optional integration** - -Run: `./node_modules/.bin/mocha -t 600000 test/sandbox_integration.test.js` - -Expected: real integration passes when `.env` has `QINIU_SANDBOX_INTEGRATION=true`, `QINIU_SANDBOX_API_KEY`, and optional `GIT_REPO_URL`, `GIT_USERNAME`, `GIT_PASSWORD`; injection rule test remains pending unless `QINIU_SANDBOX_TEST_INJECTION_RULES=true` and AK/SK are present. - ---- - -## Self-Review - -- Spec coverage: The plan covers E2B-style Sandbox entry points, command behavior, filesystem read formats, Git operations/auth, template builder, network update, unsupported Volume handling, Qiniu injection/resource extensions, TypeScript declarations, and integration gates. -- Backend reality check: E2B persistent Volume is not present in the Qiniu public OpenAPI observed during planning, so the compatibility surface returns `NotImplementedError` instead of pretending a server-backed Volume exists. -- Placeholder scan: The plan avoids deferred TODOs and gives each task explicit test snippets, implementation shape, and verification commands. -- Type consistency: Method names match the current `qiniu.sandbox` namespace and proposed E2B-compatible aliases. From b674adc413ab53eb5cdd1cd4571412b0cd690bee Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 18:28:58 +0800 Subject: [PATCH 06/48] fix(sandbox): harden runtime compatibility Encode Buffer stdin correctly, avoid newer Node-only URL and stream APIs in sandbox runtime paths, and quote Template shell helper arguments to preserve paths with spaces. --- qiniu/sandbox/commands.js | 3 ++- qiniu/sandbox/filesystem.js | 12 ++++++++---- qiniu/sandbox/pty.js | 5 +++-- qiniu/sandbox/template.js | 25 ++++++++++++------------ qiniu/sandbox/util.js | 32 +++++++++++++++++++++++++++++++ test/sandbox.test.js | 38 ++++++++++++++++++++----------------- 6 files changed, 79 insertions(+), 36 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 8aa6c9f..e27e382 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -189,12 +189,13 @@ Commands.prototype.connect = function (pid, opts) { }; Commands.prototype.sendStdin = function (pid, data, opts) { + const stdin = Buffer.isBuffer(data) ? data.toString('base64') : Buffer.from(String(data)).toString('base64'); return connectRPC(this.sandbox, '/process.Process/SendInput', { process: { selector: { pid } }, input: { - stdin: typeof data === 'string' ? Buffer.from(data).toString('base64') : data + stdin } }, opts).then(() => null); }; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 15c0cb3..cf3a4e1 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -1,6 +1,6 @@ const { connectRPC, envdHeaders } = require('./envd'); const { SandboxError } = require('./errors'); -const { rawRequest } = require('./util'); +const { parseRequestUrl, rawRequest } = require('./util'); const { Readable } = require('stream'); const zlib = require('zlib'); const http = require('http'); @@ -152,7 +152,11 @@ function formatReadResult (data, opts) { return buffer; } if (format === 'stream') { - return Readable.from([buffer]); + const stream = new Readable(); + stream._read = function () {}; + stream.push(buffer); + stream.push(null); + return stream; } if (format === 'blob') { return typeof global.Blob !== 'undefined' ? new global.Blob([buffer]) : buffer; @@ -306,7 +310,7 @@ exports.normalizeEntry = normalizeEntry; function watchDir (sandbox, path, onEvent, opts) { return new Promise((resolve, reject) => { - const target = new URL(sandbox.envdUrl() + '/filesystem.Filesystem/WatchDir'); + const target = parseRequestUrl(sandbox.envdUrl() + '/filesystem.Filesystem/WatchDir'); const transport = target.protocol === 'https:' ? https : http; const headers = Object.assign({ 'Content-Type': 'application/connect+json', @@ -317,7 +321,7 @@ function watchDir (sandbox, path, onEvent, opts) { protocol: target.protocol, hostname: target.hostname, port: target.port, - path: target.pathname + target.search, + path: target.path, headers }); diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 10bb3db..6f5ecae 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -1,5 +1,6 @@ const { connectRPC } = require('./envd'); const { envdHeaders } = require('./envd'); +const { parseRequestUrl } = require('./util'); const http = require('http'); const https = require('https'); @@ -59,7 +60,7 @@ LivePtyHandle.prototype.disconnect = function () { function connectLivePty (sandbox, procedure, body, opts, pty) { opts = opts || {}; return new Promise((resolve, reject) => { - const target = new URL(sandbox.envdUrl() + procedure); + const target = parseRequestUrl(sandbox.envdUrl() + procedure); const transport = target.protocol === 'https:' ? https : http; const headers = Object.assign({ 'Content-Type': 'application/connect+json', @@ -70,7 +71,7 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { protocol: target.protocol, hostname: target.hostname, port: target.port, - path: target.pathname + target.search, + path: target.path, headers }); diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 7ebe0fb..4c98306 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -1,4 +1,5 @@ const { SandboxClient } = require('./client'); +const { shellQuote } = require('./util'); const fs = require('fs'); function Template () { @@ -157,7 +158,7 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { Template.prototype.aptInstall = function (packages, options) { if (options) { - const packageList = asArray(packages); + const packageList = asArray(packages).map(shellQuote); return this.runCmd([ 'apt-get update', `DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y ${options.noInstallRecommends ? '--no-install-recommends ' : ''}${options.fixMissing ? '--fix-missing ' : ''}${packageList.join(' ')}` @@ -230,13 +231,13 @@ Template.prototype.remove = function (path, options) { if (options.force) { args.push('-f'); } - args.push.apply(args, asArray(path)); + args.push.apply(args, asArray(path).map(shellQuote)); return this.runCmd(args.join(' '), { user: options.user }); }; Template.prototype.rename = function (src, dest, options) { options = options || {}; - const args = ['mv', src, dest]; + const args = ['mv', shellQuote(src), shellQuote(dest)]; if (options.force) { args.push('-f'); } @@ -249,7 +250,7 @@ Template.prototype.makeDir = function (path, options) { if (options.mode) { args.push(`-m ${padOctal(options.mode)}`); } - args.push.apply(args, asArray(path)); + args.push.apply(args, asArray(path).map(shellQuote)); return this.runCmd(args.join(' '), { user: options.user }); }; @@ -259,7 +260,7 @@ Template.prototype.makeSymlink = function (src, dest, options) { if (options.force) { args.push('-f'); } - args.push(src, dest); + args.push(shellQuote(src), shellQuote(dest)); return this.runCmd(args.join(' '), { user: options.user }); }; @@ -278,7 +279,7 @@ Template.prototype.pipInstall = function (packages, options) { args.push('--user'); } if (packages) { - args.push.apply(args, asArray(packages)); + args.push.apply(args, asArray(packages).map(shellQuote)); } else { args.push('.'); } @@ -295,7 +296,7 @@ Template.prototype.npmInstall = function (packages, options) { args.push('--save-dev'); } if (packages) { - args.push.apply(args, asArray(packages)); + args.push.apply(args, asArray(packages).map(shellQuote)); } return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); }; @@ -310,22 +311,22 @@ Template.prototype.bunInstall = function (packages, options) { args.push('--dev'); } if (packages) { - args.push.apply(args, asArray(packages)); + args.push.apply(args, asArray(packages).map(shellQuote)); } return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); }; Template.prototype.gitClone = function (url, path, options) { options = options || {}; - const args = ['git', 'clone', url]; + const args = ['git', 'clone', shellQuote(url)]; if (options.branch) { - args.push('--branch', options.branch, '--single-branch'); + args.push('--branch', shellQuote(options.branch), '--single-branch'); } if (options.depth) { - args.push('--depth', options.depth); + args.push('--depth', shellQuote(options.depth)); } if (path) { - args.push(path); + args.push(shellQuote(path)); } return this.runCmd(args.join(' '), { user: options.user }); }; diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 5d31f2e..9e95a51 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -101,6 +101,37 @@ function rawRequest (requestUrl, options) { }); } +function parseRequestUrl (requestUrl) { + const match = String(requestUrl).match(/^(https?:)\/\/([^/?#]+)([^?#]*)(\?[^#]*)?/); + if (!match) { + throw new SandboxError(`Invalid request URL: ${requestUrl}`); + } + + const host = match[2]; + let hostname = host; + let port = ''; + if (host.charAt(0) === '[') { + const end = host.indexOf(']'); + hostname = host.slice(1, end); + if (host.charAt(end + 1) === ':') { + port = host.slice(end + 2); + } + } else { + const colon = host.lastIndexOf(':'); + if (colon > 0 && host.indexOf(':') === colon) { + hostname = host.slice(0, colon); + port = host.slice(colon + 1); + } + } + + return { + protocol: match[1], + hostname, + port, + path: (match[3] || '/') + (match[4] || '') + }; +} + function parseJSON (data) { if (Buffer.isBuffer(data)) { data = data.toString(); @@ -125,5 +156,6 @@ exports.poll = poll; exports.basicAuth = basicAuth; exports.fileSignature = fileSignature; exports.rawRequest = rawRequest; +exports.parseRequestUrl = parseRequestUrl; exports.parseJSON = parseJSON; exports.shellQuote = shellQuote; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 7f43180..cbe8cbd 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1080,10 +1080,10 @@ describe('test sandbox module', function () { { src: 'app.js', dest: '/app/', user: 'root', mode: 0o755 }, { src: ['package.json', 'package-lock.json'], dest: '/app/' } ]) - .remove(['/tmp/cache', '/tmp/old'], { recursive: true, force: true, user: 'root' }) - .rename('/tmp/a', '/tmp/b', { force: true }) - .makeDir(['/app/data', '/app/logs'], { mode: 0o755 }) - .makeSymlink('/usr/bin/node', '/usr/local/bin/node', { force: true, user: 'root' }) + .remove(['/tmp/cache dir', '/tmp/old'], { recursive: true, force: true, user: 'root' }) + .rename('/tmp/a file', '/tmp/b file', { force: true }) + .makeDir(['/app/data dir', '/app/logs'], { mode: 0o755 }) + .makeSymlink('/usr/bin/node', '/usr/local/bin/node link', { force: true, user: 'root' }) .setWorkdir('/app') .setUser('node') .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) @@ -1092,7 +1092,7 @@ describe('test sandbox module', function () { .npmInstall('tsx', { g: true }) .bunInstall(['elysia'], { dev: true }) .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) - .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk', { + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { branch: 'sandbox', depth: 1, user: 'root' @@ -1108,19 +1108,19 @@ describe('test sandbox module', function () { { type: 'COPY', args: ['app.js', '/app/', 'root', '0755'] }, { type: 'COPY', args: ['package.json', '/app/', '', ''] }, { type: 'COPY', args: ['package-lock.json', '/app/', '', ''] }, - { type: 'RUN', args: ['rm -r -f /tmp/cache /tmp/old', 'root'] }, - { type: 'RUN', args: ['mv /tmp/a /tmp/b -f'] }, - { type: 'RUN', args: ['mkdir -p -m 0755 /app/data /app/logs'] }, - { type: 'RUN', args: ['ln -s -f /usr/bin/node /usr/local/bin/node', 'root'] }, + { type: 'RUN', args: ['rm -r -f \'/tmp/cache dir\' \'/tmp/old\'', 'root'] }, + { type: 'RUN', args: ['mv \'/tmp/a file\' \'/tmp/b file\' -f'] }, + { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/data dir\' \'/app/logs\''] }, + { type: 'RUN', args: ['ln -s -f \'/usr/bin/node\' \'/usr/local/bin/node link\'', 'root'] }, { type: 'WORKDIR', args: ['/app'] }, { type: 'USER', args: ['node'] }, { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '8080'] }, - { type: 'RUN', args: ['pip install --user numpy pandas'] }, - { type: 'RUN', args: ['npm install --save-dev typescript'] }, - { type: 'RUN', args: ['npm install -g tsx', 'root'] }, - { type: 'RUN', args: ['bun install --dev elysia'] }, - { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing curl', 'root'] }, - { type: 'RUN', args: ['git clone https://github.com/qiniu/nodejs-sdk.git --branch sandbox --single-branch --depth 1 /src/sdk', 'root'] }, + { type: 'RUN', args: ['pip install --user \'numpy\' \'pandas\''] }, + { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, + { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, + { type: 'RUN', args: ['bun install --dev \'elysia\''] }, + { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, + { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, { type: 'RUN', args: ['echo one && echo two', 'root'] } ]); }).then(() => closeServer(fixture.server), err => { @@ -1434,8 +1434,12 @@ describe('test sandbox module', function () { return sandbox.commands.list(); }).then(list => { list[0].cwd.should.eql('/w'); - return sandbox.commands.sendStdin(12, 'hello'); - }).then(() => sandbox.commands.closeStdin(12)) + return sandbox.commands.sendStdin(12, Buffer.from('hello')); + }).then(() => { + const sendStdinBody = JSON.parse(fixture.requests[3].body); + sendStdinBody.input.stdin.should.eql(Buffer.from('hello').toString('base64')); + return sandbox.commands.closeStdin(12); + }) .then(() => sandbox.commands.kill(12)) .then(() => handleGitAndPty(git, pty, commandsSeen)) .then(() => closeServer(fixture.server), err => { From 289cb2794d7706a990fd0f38ef18ac5c01b03082 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 18:41:16 +0800 Subject: [PATCH 07/48] fix(sandbox): stream command handles Return command handles as soon as process start events arrive, decode Connect JSON byte fields as base64, and cover live background command streams. --- qiniu/sandbox/commands.js | 270 ++++++++++++++++++++++++++++++-------- test/sandbox.test.js | 59 ++++++++- 2 files changed, 270 insertions(+), 59 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index e27e382..b9dc995 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -1,48 +1,31 @@ -const { connectRPC, connectStreamRPC } = require('./envd'); +const { connectRPC, envdHeaders } = require('./envd'); const { CommandExitError } = require('./errors'); +const { parseJSON, parseRequestUrl } = require('./util'); +const http = require('http'); +const https = require('https'); function eventPayload (event) { return event.event || event; } function bytesToString (value) { + return bytesToBuffer(value).toString(); +} + +function bytesToBuffer (value) { if (!value) { - return ''; + return Buffer.alloc(0); } if (Buffer.isBuffer(value)) { - return value.toString(); + return value; } if (Array.isArray(value)) { - return Buffer.from(value).toString(); - } - if (typeof value === 'string' && isBase64Text(value)) { - return Buffer.from(value, 'base64').toString(); - } - return String(value); -} - -function isBase64Text (value) { - if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { - return false; - } - const normalized = value.replace(/=+$/, ''); - const decoded = Buffer.from(value, 'base64'); - const encoded = decoded.toString('base64').replace(/=+$/, ''); - if (encoded !== normalized) { - return false; + return Buffer.from(value); } - const text = decoded.toString(); - if (!text) { - return false; + if (typeof value === 'string') { + return Buffer.from(value, 'base64'); } - let printable = 0; - for (let i = 0; i < text.length; i++) { - const code = text.charCodeAt(i); - if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) { - printable++; - } - } - return printable / text.length > 0.8; + return Buffer.from(String(value)); } function commandResultFromEvents (events, callbacks) { @@ -78,7 +61,7 @@ function commandResultFromEvents (events, callbacks) { } } if (pty !== undefined && callbacks.onData) { - callbacks.onData(Buffer.isBuffer(pty) ? pty : Buffer.from(Array.isArray(pty) ? pty : bytesToString(pty))); + callbacks.onData(bytesToBuffer(pty)); } } if (end) { @@ -95,18 +78,49 @@ function requestTimeout (opts) { return opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; } -function CommandHandle (commands, pid, result, opts) { +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message || {})); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function eventListFromResponse (data) { + if (Array.isArray(data.events)) { + return data.events; + } + if (Array.isArray(data)) { + return data; + } + if (data.event) { + return [data]; + } + return []; +} + +function CommandHandle (commands, pid, result, opts, request, waitPromise) { this.commands = commands; this.pid = pid; this.result = result; this.opts = opts || {}; + this._request = request; + this._waitPromise = waitPromise; + this.stdout = result && result.stdout ? result.stdout : ''; + this.stderr = result && result.stderr ? result.stderr : ''; } CommandHandle.prototype.wait = function () { - if (this.opts.throwOnError && this.result && this.result.exitCode) { - return Promise.reject(new CommandExitError(this.result)); + const finish = result => { + if (this.opts.throwOnError && result && result.exitCode) { + throw new CommandExitError(result); + } + return result; + }; + if (this._waitPromise) { + return this._waitPromise.then(finish); } - return Promise.resolve(this.result); + return Promise.resolve(this.result).then(finish); }; CommandHandle.prototype.kill = function () { @@ -114,9 +128,169 @@ CommandHandle.prototype.kill = function () { }; CommandHandle.prototype.disconnect = function () { + if (this._request) { + this._request.destroy(); + this._request = null; + } return Promise.resolve(); }; +function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { + opts = opts || {}; + return new Promise((resolve, reject) => { + const target = parseRequestUrl(commands.sandbox.envdUrl() + procedure); + const transport = target.protocol === 'https:' ? https : http; + const headers = Object.assign({ + 'Content-Type': 'application/connect+json', + 'Keepalive-Ping-Interval': '50' + }, envdHeaders(commands.sandbox, opts.user)); + const req = transport.request({ + method: 'POST', + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.path, + headers + }); + + let settled = false; + let handle; + let responseBuffer = Buffer.alloc(0); + let jsonBuffer = Buffer.alloc(0); + let isConnectStream = true; + let result = { + pid: fallbackPid || 0, + exitCode: -1, + stdout: '', + stderr: '', + error: '' + }; + let resolveWait; + let rejectWait; + const waitPromise = new Promise((resolve, reject) => { + resolveWait = resolve; + rejectWait = reject; + }); + + function fail (err) { + if (!settled) { + settled = true; + reject(err); + } + rejectWait(err); + } + + function ensureHandle (pid) { + if (!handle) { + result.pid = pid || result.pid; + handle = new CommandHandle(commands, result.pid, null, opts, req, waitPromise); + settled = true; + resolve(handle); + } + } + + function appendData (data) { + if (data.stdout !== undefined) { + const out = bytesToString(data.stdout); + result.stdout += out; + if (handle) { + handle.stdout += out; + } + if (out && opts.onStdout) { + opts.onStdout(out); + } + } + if (data.stderr !== undefined) { + const err = bytesToString(data.stderr); + result.stderr += err; + if (handle) { + handle.stderr += err; + } + if (err && opts.onStderr) { + opts.onStderr(err); + } + } + if (data.pty !== undefined && opts.onData) { + opts.onData(bytesToBuffer(data.pty)); + } + } + + function finish (end) { + result.exitCode = end && end.exitCode !== undefined ? end.exitCode : 0; + result.error = end && end.error ? end.error : ''; + if (handle) { + handle.result = result; + } + resolveWait(result); + } + + function handleMessage (message) { + const event = eventPayload(message); + if (event.start) { + ensureHandle(event.start.pid); + } + if (event.data) { + appendData(event.data); + } + if (event.end) { + finish(event.end); + } + } + + req.on('response', res => { + if (res.statusCode < 200 || res.statusCode >= 300) { + fail(new Error(`Sandbox envd request failed with status ${res.statusCode}`)); + res.resume(); + return; + } + const contentType = (res.headers && res.headers['content-type']) || ''; + isConnectStream = contentType.indexOf('application/connect+json') >= 0; + res.on('data', chunk => { + if (!isConnectStream) { + jsonBuffer = Buffer.concat([jsonBuffer, chunk]); + return; + } + responseBuffer = Buffer.concat([responseBuffer, chunk]); + while (responseBuffer.length >= 5) { + const flags = responseBuffer[0]; + const length = responseBuffer.readUInt32BE(1); + if (responseBuffer.length < 5 + length) { + break; + } + const payload = responseBuffer.slice(5, 5 + length).toString(); + responseBuffer = responseBuffer.slice(5 + length); + if (!(flags & 2) && payload) { + handleMessage(JSON.parse(payload)); + } + } + }); + res.on('end', () => { + if (!isConnectStream) { + const events = eventListFromResponse(parseJSON(jsonBuffer)); + result = commandResultFromEvents(events, opts); + if (!result.pid && fallbackPid) { + result.pid = fallbackPid; + } + handle = new CommandHandle(commands, result.pid, result, opts); + settled = true; + resolve(handle); + resolveWait(result); + return; + } + if (!settled) { + fail(new Error('Command stream ended before process start')); + return; + } + if (result.exitCode === -1) { + finish({ exitCode: 0 }); + } + }); + }); + req.on('error', fail); + req.end(encodeConnectEnvelope(body)); + }); +} + function Commands (sandbox) { this.sandbox = sandbox; } @@ -145,16 +319,9 @@ Commands.prototype.start = function (cmd, opts) { body.tag = opts.tag; } - return connectStreamRPC(this.sandbox, '/process.Process/Start', body, { - user: opts.user, - keepalive: true, - timeout: requestTimeout(opts), - timeoutMs: requestTimeout(opts), + return connectLiveCommand(this, '/process.Process/Start', body, Object.assign({}, opts, { requestTimeoutMs: requestTimeout(opts) - }).then(events => { - const result = commandResultFromEvents(events, opts); - return new CommandHandle(this, result.pid, result, opts); - }); + })); }; Commands.prototype.list = function (opts) { @@ -172,20 +339,13 @@ Commands.prototype.list = function (opts) { Commands.prototype.connect = function (pid, opts) { opts = opts || {}; - return connectStreamRPC(this.sandbox, '/process.Process/Connect', { + return connectLiveCommand(this, '/process.Process/Connect', { process: { selector: { pid } } - }, { - user: opts.user, - keepalive: true, - timeout: requestTimeout(opts), - timeoutMs: requestTimeout(opts), + }, Object.assign({}, opts, { requestTimeoutMs: requestTimeout(opts) - }).then(events => { - const result = commandResultFromEvents(events, opts); - return new CommandHandle(this, result.pid || pid, result, opts); - }); + }), pid); }; Commands.prototype.sendStdin = function (pid, data, opts) { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index cbe8cbd..4684f68 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -657,7 +657,7 @@ describe('test sandbox module', function () { res.setHeader('Content-Type', 'application/connect+json'); res.end(Buffer.concat([ encodeConnectEnvelope({ event: { start: { pid: 101 } } }), - encodeConnectEnvelope({ event: { data: { stdout: '## main\\n M a.txt\\n?? b.txt\\n' } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('## main\n M a.txt\n?? b.txt\n').toString('base64') } } }), encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) ])); return; @@ -1450,6 +1450,57 @@ describe('test sandbox module', function () { }); }); + it('returns command background handles before the process stream ends', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 88 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_live_cmd', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const seen = []; + + return sandbox.commands.run('sleep 5', { + background: true, + onStdout: data => seen.push(data) + }).then(handle => { + handle.pid.should.eql(88); + should.not.exist(handle.result); + commandResponse.write(encodeConnectEnvelope({ + event: { + data: { + stdout: Buffer.from('ready').toString('base64') + } + } + })); + commandResponse.end(encodeConnectEnvelope({ event: { end: { exitCode: 0 } } })); + return handle.wait(); + }).then(result => { + result.stdout.should.eql('ready'); + seen.should.eql(['ready']); + }).then(() => closeServer(fixture.server), err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ @@ -1710,7 +1761,7 @@ describe('test sandbox module', function () { res.end(JSON.stringify({ events: [ { event: { start: { pid: 22 } } }, - { event: { data: { stdout: 'ok' } } }, + { event: { data: { stdout: Buffer.from('ok').toString('base64') } } }, { event: { end: { exitCode: 0 } } } ] })); @@ -1763,7 +1814,7 @@ describe('test sandbox module', function () { if (calls === 1) { res.end(JSON.stringify([ { event: { start: { pid: 31 } } }, - { event: { data: { stdout: 'array' } } }, + { event: { data: { stdout: Buffer.from('array').toString('base64') } } }, { event: { end: { exitCode: 0 } } } ])); return; @@ -2222,7 +2273,7 @@ describe('test sandbox module', function () { res.setHeader('Content-Type', 'application/connect+json'); res.end(Buffer.concat([ encodeConnectEnvelope({ event: { start: { pid: 55 } } }), - encodeConnectEnvelope({ event: { data: { stdout: 'connected' } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('connected').toString('base64') } } }), encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) ])); return; From 965f1b794c50757b6f0d0bcd413b428c032b32f3 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 18:49:53 +0800 Subject: [PATCH 08/48] fix(sandbox): address review hardening Add live command start timeout handling, prevent early wait promise rejection leaks, validate git reset modes, clean clone credentials for default destinations, retry transient poll errors, and avoid probing long Dockerfile text as a path. --- qiniu/sandbox/commands.js | 19 ++++++ qiniu/sandbox/git.js | 16 ++++- qiniu/sandbox/pty.js | 1 + qiniu/sandbox/template.js | 7 ++- qiniu/sandbox/util.js | 5 ++ test/sandbox.test.js | 128 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 4 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index b9dc995..b1bc5eb 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -171,8 +171,18 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { resolveWait = resolve; rejectWait = reject; }); + waitPromise.catch(() => {}); + let startTimer; + + function cleanupStartTimer () { + if (startTimer) { + clearTimeout(startTimer); + startTimer = null; + } + } function fail (err) { + cleanupStartTimer(); if (!settled) { settled = true; reject(err); @@ -182,6 +192,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { function ensureHandle (pid) { if (!handle) { + cleanupStartTimer(); result.pid = pid || result.pid; handle = new CommandHandle(commands, result.pid, null, opts, req, waitPromise); settled = true; @@ -266,6 +277,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { }); res.on('end', () => { if (!isConnectStream) { + cleanupStartTimer(); const events = eventListFromResponse(parseJSON(jsonBuffer)); result = commandResultFromEvents(events, opts); if (!result.pid && fallbackPid) { @@ -287,6 +299,13 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { }); }); req.on('error', fail); + const startTimeout = opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; + if (startTimeout) { + startTimer = setTimeout(() => { + fail(new Error('Command stream start timed out')); + req.destroy(); + }, startTimeout); + } req.end(encodeConnectEnvelope(body)); }); } diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 460df54..3068b82 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -1,5 +1,5 @@ const { shellQuote } = require('./util'); -const { GitAuthError, GitUpstreamError } = require('./errors'); +const { GitAuthError, GitUpstreamError, InvalidArgumentError } = require('./errors'); function Git (commands) { this.commands = commands; @@ -21,6 +21,12 @@ function pathAndOptions (pathOrOpts, maybeOpts) { return { path: opts.path, opts }; } +function cloneDirectoryName (repoUrl) { + const withoutQuery = String(repoUrl || '').split(/[?#]/)[0].replace(/\/+$/, ''); + const name = withoutQuery.slice(withoutQuery.lastIndexOf('/') + 1).replace(/\.git$/, ''); + return name || null; +} + function authUrl (repoUrl, opts) { opts = opts || {}; if (!opts.username && !opts.password) { @@ -126,8 +132,9 @@ Git.prototype.clone = function (repoUrl, pathOrOpts, maybeOpts) { args.push(shellQuote(normalized.path)); } return this.commands.run(`git ${args.join(' ')}`, opts).then(result => { - if ((opts.username || opts.password) && !opts.dangerouslyStoreCredentials && normalized.path) { - return this._runGit(normalized.path, ['remote', 'set-url', 'origin', shellQuote(stripAuth(cloneUrl))], opts) + const clonePath = normalized.path || cloneDirectoryName(repoUrl); + if ((opts.username || opts.password) && !opts.dangerouslyStoreCredentials && clonePath) { + return this._runGit(clonePath, ['remote', 'set-url', 'origin', shellQuote(stripAuth(cloneUrl))], opts) .then(() => result); } return result; @@ -278,6 +285,9 @@ Git.prototype.reset = function (repoPath, opts) { const args = ['reset']; const mode = opts.mode || (opts.hard ? 'hard' : null) || (opts.soft ? 'soft' : null) || (opts.mixed ? 'mixed' : null); if (mode) { + if (['soft', 'mixed', 'hard', 'merge', 'keep'].indexOf(mode) === -1) { + throw new InvalidArgumentError(`Invalid git reset mode: ${mode}`); + } args.push(`--${mode}`); } const target = opts.target || opts.ref; diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 6f5ecae..a41a020 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -89,6 +89,7 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { resolveWait = resolve; rejectWait = reject; }); + waitPromise.catch(() => {}); function fail (err) { if (!settled) { diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 4c98306..cb1bc9a 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -119,7 +119,12 @@ function parseEnvArgs (value) { } Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { - const content = fs.existsSync(dockerfileContentOrPath) + const isPath = typeof dockerfileContentOrPath === 'string' && + dockerfileContentOrPath.length < 1024 && + dockerfileContentOrPath.indexOf('\n') < 0 && + dockerfileContentOrPath.indexOf('\r') < 0 && + fs.existsSync(dockerfileContentOrPath); + const content = isPath ? fs.readFileSync(dockerfileContentOrPath, 'utf8') : dockerfileContentOrPath; content.split(/\r?\n/).forEach(line => { diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 9e95a51..060b743 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -64,6 +64,11 @@ function poll (fn, opts, done) { throw new SandboxError('Sandbox poll timed out'); } return new Promise(resolve => setTimeout(resolve, interval)).then(tick); + }, err => { + if (Date.now() - startedAt >= timeout) { + throw err; + } + return new Promise(resolve => setTimeout(resolve, interval)).then(tick); }); } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 4684f68..819fd18 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1181,6 +1181,13 @@ describe('test sandbox module', function () { }); }); + it('treats long Dockerfile text as content instead of probing it as a path', function () { + const content = 'FROM node:22\nRUN ' + new Array(1200).join('x'); + const template = qiniu.sandbox.Template().fromDockerfile(content); + template.buildConfig.fromImage.should.eql('node:22'); + template.buildConfig.steps[0].cmd.should.match(/^x+$/); + }); + it('exposes network constants and maps updateNetwork to Qiniu API', function () { return startServer((req, res) => { res.statusCode = 200; @@ -1501,6 +1508,46 @@ describe('test sandbox module', function () { }); }); + it('rejects command start when the process stream does not start before timeout', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_timeout', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 5', { + requestTimeoutMs: 5 + }).then(() => { + throw new Error('expected command start to time out'); + }, err => { + err.message.should.eql('Command stream start timed out'); + if (commandResponse) { + commandResponse.end(); + } + }).then(() => closeServer(fixture.server), err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ @@ -1588,6 +1635,45 @@ describe('test sandbox module', function () { }); }); + it('cleans git clone credentials when using the default destination directory', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://github.com/acme/private.git', { + username: 'u', + password: 'p' + }).then(() => { + commandsSeen.map(item => item.cmd).should.eql([ + 'git clone \'https://u:p@github.com/acme/private.git\'', + 'git remote set-url origin \'https://github.com/acme/private.git\'' + ]); + commandsSeen[1].opts.cwd.should.eql('private'); + }); + }); + + it('rejects unsafe git reset modes', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + try { + git.reset('/repo', { + mode: 'hard; touch /tmp/pwned' + }); + throw new Error('expected git reset to reject unsafe mode'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/Invalid git reset mode/); + } + }); + it('surfaces git upstream and validation errors on auth helpers', function () { const git = new qiniu.sandbox.Git({ run: function () { @@ -1727,6 +1813,48 @@ describe('test sandbox module', function () { }); }); + it('retries transient waitForReady polling errors before timeout', function () { + let infoCalls = 0; + return startServer((req, res) => { + if (req.method === 'GET' && req.url === '/sandboxes/sbx_retry') { + infoCalls += 1; + if (infoCalls === 1) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'temporary' })); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_retry', state: 'running' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_retry', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ + interval: 1, + timeout: 50 + }).then(info => { + info.state.should.eql('running'); + infoCalls.should.eql(2); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rethrows non-502 envd health errors', function () { return startServer((req, res) => { res.statusCode = 500; From 3ac6673feefba6bf4a36fa06518242fae286658b Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 18:55:26 +0800 Subject: [PATCH 09/48] fix(sandbox): tighten review edge cases Apply PTY start timeouts, optimize command JSON fallback buffering, support http git auth cleanup, parse escaped Dockerfile ENV values, and prefer native URL parsing with compatibility fallback. --- qiniu/sandbox/commands.js | 6 ++-- qiniu/sandbox/git.js | 4 +-- qiniu/sandbox/pty.js | 27 +++++++++++++-- qiniu/sandbox/template.js | 7 ++-- qiniu/sandbox/util.js | 15 +++++++++ test/sandbox.test.js | 71 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 9 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index b1bc5eb..a8c06b9 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -156,7 +156,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { let settled = false; let handle; let responseBuffer = Buffer.alloc(0); - let jsonBuffer = Buffer.alloc(0); + const jsonChunks = []; let isConnectStream = true; let result = { pid: fallbackPid || 0, @@ -258,7 +258,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { isConnectStream = contentType.indexOf('application/connect+json') >= 0; res.on('data', chunk => { if (!isConnectStream) { - jsonBuffer = Buffer.concat([jsonBuffer, chunk]); + jsonChunks.push(chunk); return; } responseBuffer = Buffer.concat([responseBuffer, chunk]); @@ -278,7 +278,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { res.on('end', () => { if (!isConnectStream) { cleanupStartTimer(); - const events = eventListFromResponse(parseJSON(jsonBuffer)); + const events = eventListFromResponse(parseJSON(Buffer.concat(jsonChunks))); result = commandResultFromEvents(events, opts); if (!result.pid && fallbackPid) { result.pid = fallbackPid; diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 3068b82..0c6063c 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -35,11 +35,11 @@ function authUrl (repoUrl, opts) { if (!opts.username || !opts.password) { throw new GitAuthError('Both username and password are required for git authentication'); } - return repoUrl.replace(/^https:\/\//, `https://${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@`); + return repoUrl.replace(/^(https?):\/\//, `$1://${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@`); } function stripAuth (repoUrl) { - return String(repoUrl || '').replace(/^https:\/\/[^/@]+:[^/@]+@/, 'https://'); + return String(repoUrl || '').replace(/^(https?):\/\/[^/@]+:[^/@]+@/, '$1://'); } function configScopeArg (opts) { diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index a41a020..0fd88ff 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -90,8 +90,17 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { rejectWait = reject; }); waitPromise.catch(() => {}); + let startTimer; + + function cleanupStartTimer () { + if (startTimer) { + clearTimeout(startTimer); + startTimer = null; + } + } function fail (err) { + cleanupStartTimer(); if (!settled) { settled = true; reject(err); @@ -102,6 +111,7 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { function handleMessage (message) { const event = eventPayload(message); if (event.start && !handle) { + cleanupStartTimer(); handle = new LivePtyHandle(pty, event.start.pid, req, waitPromise); settled = true; resolve(handle); @@ -167,6 +177,13 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { }); }); req.on('error', fail); + const startTimeout = opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; + if (startTimeout) { + startTimer = setTimeout(() => { + fail(new Error('PTY stream start timed out')); + req.destroy(); + }, startTimeout); + } req.end(encodeConnectEnvelope(body)); }); } @@ -214,7 +231,10 @@ Pty.prototype.create = function (opts) { return connectLivePty(this.sandbox, '/process.Process/Start', body, { user: opts.user, - onData: opts.onData + onData: opts.onData, + requestTimeoutMs: opts.requestTimeoutMs, + timeoutMs: opts.timeoutMs, + timeout: opts.timeout }, this); }; @@ -226,7 +246,10 @@ Pty.prototype.connect = function (pid, opts) { } }, { user: opts.user, - onData: opts.onData + onData: opts.onData, + requestTimeoutMs: opts.requestTimeoutMs, + timeoutMs: opts.timeoutMs, + timeout: opts.timeout }, this); }; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index cb1bc9a..a6b3b4b 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -103,7 +103,7 @@ function runShellStep (template, command, options) { function parseEnvArgs (value) { const args = []; - const pattern = /([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)/g; + const pattern = /([A-Za-z_][A-Za-z0-9_]*)=("(\\.|[^"\\])*"|'(\\.|[^'\\])*'|\S+)/g; let match; while ((match = pattern.exec(value))) { let envValue = match[2]; @@ -111,7 +111,10 @@ function parseEnvArgs (value) { (envValue[0] === '"' && envValue[envValue.length - 1] === '"') || (envValue[0] === '\'' && envValue[envValue.length - 1] === '\'') ) { - envValue = envValue.slice(1, -1); + envValue = envValue.slice(1, -1) + .replace(/\\"/g, '"') + .replace(/\\'/g, '\'') + .replace(/\\\\/g, '\\'); } args.push(match[1], envValue); } diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 060b743..14be5a7 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -107,6 +107,21 @@ function rawRequest (requestUrl, options) { } function parseRequestUrl (requestUrl) { + const URLParser = (typeof URL !== 'undefined' && URL) || null; + if (URLParser) { + try { + const parsed = new URLParser(requestUrl); + return { + protocol: parsed.protocol, + hostname: parsed.hostname.replace(/^\[|\]$/g, ''), + port: parsed.port, + path: parsed.pathname + parsed.search + }; + } catch (err) { + throw new SandboxError(`Invalid request URL: ${requestUrl}`); + } + } + const match = String(requestUrl).match(/^(https?:)\/\/([^/?#]+)([^?#]*)(\?[^#]*)?/); if (!match) { throw new SandboxError(`Invalid request URL: ${requestUrl}`); diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 819fd18..271ae43 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1188,6 +1188,14 @@ describe('test sandbox module', function () { template.buildConfig.steps[0].cmd.should.match(/^x+$/); }); + it('parses escaped quotes in Dockerfile ENV values', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nENV FOO="bar\\"baz" QUOTED=\'it\\\'s ok\''); + template.buildConfig.steps.should.eql([ + { type: 'ENV', args: ['FOO', 'bar"baz', 'QUOTED', 'it\'s ok'] } + ]); + }); + it('exposes network constants and maps updateNetwork to Qiniu API', function () { return startServer((req, res) => { res.statusCode = 200; @@ -1656,6 +1664,27 @@ describe('test sandbox module', function () { }); }); + it('cleans http git clone credentials when using the default destination directory', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('http://git.example.com/acme/private.git', { + username: 'u', + password: 'p' + }).then(() => { + commandsSeen.map(item => item.cmd).should.eql([ + 'git clone \'http://u:p@git.example.com/acme/private.git\'', + 'git remote set-url origin \'http://git.example.com/acme/private.git\'' + ]); + commandsSeen[1].opts.cwd.should.eql('private'); + }); + }); + it('rejects unsafe git reset modes', function () { const git = new qiniu.sandbox.Git({ run: function () { @@ -2247,6 +2276,48 @@ describe('test sandbox module', function () { }); }); + it('rejects live PTY start when the process stream does not start before timeout', function () { + let ptyResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + ptyResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_timeout', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24, + requestTimeoutMs: 5 + }).then(() => { + throw new Error('expected pty start to time out'); + }, err => { + err.message.should.eql('PTY stream start timed out'); + if (ptyResponse) { + ptyResponse.end(); + } + }).then(() => closeServer(fixture.server), err => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports filesystem gzip and octet-stream write compatibility options', function () { return startServer((req, res) => { const parsed = parseUrl(req.url); From a175f811d0ef9431a456c69f9856107ca8e12454 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 19:03:08 +0800 Subject: [PATCH 10/48] fix(sandbox): handle review edge cases --- qiniu/sandbox/client.js | 16 ++- qiniu/sandbox/commands.js | 23 +++- qiniu/sandbox/filesystem.js | 10 +- qiniu/sandbox/pty.js | 20 +++- qiniu/sandbox/template.js | 20 +++- qiniu/sandbox/util.js | 4 +- test/sandbox.test.js | 218 +++++++++++++++++++++++++++++++++++- 7 files changed, 291 insertions(+), 20 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index edbd0ff..f645a7f 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -5,7 +5,7 @@ const { HttpClient } = require('../httpc/client'); const { QiniuAuthMiddleware } = require('../httpc/middleware/qiniuAuth'); const digest = require('../auth/digest'); const { DEFAULT_TEMPLATE } = require('./constants'); -const { SandboxError } = require('./errors'); +const { SandboxError, TemplateBuildError } = require('./errors'); const { appendQuery, copyDefined, @@ -114,10 +114,10 @@ function SandboxClient (opts) { this.apiKey = normalized.apiKey; this.accessToken = normalized.accessToken; this.mac = normalized.mac; + this.timeout = normalized.timeout; this.httpClient = new HttpClient({ httpAgent: normalized.httpAgent, - httpsAgent: normalized.httpsAgent, - timeout: normalized.timeout + httpsAgent: normalized.httpsAgent }); } @@ -166,6 +166,9 @@ SandboxClient.prototype._request = function (method, path, options) { gzip: true, followRedirect: true }; + if (this.timeout !== undefined) { + urllibOptions.timeout = this.timeout; + } if (hasBody) { urllibOptions.content = JSON.stringify(body); @@ -215,7 +218,7 @@ SandboxClient.prototype.createSandbox = function (opts) { }; SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { - const ids = Array.isArray(sandboxIDs) ? sandboxIDs : sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs; + const ids = Array.isArray(sandboxIDs) ? sandboxIDs : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs)) || []; return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); }; @@ -402,6 +405,11 @@ SandboxClient.prototype.createAndWait = function (opts, pollOpts) { SandboxClient.prototype.waitForBuild = function (templateID, buildID, opts) { return poll(() => this.getTemplateBuildStatus(templateID, buildID), opts, info => { return info && (info.status === 'ready' || info.status === 'error'); + }).then(info => { + if (info && info.status === 'error') { + throw new TemplateBuildError(info.error || info.message || 'Sandbox template build failed', { info }); + } + return info; }); }; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index a8c06b9..89367ba 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -124,7 +124,12 @@ CommandHandle.prototype.wait = function () { }; CommandHandle.prototype.kill = function () { - return this.commands.kill(this.pid); + return this.commands.kill(this.pid, { + user: this.opts.user, + requestTimeoutMs: this.opts.requestTimeoutMs, + timeoutMs: this.opts.timeoutMs, + timeout: this.opts.timeout + }); }; CommandHandle.prototype.disconnect = function () { @@ -271,14 +276,26 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { const payload = responseBuffer.slice(5, 5 + length).toString(); responseBuffer = responseBuffer.slice(5 + length); if (!(flags & 2) && payload) { - handleMessage(JSON.parse(payload)); + try { + handleMessage(JSON.parse(payload)); + } catch (err) { + fail(err); + req.destroy(); + return; + } } } }); res.on('end', () => { if (!isConnectStream) { cleanupStartTimer(); - const events = eventListFromResponse(parseJSON(Buffer.concat(jsonChunks))); + let events; + try { + events = eventListFromResponse(parseJSON(Buffer.concat(jsonChunks))); + } catch (err) { + fail(err); + return; + } result = commandResultFromEvents(events, opts); if (!result.pid && fallbackPid) { result.pid = fallbackPid; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index cf3a4e1..0178635 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -133,7 +133,7 @@ function multipartBody (boundary, parts) { chunks.push(Buffer.from(`--${boundary}\r\n`)); chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.field}"; filename="${part.filename}"\r\n`)); chunks.push(Buffer.from('Content-Type: application/octet-stream\r\n\r\n')); - chunks.push(Buffer.isBuffer(part.data) ? part.data : Buffer.from(String(part.data))); + chunks.push(Buffer.isBuffer(part.data) ? part.data : Buffer.from(String(part.data || ''))); chunks.push(Buffer.from('\r\n')); }); chunks.push(Buffer.from(`--${boundary}--\r\n`)); @@ -394,7 +394,13 @@ function watchDir (sandbox, path, onEvent, opts) { const payload = responseBuffer.slice(5, 5 + length).toString(); responseBuffer = responseBuffer.slice(5 + length); if (!(flags & 2) && payload) { - handleMessage(JSON.parse(payload)); + try { + handleMessage(JSON.parse(payload)); + } catch (err) { + fail(err); + req.destroy(); + return; + } } } }); diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 0fd88ff..692e675 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -33,11 +33,12 @@ function dataToBuffer (value) { return Buffer.from(String(value || '')); } -function LivePtyHandle (pty, pid, request, waitPromise) { +function LivePtyHandle (pty, pid, request, waitPromise, opts) { this.pty = pty; this.pid = pid; this._request = request; this._waitPromise = waitPromise; + this.opts = opts || {}; this.stdout = ''; this.stderr = ''; } @@ -47,7 +48,12 @@ LivePtyHandle.prototype.wait = function () { }; LivePtyHandle.prototype.kill = function () { - return this.pty.kill(this.pid); + return this.pty.kill(this.pid, { + user: this.opts.user, + requestTimeoutMs: this.opts.requestTimeoutMs, + timeoutMs: this.opts.timeoutMs, + timeout: this.opts.timeout + }); }; LivePtyHandle.prototype.disconnect = function () { @@ -112,7 +118,7 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { const event = eventPayload(message); if (event.start && !handle) { cleanupStartTimer(); - handle = new LivePtyHandle(pty, event.start.pid, req, waitPromise); + handle = new LivePtyHandle(pty, event.start.pid, req, waitPromise, opts); settled = true; resolve(handle); } @@ -161,7 +167,13 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { const payload = responseBuffer.slice(5, 5 + length).toString(); responseBuffer = responseBuffer.slice(5 + length); if (!(flags & 2) && payload) { - handleMessage(JSON.parse(payload)); + try { + handleMessage(JSON.parse(payload)); + } catch (err) { + fail(err); + req.destroy(); + return; + } } } }); diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index a6b3b4b..e7673fa 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -121,6 +121,24 @@ function parseEnvArgs (value) { return args; } +function joinDockerfileLines (content) { + const lines = []; + let current = ''; + String(content || '').split(/\r?\n/).forEach(rawLine => { + const line = rawLine.trim(); + if (line.charAt(line.length - 1) === '\\') { + current += line.slice(0, -1) + ' '; + return; + } + lines.push(current + line); + current = ''; + }); + if (current) { + lines.push(current); + } + return lines; +} + Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { const isPath = typeof dockerfileContentOrPath === 'string' && dockerfileContentOrPath.length < 1024 && @@ -130,7 +148,7 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { const content = isPath ? fs.readFileSync(dockerfileContentOrPath, 'utf8') : dockerfileContentOrPath; - content.split(/\r?\n/).forEach(line => { + joinDockerfileLines(content).forEach(line => { line = line.trim(); if (!line || line[0] === '#') { return; diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 14be5a7..9c682f0 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -2,7 +2,7 @@ const crypto = require('crypto'); const urllib = require('urllib'); const { DEFAULT_ENDPOINT, DEFAULT_USER } = require('./constants'); -const { SandboxError } = require('./errors'); +const { SandboxError, TimeoutError } = require('./errors'); function normalizeEndpoint (endpoint) { endpoint = endpoint || process.env.QINIU_SANDBOX_ENDPOINT || DEFAULT_ENDPOINT; @@ -61,7 +61,7 @@ function poll (fn, opts, done) { return value; } if (Date.now() - startedAt >= timeout) { - throw new SandboxError('Sandbox poll timed out'); + throw new TimeoutError('Sandbox poll timed out'); } return new Promise(resolve => setTimeout(resolve, interval)).then(tick); }, err => { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 271ae43..f5f9979 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -57,6 +57,14 @@ function encodeConnectEnvelope (message) { return Buffer.concat([header, payload]); } +function encodeRawConnectEnvelope (payload) { + payload = Buffer.from(payload); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + describe('test sandbox module', function () { it('creates sandbox with E2B compatible options and API key auth', function () { return startServer((req, res) => { @@ -121,6 +129,32 @@ describe('test sandbox module', function () { }); }); + it('passes client timeout to urllib requests and handles empty metrics ids', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + timeout: 1234 + }); + const urls = []; + client.httpClient.sendRequest = req => { + urls.push(req.url); + req.urllibOptions.timeout.should.eql(1234); + return Promise.resolve({ + ok: () => true, + data: { ok: true } + }); + }; + + return client.listSandboxes() + .then(() => client.getSandboxesMetrics()) + .then(() => { + urls.should.eql([ + 'http://sandbox.test/sandboxes', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=' + ]); + }); + }); + it('keeps Qiniu sandbox extensions in create body', function () { return startServer((req, res) => { res.statusCode = 201; @@ -644,6 +678,48 @@ describe('test sandbox module', function () { }); }); + it('rejects malformed filesystem watch stream payloads', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeRawConnectEnvelope('not-json') + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + let handle; + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + err.message.should.match(/Unexpected token/); + } + }).then(ret => { + handle = ret; + return new Promise(resolve => setTimeout(resolve, 20)); + }).then(() => { + handle._stopped.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('runs commands and git operations through process RPC', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -1196,6 +1272,15 @@ describe('test sandbox module', function () { ]); }); + it('joins Dockerfile lines continued with backslash before parsing', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM ubuntu:22.04\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, + { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] } + ]); + }); + it('exposes network constants and maps updateNetwork to Qiniu API', function () { return startServer((req, res) => { res.statusCode = 200; @@ -1475,6 +1560,12 @@ describe('test sandbox module', function () { res.write(encodeConnectEnvelope({ event: { start: { pid: 88 } } })); return; } + if (req.url === '/process.Process/SendSignal') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('{}'); + return; + } res.statusCode = 404; res.end(); }).then(fixture => { @@ -1489,10 +1580,15 @@ describe('test sandbox module', function () { return sandbox.commands.run('sleep 5', { background: true, + user: 'root', + requestTimeoutMs: 1000, onStdout: data => seen.push(data) }).then(handle => { handle.pid.should.eql(88); should.not.exist(handle.result); + return handle.kill().then(() => handle); + }).then(handle => { + fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); commandResponse.write(encodeConnectEnvelope({ event: { data: { @@ -1516,6 +1612,50 @@ describe('test sandbox module', function () { }); }); + it('rejects malformed command stream and JSON fallback payloads', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + if (req.url === '/process.Process/Start' && calls === 1) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeRawConnectEnvelope('not-json')); + return; + } + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('not-json'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(() => { + throw new Error('expected command stream parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + return sandbox.commands.start('echo bad'); + }).then(() => { + throw new Error('expected command JSON fallback parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects command start when the process stream does not start before timeout', function () { let commandResponse; return startServer((req, res) => { @@ -1951,6 +2091,7 @@ describe('test sandbox module', function () { .then(() => { throw new Error('expected waitForReady timeout'); }, err => { + err.name.should.eql('TimeoutError'); err.message.should.eql('Sandbox poll timed out'); }) .then(() => closeServer(fixture.server), err => { @@ -1961,6 +2102,35 @@ describe('test sandbox module', function () { }); }); + it('throws TemplateBuildError when a template build finishes with error status', function () { + return startServer((req, res) => { + if (req.url === '/templates/tpl_1/builds/bld_1/status') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ status: 'error', error: 'compile failed' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + + return client.waitForBuild('tpl_1', 'bld_1', { interval: 1, timeout: 20 }).then(() => { + throw new Error('expected template build error'); + }, err => { + err.name.should.eql('TemplateBuildError'); + err.message.should.eql('compile failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports process stream JSON array and single event fallback responses', function () { let calls = 0; return startServer((req, res) => { @@ -2249,10 +2419,16 @@ describe('test sandbox module', function () { return sandbox.pty.create({ cols: 80, rows: 24, + user: 'root', + requestTimeoutMs: 1000, onData: chunk => data.push(Buffer.from(chunk).toString()) }).then(handle => { handle.pid.should.eql(44); - return handle.wait(); + return handle.kill().then(killed => { + killed.should.eql(true); + fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); + return handle.wait(); + }); }).then(() => sandbox.pty.connect(44, { onData: chunk => data.push(Buffer.from(chunk).toString()) })).then(handle => handle.wait()) @@ -2262,11 +2438,11 @@ describe('test sandbox module', function () { .then(killed => { killed.should.eql(true); data.should.eql(['ok', 'ok']); - const sendBody = JSON.parse(fixture.requests[2].body); + const sendBody = JSON.parse(fixture.requests[3].body); sendBody.input.pty.should.eql(Buffer.from('ls\n').toString('base64')); - const resizeBody = JSON.parse(fixture.requests[3].body); + const resizeBody = JSON.parse(fixture.requests[4].body); resizeBody.pty.size.should.eql({ cols: 100, rows: 30 }); - const killBody = JSON.parse(fixture.requests[4].body); + const killBody = JSON.parse(fixture.requests[5].body); killBody.signal.should.eql('SIGNAL_SIGKILL'); }).then(() => closeServer(fixture.server), err => { return closeServer(fixture.server).then(() => { @@ -2276,6 +2452,40 @@ describe('test sandbox module', function () { }); }); + it('rejects malformed live PTY stream payloads', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeRawConnectEnvelope('not-json')); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(() => { + throw new Error('expected pty stream parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects live PTY start when the process stream does not start before timeout', function () { let ptyResponse; return startServer((req, res) => { From 813e680b828be07369e5313ebceafd45015e1271 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 19:15:03 +0800 Subject: [PATCH 11/48] fix(sandbox): handle stream and auth review --- qiniu/sandbox/commands.js | 18 ++- qiniu/sandbox/envd.js | 20 ++++ qiniu/sandbox/filesystem.js | 21 +++- qiniu/sandbox/git.js | 50 ++++++--- qiniu/sandbox/pty.js | 19 +++- qiniu/sandbox/sandbox.js | 10 +- qiniu/sandbox/template.js | 32 ++++-- qiniu/sandbox/util.js | 4 +- test/sandbox.test.js | 215 ++++++++++++++++++++++++++++++++---- 9 files changed, 335 insertions(+), 54 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 89367ba..f1d7d47 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -1,4 +1,4 @@ -const { connectRPC, envdHeaders } = require('./envd'); +const { connectEndStreamError, connectRPC, envdHeaders } = require('./envd'); const { CommandExitError } = require('./errors'); const { parseJSON, parseRequestUrl } = require('./util'); const http = require('http'); @@ -275,6 +275,21 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { } const payload = responseBuffer.slice(5, 5 + length).toString(); responseBuffer = responseBuffer.slice(5 + length); + if (flags & 2) { + try { + const err = connectEndStreamError(payload); + if (err) { + fail(err); + req.destroy(); + return; + } + } catch (err) { + fail(err); + req.destroy(); + return; + } + continue; + } if (!(flags & 2) && payload) { try { handleMessage(JSON.parse(payload)); @@ -286,6 +301,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { } } }); + res.on('error', fail); res.on('end', () => { if (!isConnectStream) { cleanupStartTimer(); diff --git a/qiniu/sandbox/envd.js b/qiniu/sandbox/envd.js index 771209d..f74d3b9 100644 --- a/qiniu/sandbox/envd.js +++ b/qiniu/sandbox/envd.js @@ -43,6 +43,21 @@ function encodeConnectEnvelope (message) { return Buffer.concat([header, payload]); } +function connectEndStreamError (payload) { + if (!payload) { + return null; + } + const data = JSON.parse(String(payload)); + if (!data || !data.error) { + return null; + } + const error = data.error; + const err = new Error(error.message || 'Sandbox envd stream failed'); + err.code = error.code; + err.details = error.details; + return err; +} + function decodeConnectEnvelopes (data) { data = Buffer.isBuffer(data) ? data : Buffer.from(data || ''); const messages = []; @@ -57,6 +72,10 @@ function decodeConnectEnvelopes (data) { const payload = data.slice(offset, offset + length).toString(); offset += length; if (flags & 2) { + const err = connectEndStreamError(payload); + if (err) { + throw err; + } continue; } if (payload) { @@ -105,4 +124,5 @@ function connectStreamRPC (sandbox, procedure, body, opts) { exports.connectRPC = connectRPC; exports.connectStreamRPC = connectStreamRPC; +exports.connectEndStreamError = connectEndStreamError; exports.envdHeaders = envdHeaders; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 0178635..69634ad 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -1,4 +1,4 @@ -const { connectRPC, envdHeaders } = require('./envd'); +const { connectEndStreamError, connectRPC, envdHeaders } = require('./envd'); const { SandboxError } = require('./errors'); const { parseRequestUrl, rawRequest } = require('./util'); const { Readable } = require('stream'); @@ -173,7 +173,8 @@ Filesystem.prototype.read = function (path, opts) { return rawRequest(this.sandbox.downloadUrl(path, opts), { method: 'GET', dataType: 'buffer', - headers + headers, + gzip: !!opts.gzip }).then(({ data }) => formatReadResult(data, opts)); }; @@ -393,6 +394,21 @@ function watchDir (sandbox, path, onEvent, opts) { } const payload = responseBuffer.slice(5, 5 + length).toString(); responseBuffer = responseBuffer.slice(5 + length); + if (flags & 2) { + try { + const err = connectEndStreamError(payload); + if (err) { + fail(err); + req.destroy(); + return; + } + } catch (err) { + fail(err); + req.destroy(); + return; + } + continue; + } if (!(flags & 2) && payload) { try { handleMessage(JSON.parse(payload)); @@ -404,6 +420,7 @@ function watchDir (sandbox, path, onEvent, opts) { } } }); + res.on('error', fail); res.on('end', () => { cleanupStartTimer(); if (!settled) { diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 0c6063c..75ba65f 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -21,12 +21,6 @@ function pathAndOptions (pathOrOpts, maybeOpts) { return { path: opts.path, opts }; } -function cloneDirectoryName (repoUrl) { - const withoutQuery = String(repoUrl || '').split(/[?#]/)[0].replace(/\/+$/, ''); - const name = withoutQuery.slice(withoutQuery.lastIndexOf('/') + 1).replace(/\.git$/, ''); - return name || null; -} - function authUrl (repoUrl, opts) { opts = opts || {}; if (!opts.username && !opts.password) { @@ -42,6 +36,36 @@ function stripAuth (repoUrl) { return String(repoUrl || '').replace(/^(https?):\/\/[^/@]+:[^/@]+@/, '$1://'); } +function credentialHelperArgs (opts) { + opts = opts || {}; + if (!opts.username && !opts.password) { + return []; + } + if (!opts.username || !opts.password) { + throw new GitAuthError('Both username and password are required for git authentication'); + } + return [ + '-c', + shellQuote('credential.helper=!f() { echo username=$GIT_USERNAME; echo password=$GIT_PASSWORD; }; f'), + '-c', + shellQuote('credential.useHttpPath=true') + ]; +} + +function gitCredentialOptions (opts) { + opts = Object.assign({}, opts || {}); + if (opts.username || opts.password) { + if (!opts.username || !opts.password) { + throw new GitAuthError('Both username and password are required for git authentication'); + } + opts.envs = Object.assign({}, opts.envs || {}, { + GIT_USERNAME: opts.username, + GIT_PASSWORD: opts.password + }); + } + return opts; +} + function configScopeArg (opts) { opts = opts || {}; if (opts.scope === 'global') { @@ -119,9 +143,8 @@ Git.prototype._runGit = function (repoPath, args, opts) { Git.prototype.clone = function (repoUrl, pathOrOpts, maybeOpts) { const normalized = pathAndOptions(pathOrOpts, maybeOpts); - const opts = normalized.opts; - const cloneUrl = authUrl(repoUrl, opts); - const args = gitConfigArgs(opts).concat(['clone', shellQuote(cloneUrl)]); + const opts = gitCredentialOptions(normalized.opts); + const args = gitConfigArgs(opts).concat(credentialHelperArgs(opts)).concat(['clone', shellQuote(stripAuth(repoUrl))]); if (opts.depth) { args.push('--depth', shellQuote(opts.depth)); } @@ -131,14 +154,7 @@ Git.prototype.clone = function (repoUrl, pathOrOpts, maybeOpts) { if (normalized.path) { args.push(shellQuote(normalized.path)); } - return this.commands.run(`git ${args.join(' ')}`, opts).then(result => { - const clonePath = normalized.path || cloneDirectoryName(repoUrl); - if ((opts.username || opts.password) && !opts.dangerouslyStoreCredentials && clonePath) { - return this._runGit(clonePath, ['remote', 'set-url', 'origin', shellQuote(stripAuth(cloneUrl))], opts) - .then(() => result); - } - return result; - }); + return this.commands.run(`git ${args.join(' ')}`, opts); }; Git.prototype.init = function (repoPath, opts) { diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 692e675..9f19915 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -1,5 +1,4 @@ -const { connectRPC } = require('./envd'); -const { envdHeaders } = require('./envd'); +const { connectEndStreamError, connectRPC, envdHeaders } = require('./envd'); const { parseRequestUrl } = require('./util'); const http = require('http'); const https = require('https'); @@ -166,6 +165,21 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { } const payload = responseBuffer.slice(5, 5 + length).toString(); responseBuffer = responseBuffer.slice(5 + length); + if (flags & 2) { + try { + const err = connectEndStreamError(payload); + if (err) { + fail(err); + req.destroy(); + return; + } + } catch (err) { + fail(err); + req.destroy(); + return; + } + continue; + } if (!(flags & 2) && payload) { try { handleMessage(JSON.parse(payload)); @@ -177,6 +191,7 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { } } }); + res.on('error', fail); res.on('end', () => { if (!settled) { fail(new Error('PTY stream ended before process start')); diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index b198187..458288a 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -239,12 +239,20 @@ Sandbox.prototype.getMcpToken = function () { if (this.mcpToken) { return Promise.resolve(this.mcpToken); } - return this.files.read('/etc/mcp-gateway/.token', { + if (this._mcpTokenPromise) { + return this._mcpTokenPromise; + } + this._mcpTokenPromise = this.files.read('/etc/mcp-gateway/.token', { user: 'root' }).then(token => { this.mcpToken = token; + this._mcpTokenPromise = null; return token; + }, err => { + this._mcpTokenPromise = null; + throw err; }); + return this._mcpTokenPromise; }; Sandbox.prototype.waitForReady = function (opts) { diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index e7673fa..cfdc6ef 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -103,24 +103,34 @@ function runShellStep (template, command, options) { function parseEnvArgs (value) { const args = []; + if (value.indexOf('=') < 0) { + const index = value.search(/\s+/); + if (index > 0) { + args.push(value.slice(0, index).trim(), unquoteDockerfileValue(value.slice(index + 1).trim())); + } + return args; + } const pattern = /([A-Za-z_][A-Za-z0-9_]*)=("(\\.|[^"\\])*"|'(\\.|[^'\\])*'|\S+)/g; let match; while ((match = pattern.exec(value))) { - let envValue = match[2]; - if ( - (envValue[0] === '"' && envValue[envValue.length - 1] === '"') || - (envValue[0] === '\'' && envValue[envValue.length - 1] === '\'') - ) { - envValue = envValue.slice(1, -1) - .replace(/\\"/g, '"') - .replace(/\\'/g, '\'') - .replace(/\\\\/g, '\\'); - } - args.push(match[1], envValue); + args.push(match[1], unquoteDockerfileValue(match[2])); } return args; } +function unquoteDockerfileValue (value) { + if ( + (value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === '\'' && value[value.length - 1] === '\'') + ) { + return value.slice(1, -1) + .replace(/\\"/g, '"') + .replace(/\\'/g, '\'') + .replace(/\\\\/g, '\\'); + } + return value; +} + function joinDockerfileLines (content) { const lines = []; let current = ''; diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 9c682f0..56054f8 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -65,7 +65,9 @@ function poll (fn, opts, done) { } return new Promise(resolve => setTimeout(resolve, interval)).then(tick); }, err => { - if (Date.now() - startedAt >= timeout) { + const statusCode = (err.response && err.response.statusCode) || (err.resp && err.resp.statusCode); + const fatalClientError = statusCode >= 400 && statusCode < 500 && statusCode !== 408 && statusCode !== 429; + if (fatalClientError || Date.now() - startedAt >= timeout) { throw err; } return new Promise(resolve => setTimeout(resolve, interval)).then(tick); diff --git a/test/sandbox.test.js b/test/sandbox.test.js index f5f9979..e0deee1 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -57,14 +57,18 @@ function encodeConnectEnvelope (message) { return Buffer.concat([header, payload]); } -function encodeRawConnectEnvelope (payload) { +function encodeRawConnectEnvelope (payload, flags) { payload = Buffer.from(payload); const header = Buffer.alloc(5); - header[0] = 0; + header[0] = flags || 0; header.writeUInt32BE(payload.length, 1); return Buffer.concat([header, payload]); } +function encodeConnectEndEnvelope (message) { + return encodeRawConnectEnvelope(JSON.stringify(message || {}), 2); +} + describe('test sandbox module', function () { it('creates sandbox with E2B compatible options and API key auth', function () { return startServer((req, res) => { @@ -1274,10 +1278,11 @@ describe('test sandbox module', function () { it('joins Dockerfile lines continued with backslash before parsing', function () { const template = qiniu.sandbox.Template() - .fromDockerfile('FROM ubuntu:22.04\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux'); + .fromDockerfile('FROM ubuntu:22.04\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000'); template.buildConfig.steps.should.eql([ { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, - { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] } + { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] }, + { type: 'ENV', args: ['PORT', '3000'] } ]); }); @@ -1656,6 +1661,86 @@ describe('test sandbox module', function () { }); }); + it('rejects command wait when Connect end-stream carries an error', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 91 } } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'stream failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(91); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('stream failed'); + err.code.should.eql('internal'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir on Connect end-stream errors after start', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'watch failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('watch failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects command start when the process stream does not start before timeout', function () { let commandResponse; return startServer((req, res) => { @@ -1734,8 +1819,13 @@ describe('test sandbox module', function () { .then(() => git.setConfig('/repo', 'user.name', 'Alice', { scope: 'global' })) .then(() => { const commandText = commandsSeen.map(item => item.cmd).join('\n'); - commandText.should.containEql('clone \'https://u:p@github.com/acme/repo.git\''); - commandText.should.containEql('remote set-url origin \'https://github.com/acme/repo.git\''); + commandText.should.containEql('credential.helper='); + commandText.should.containEql('clone \'https://github.com/acme/repo.git\''); + commandText.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); commandText.should.containEql('branch --format'); commandText.should.containEql('reset --hard \'HEAD~1\''); commandText.should.containEql('restore --staged -- \'a.txt\''); @@ -1783,7 +1873,7 @@ describe('test sandbox module', function () { }); }); - it('cleans git clone credentials when using the default destination directory', function () { + it('passes git clone credentials through a helper instead of the command line', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ run: function (cmd, opts) { @@ -1796,15 +1886,18 @@ describe('test sandbox module', function () { username: 'u', password: 'p' }).then(() => { - commandsSeen.map(item => item.cmd).should.eql([ - 'git clone \'https://u:p@github.com/acme/private.git\'', - 'git remote set-url origin \'https://github.com/acme/private.git\'' - ]); - commandsSeen[1].opts.cwd.should.eql('private'); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('clone \'https://github.com/acme/private.git\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); }); }); - it('cleans http git clone credentials when using the default destination directory', function () { + it('passes http git clone credentials through a helper instead of the command line', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ run: function (cmd, opts) { @@ -1817,11 +1910,14 @@ describe('test sandbox module', function () { username: 'u', password: 'p' }).then(() => { - commandsSeen.map(item => item.cmd).should.eql([ - 'git clone \'http://u:p@git.example.com/acme/private.git\'', - 'git remote set-url origin \'http://git.example.com/acme/private.git\'' - ]); - commandsSeen[1].opts.cwd.should.eql('private'); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('clone \'http://git.example.com/acme/private.git\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); }); }); @@ -2024,6 +2120,36 @@ describe('test sandbox module', function () { }); }); + it('does not retry fatal client errors while polling', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'missing' })); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_missing', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ interval: 1, timeout: 100 }).then(() => { + throw new Error('expected waitForReady to fail'); + }, err => { + err.response.statusCode.should.eql(404); + calls.should.eql(1); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rethrows non-502 envd health errors', function () { return startServer((req, res) => { res.statusCode = 500; @@ -2301,6 +2427,7 @@ describe('test sandbox module', function () { }); it('supports E2B style sandbox paginator, snapshots, and MCP helpers', function () { + let tokenReads = 0; return startServer((req, res) => { res.setHeader('Content-Type', 'application/json'); if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=2&nextToken=n1&metadata%5Buser%5D=alice&state=running') { @@ -2326,6 +2453,7 @@ describe('test sandbox module', function () { } const parsed = parseUrl(req.url); if (req.method === 'GET' && parsed.pathname === '/files' && parsed.searchParams.get('path') === '/etc/mcp-gateway/.token') { + tokenReads += 1; res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('mcp-token'); @@ -2362,8 +2490,16 @@ describe('test sandbox module', function () { client }); sandbox.getMcpUrl().should.eql('https://50005-sbx_page.page.example.com/mcp'); - return sandbox.getMcpToken().then(token => { + return Promise.all([ + sandbox.getMcpToken(), + sandbox.getMcpToken() + ]).then(tokens => { + tokens.should.eql(['mcp-token', 'mcp-token']); + tokenReads.should.eql(1); + return sandbox.getMcpToken(); + }).then(token => { token.should.eql('mcp-token'); + tokenReads.should.eql(1); return sandbox.createSnapshot({ name: 'snap' }); }).then(snapshot => { snapshot.snapshotId.should.eql('snap_1'); @@ -2486,6 +2622,47 @@ describe('test sandbox module', function () { }); }); + it('rejects PTY wait when Connect end-stream carries an error', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 45 } } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'pty failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(45); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('pty failed'); + err.code.should.eql('internal'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects live PTY start when the process stream does not start before timeout', function () { let ptyResponse; return startServer((req, res) => { From cb1c1e0de9ed7c7d95bce236c842352b9e255aa6 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 19:25:48 +0800 Subject: [PATCH 12/48] fix(sandbox): cap stream frames and credentials --- qiniu/sandbox/client.js | 2 +- qiniu/sandbox/commands.js | 7 +- qiniu/sandbox/envd.js | 6 + qiniu/sandbox/filesystem.js | 10 +- qiniu/sandbox/git.js | 19 +-- qiniu/sandbox/pty.js | 7 +- qiniu/sandbox/sandbox.js | 5 +- qiniu/sandbox/template.js | 48 +++++++- test/sandbox.test.js | 227 ++++++++++++++++++++++++++++-------- 9 files changed, 261 insertions(+), 70 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index f645a7f..dc08bca 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -218,7 +218,7 @@ SandboxClient.prototype.createSandbox = function (opts) { }; SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { - const ids = Array.isArray(sandboxIDs) ? sandboxIDs : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs)) || []; + const ids = Array.isArray(sandboxIDs) ? sandboxIDs : (typeof sandboxIDs === 'string' ? [sandboxIDs] : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs)) || []); return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); }; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index f1d7d47..0432f50 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -1,4 +1,4 @@ -const { connectEndStreamError, connectRPC, envdHeaders } = require('./envd'); +const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); const { CommandExitError } = require('./errors'); const { parseJSON, parseRequestUrl } = require('./util'); const http = require('http'); @@ -270,6 +270,11 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { while (responseBuffer.length >= 5) { const flags = responseBuffer[0]; const length = responseBuffer.readUInt32BE(1); + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + fail(new Error(`Sandbox envd stream envelope too large: ${length}`)); + req.destroy(); + return; + } if (responseBuffer.length < 5 + length) { break; } diff --git a/qiniu/sandbox/envd.js b/qiniu/sandbox/envd.js index f74d3b9..e8bb46a 100644 --- a/qiniu/sandbox/envd.js +++ b/qiniu/sandbox/envd.js @@ -1,5 +1,7 @@ const { basicAuth, parseJSON, rawRequest } = require('./util'); +const MAX_CONNECT_ENVELOPE_BYTES = 10 * 1024 * 1024; + function envdHeaders (sandbox, user) { const headers = { Authorization: basicAuth(user) @@ -66,6 +68,9 @@ function decodeConnectEnvelopes (data) { const flags = data[offset]; const length = data.readUInt32BE(offset + 1); offset += 5; + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + throw new Error(`Sandbox envd stream envelope too large: ${length}`); + } if (offset + length > data.length) { break; } @@ -126,3 +131,4 @@ exports.connectRPC = connectRPC; exports.connectStreamRPC = connectStreamRPC; exports.connectEndStreamError = connectEndStreamError; exports.envdHeaders = envdHeaders; +exports.MAX_CONNECT_ENVELOPE_BYTES = MAX_CONNECT_ENVELOPE_BYTES; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 69634ad..8c412b8 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -1,4 +1,4 @@ -const { connectEndStreamError, connectRPC, envdHeaders } = require('./envd'); +const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); const { SandboxError } = require('./errors'); const { parseRequestUrl, rawRequest } = require('./util'); const { Readable } = require('stream'); @@ -266,7 +266,8 @@ Filesystem.prototype.list = function (path, opts) { Filesystem.prototype.exists = function (path, opts) { return this.getInfo(path, opts).then(() => true, err => { - if (err.response && err.response.statusCode === 404) { + const resp = err.response || err.resp; + if (resp && resp.statusCode === 404) { return false; } throw err; @@ -389,6 +390,11 @@ function watchDir (sandbox, path, onEvent, opts) { while (responseBuffer.length >= 5) { const flags = responseBuffer[0]; const length = responseBuffer.readUInt32BE(1); + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + fail(new SandboxError(`Sandbox envd stream envelope too large: ${length}`)); + req.destroy(); + return; + } if (responseBuffer.length < 5 + length) { break; } diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 75ba65f..3a57ad7 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -363,22 +363,9 @@ Git.prototype._runGitWithTemporaryAuth = function (repoPath, args, opts) { if (!opts.username && !opts.password) { return this._runGit(repoPath, args, opts); } - const remote = opts.remote || 'origin'; - let originalUrl; - return this.remoteGet(repoPath, remote, opts).then(repoUrl => { - if (!repoUrl) { - throw new GitUpstreamError(`Remote ${remote} does not exist`); - } - originalUrl = repoUrl; - return this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(authUrl(repoUrl, opts))], opts); - }).then(() => this._runGit(repoPath, args, opts)) - .then(result => this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(stripAuth(originalUrl))], opts) - .then(() => result), err => this._runGit(repoPath, ['remote', 'set-url', shellQuote(remote), shellQuote(stripAuth(originalUrl))], opts) - .then(() => { - throw err; - }, () => { - throw err; - })); + const runOpts = gitCredentialOptions(opts); + const authArgs = credentialHelperArgs(runOpts).concat(args); + return this._runGit(repoPath, authArgs, runOpts); }; function parseGitStatus (stdout) { diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 9f19915..593abe9 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -1,4 +1,4 @@ -const { connectEndStreamError, connectRPC, envdHeaders } = require('./envd'); +const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); const { parseRequestUrl } = require('./util'); const http = require('http'); const https = require('https'); @@ -160,6 +160,11 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { while (responseBuffer.length >= 5) { const flags = responseBuffer[0]; const length = responseBuffer.readUInt32BE(1); + if (length > MAX_CONNECT_ENVELOPE_BYTES) { + fail(new Error(`Sandbox envd stream envelope too large: ${length}`)); + req.destroy(); + return; + } if (responseBuffer.length < 5 + length) { break; } diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index 458288a..8242de6 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -245,9 +245,10 @@ Sandbox.prototype.getMcpToken = function () { this._mcpTokenPromise = this.files.read('/etc/mcp-gateway/.token', { user: 'root' }).then(token => { - this.mcpToken = token; + const trimmedToken = typeof token === 'string' ? token.trim() : token; + this.mcpToken = trimmedToken; this._mcpTokenPromise = null; - return token; + return trimmedToken; }, err => { this._mcpTokenPromise = null; throw err; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index cfdc6ef..b2f5108 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -149,6 +149,52 @@ function joinDockerfileLines (content) { return lines; } +function splitDockerfileArgs (value) { + const args = []; + let current = ''; + let quote = ''; + let escape = false; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + if (escape) { + current += ch; + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (quote) { + if (ch === quote) { + quote = ''; + } else { + current += ch; + } + continue; + } + if (ch === '"' || ch === '\'') { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + current += ch; + } + if (escape) { + current += '\\'; + } + if (current) { + args.push(current); + } + return args; +} + Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { const isPath = typeof dockerfileContentOrPath === 'string' && dockerfileContentOrPath.length < 1024 && @@ -183,7 +229,7 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { addStep(this, 'ENV', args); } } else if (instruction === 'COPY' || instruction === 'ADD') { - const parts = rest.split(/\s+/); + const parts = splitDockerfileArgs(rest); if (parts.length >= 2) { this.copy(parts.slice(0, -1), parts[parts.length - 1]); } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index e0deee1..db06ff5 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -69,6 +69,13 @@ function encodeConnectEndEnvelope (message) { return encodeRawConnectEnvelope(JSON.stringify(message || {}), 2); } +function encodeOversizedConnectHeader () { + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(10 * 1024 * 1024 + 1, 1); + return header; +} + describe('test sandbox module', function () { it('creates sandbox with E2B compatible options and API key auth', function () { return startServer((req, res) => { @@ -151,10 +158,12 @@ describe('test sandbox module', function () { return client.listSandboxes() .then(() => client.getSandboxesMetrics()) + .then(() => client.getSandboxesMetrics('sbx_one')) .then(() => { urls.should.eql([ 'http://sandbox.test/sandboxes', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=' + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one' ]); }); }); @@ -682,6 +691,22 @@ describe('test sandbox module', function () { }); }); + it('returns false from exists for err.resp 404 responses', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_exists_resp', + envdUrl: 'http://127.0.0.1:9', + info: {} + }); + sandbox.files.getInfo = function () { + const err = new Error('missing'); + err.resp = { statusCode: 404 }; + return Promise.reject(err); + }; + return sandbox.files.exists('/missing').then(exists => { + exists.should.eql(false); + }); + }); + it('rejects malformed filesystem watch stream payloads', function () { return startServer((req, res) => { if (req.url === '/filesystem.Filesystem/WatchDir') { @@ -724,6 +749,40 @@ describe('test sandbox module', function () { }); }); + it('rejects oversized filesystem watch stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('runs commands and git operations through process RPC', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -1278,11 +1337,12 @@ describe('test sandbox module', function () { it('joins Dockerfile lines continued with backslash before parsing', function () { const template = qiniu.sandbox.Template() - .fromDockerfile('FROM ubuntu:22.04\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000'); + .fromDockerfile('FROM ubuntu:22.04\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000\nCOPY "file name.txt" "/app/data dir/"'); template.buildConfig.steps.should.eql([ { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] }, - { type: 'ENV', args: ['PORT', '3000'] } + { type: 'ENV', args: ['PORT', '3000'] }, + { type: 'COPY', args: ['file name.txt', '/app/data dir/', '', ''] } ]); }); @@ -1699,6 +1759,37 @@ describe('test sandbox module', function () { }); }); + it('rejects oversized command stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo huge').then(() => { + throw new Error('expected command stream to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('fails watchDir on Connect end-stream errors after start', function () { let exitError; return startServer((req, res) => { @@ -1837,18 +1928,12 @@ describe('test sandbox module', function () { }); }); - it('cleans temporary git credentials when push fails', function () { + it('passes git push credentials through a helper when push fails', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ run: function (cmd, opts) { commandsSeen.push({ cmd, opts }); - if (cmd.indexOf('remote get-url') >= 0) { - return Promise.resolve({ - stdout: 'https://github.com/acme/repo.git\n', - exitCode: 0 - }); - } - if (cmd.indexOf('git push') >= 0) { + if (cmd.indexOf(' push ') >= 0) { return Promise.reject(new Error('push failed')); } return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); @@ -1864,12 +1949,40 @@ describe('test sandbox module', function () { throw new Error('expected git push to fail'); }, err => { err.message.should.eql('push failed'); - commandsSeen.map(item => item.cmd).should.eql([ - 'git remote get-url \'origin\'', - 'git remote set-url \'origin\' \'https://u:p@github.com/acme/repo.git\'', - 'git push \'origin\' \'main\'', - 'git remote set-url \'origin\' \'https://github.com/acme/repo.git\'' - ]); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('passes git pull credentials through a helper without rewriting remotes', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.pull('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('pull \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); }); }); @@ -1939,27 +2052,26 @@ describe('test sandbox module', function () { } }); - it('surfaces git upstream and validation errors on auth helpers', function () { + it('surfaces git auth and validation errors on auth helpers', function () { const git = new qiniu.sandbox.Git({ run: function () { return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); } }); - return git.push('/repo', { - username: 'u', - password: 'p' - }).then(() => { - throw new Error('expected missing upstream'); - }, err => { - err.name.should.eql('GitUpstreamError'); - return git.clone('https://github.com/acme/repo.git', '/repo', { + try { + git.push('/repo', { username: 'u' }); - }).then(() => { throw new Error('expected missing password'); - }, err => { + } catch (err) { err.name.should.eql('GitAuthError'); + } + + return git.dangerouslyAuthenticate('/repo', 'origin', 'u', 'p').then(() => { + throw new Error('expected missing upstream'); + }, err => { + err.name.should.eql('GitUpstreamError'); return git.commit('/repo', 'msg', { authorName: 'Alice' }); @@ -1970,23 +2082,14 @@ describe('test sandbox module', function () { }); }); - it('keeps original git push error when credential cleanup fails', function () { + it('keeps original git push error without credential cleanup', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ run: function (cmd, opts) { commandsSeen.push({ cmd, opts }); - if (cmd.indexOf('remote get-url') >= 0) { - return Promise.resolve({ - stdout: 'https://github.com/acme/repo.git\n', - exitCode: 0 - }); - } - if (cmd.indexOf('git push') >= 0) { + if (cmd.indexOf(' push ') >= 0) { return Promise.reject(new Error('push failed')); } - if (cmd.indexOf('remote set-url') >= 0 && commandsSeen.length > 3) { - return Promise.reject(new Error('cleanup failed')); - } return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); } }); @@ -2000,12 +2103,10 @@ describe('test sandbox module', function () { throw new Error('expected git push to fail'); }, err => { err.message.should.eql('push failed'); - commandsSeen.map(item => item.cmd).should.eql([ - 'git remote get-url \'origin\'', - 'git remote set-url \'origin\' \'https://u:p@github.com/acme/repo.git\'', - 'git push \'origin\' \'main\'', - 'git remote set-url \'origin\' \'https://github.com/acme/repo.git\'' - ]); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('remote set-url'); }); }); @@ -2456,7 +2557,7 @@ describe('test sandbox module', function () { tokenReads += 1; res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); - res.end('mcp-token'); + res.end('mcp-token\n'); return; } res.statusCode = 404; @@ -2663,6 +2764,40 @@ describe('test sandbox module', function () { }); }); + it('rejects oversized PTY stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(() => { + throw new Error('expected pty stream to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects live PTY start when the process stream does not start before timeout', function () { let ptyResponse; return startServer((req, res) => { From 070a7b123cc1ce89593831d6b9d82e0e585bd52b Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 19:36:38 +0800 Subject: [PATCH 13/48] fix(sandbox): tighten parser edge cases --- qiniu/sandbox/envd.js | 3 +- qiniu/sandbox/filesystem.js | 13 ++-- qiniu/sandbox/git.js | 23 ++++++ qiniu/sandbox/sandbox.js | 5 +- qiniu/sandbox/template.js | 20 +++++- test/sandbox.test.js | 140 +++++++++++++++++++++++++++++++++++- 6 files changed, 195 insertions(+), 9 deletions(-) diff --git a/qiniu/sandbox/envd.js b/qiniu/sandbox/envd.js index e8bb46a..8d0bd94 100644 --- a/qiniu/sandbox/envd.js +++ b/qiniu/sandbox/envd.js @@ -72,7 +72,7 @@ function decodeConnectEnvelopes (data) { throw new Error(`Sandbox envd stream envelope too large: ${length}`); } if (offset + length > data.length) { - break; + throw new Error('Sandbox envd stream truncated unexpectedly'); } const payload = data.slice(offset, offset + length).toString(); offset += length; @@ -130,5 +130,6 @@ function connectStreamRPC (sandbox, procedure, body, opts) { exports.connectRPC = connectRPC; exports.connectStreamRPC = connectStreamRPC; exports.connectEndStreamError = connectEndStreamError; +exports.decodeConnectEnvelopes = decodeConnectEnvelopes; exports.envdHeaders = envdHeaders; exports.MAX_CONNECT_ENVELOPE_BYTES = MAX_CONNECT_ENVELOPE_BYTES; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 8c412b8..7606141 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -363,10 +363,15 @@ function watchDir (sandbox, path, onEvent, opts) { if (event.filesystem && onEvent) { const type = normalizeFilesystemEventType(event.filesystem.type); if (type) { - onEvent({ - name: event.filesystem.name, - type - }); + try { + onEvent({ + name: event.filesystem.name, + type + }); + } catch (err) { + fail(err); + req.destroy(); + } } } } diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 3a57ad7..62ea76e 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -85,6 +85,14 @@ function pathFromOpts (opts) { } function normalizeConfigCall (args) { + if (args.length === 2) { + return { + repoPath: undefined, + key: args[0], + value: args[1], + opts: {} + }; + } if (typeof args[2] === 'object' && args[2] !== null) { return { repoPath: pathFromOpts(args[2]), @@ -102,6 +110,13 @@ function normalizeConfigCall (args) { } function normalizeGetConfigCall (args) { + if (args.length === 1) { + return { + repoPath: undefined, + key: args[0], + opts: {} + }; + } if (typeof args[1] === 'object' && args[1] !== null) { return { repoPath: pathFromOpts(args[1]), @@ -117,6 +132,14 @@ function normalizeGetConfigCall (args) { } function normalizeConfigureUserCall (args) { + if (args.length === 2) { + return { + repoPath: undefined, + name: args[0], + email: args[1], + opts: {} + }; + } if (typeof args[2] === 'object' && args[2] !== null) { return { repoPath: pathFromOpts(args[2]), diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index 8242de6..cc74d6e 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -269,10 +269,11 @@ Sandbox.prototype.isRunning = function () { method: 'GET', dataType: 'text' }).then(() => true, err => { - if (err.response && err.response.statusCode === 502) { + const resp = err.response || err.resp; + if (resp && resp.statusCode === 502) { return false; } - if (err.resp && err.resp.statusCode === 502) { + if (['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].indexOf(err.code) >= 0) { return false; } throw err; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index b2f5108..b90cc7c 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -195,6 +195,21 @@ function splitDockerfileArgs (value) { return args; } +function dockerfileFromImage (value) { + const parts = splitDockerfileArgs(value); + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + if (/^--/.test(part)) { + continue; + } + if (/^AS$/i.test(part)) { + break; + } + return part; + } + return null; +} + Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { const isPath = typeof dockerfileContentOrPath === 'string' && dockerfileContentOrPath.length < 1024 && @@ -216,7 +231,10 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { const instruction = match[1].toUpperCase(); const rest = match[2].trim(); if (instruction === 'FROM') { - this.fromImage(rest.split(/\s+/)[0]); + const image = dockerfileFromImage(rest); + if (image) { + this.fromImage(image); + } } else if (instruction === 'RUN') { this.runCmd(rest); } else if (instruction === 'WORKDIR') { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index db06ff5..9413a03 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -76,6 +76,13 @@ function encodeOversizedConnectHeader () { return header; } +function encodeTruncatedConnectHeader () { + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(20, 1); + return header; +} + describe('test sandbox module', function () { it('creates sandbox with E2B compatible options and API key auth', function () { return startServer((req, res) => { @@ -1337,7 +1344,8 @@ describe('test sandbox module', function () { it('joins Dockerfile lines continued with backslash before parsing', function () { const template = qiniu.sandbox.Template() - .fromDockerfile('FROM ubuntu:22.04\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000\nCOPY "file name.txt" "/app/data dir/"'); + .fromDockerfile('FROM --platform=linux/amd64 ubuntu:22.04 AS build\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000\nCOPY "file name.txt" "/app/data dir/"'); + template.buildConfig.fromImage.should.eql('ubuntu:22.04'); template.buildConfig.steps.should.eql([ { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] }, @@ -1832,6 +1840,57 @@ describe('test sandbox module', function () { }); }); + it('fails watchDir when the event callback throws', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_callback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => { + throw new Error('callback failed'); + }, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('callback failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects command start when the process stream does not start before timeout', function () { let commandResponse; return startServer((req, res) => { @@ -2110,6 +2169,35 @@ describe('test sandbox module', function () { }); }); + it('normalizes git config helpers when options are omitted', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); + } + }); + + return git.setConfig('user.name', 'Alice') + .then(() => git.getConfig('user.name')) + .then(value => { + value.should.eql('Alice'); + return git.configureUser('Alice', 'alice@example.com'); + }) + .then(() => { + const shellQuote = require('../qiniu/sandbox/util').shellQuote; + commandsSeen.map(item => item.cmd).should.eql([ + 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), + 'git config --get ' + shellQuote('user.name'), + 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), + 'git config ' + shellQuote('user.email') + ' ' + shellQuote('alice@example.com') + ]); + commandsSeen.forEach(item => { + should.not.exist(item.opts.cwd); + }); + }); + }); + it('covers Sandbox.connect, Sandbox.list, wait polling, and stopped health checks', function () { let infoCalls = 0; return startServer((req, res) => { @@ -2277,6 +2365,18 @@ describe('test sandbox module', function () { }); }); + it('returns false from isRunning on connection failures', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_down', + envdUrl: 'http://127.0.0.1:9', + info: {} + }); + + return sandbox.isRunning().then(running => { + running.should.eql(false); + }); + }); + it('supports JSON fallback for process stream responses and poll timeout errors', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -2329,6 +2429,44 @@ describe('test sandbox module', function () { }); }); + it('rejects truncated buffered Connect stream responses', function () { + try { + require('../qiniu/sandbox/envd').decodeConnectEnvelopes(encodeTruncatedConnectHeader()); + throw new Error('expected buffered decoder to reject truncated stream'); + } catch (err) { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + } + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeTruncatedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_truncated', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('echo bad').then(() => { + throw new Error('expected truncated stream error'); + }, err => { + err.message.should.eql('Command stream ended before process start'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('throws TemplateBuildError when a template build finishes with error status', function () { return startServer((req, res) => { if (req.url === '/templates/tpl_1/builds/bld_1/status') { From 9b74b33e209fc937ecd9511e29248371c5d52407 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 19:49:38 +0800 Subject: [PATCH 14/48] fix(sandbox): address stream review feedback --- qiniu/sandbox/client.js | 8 +- qiniu/sandbox/commands.js | 6 +- qiniu/sandbox/filesystem.js | 28 ++++- qiniu/sandbox/git.js | 2 +- qiniu/sandbox/pty.js | 10 +- test/sandbox.test.js | 239 ++++++++++++++++++++++++++++++++++-- 6 files changed, 272 insertions(+), 21 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index dc08bca..d3f794a 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -148,7 +148,13 @@ SandboxClient.prototype._headers = function (authType) { }; SandboxClient.prototype._middlewares = function (authType) { - if (authType === 'qiniu' || (!this.apiKey && !this.accessToken && this.mac)) { + if (authType === 'qiniu') { + if (!this.mac) { + throw new SandboxError('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + } + return [new QiniuAuthMiddleware({ mac: this.mac })]; + } + if (!this.apiKey && !this.accessToken && this.mac) { return [new QiniuAuthMiddleware({ mac: this.mac })]; } return []; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 0432f50..09e80e8 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -75,7 +75,7 @@ function commandResultFromEvents (events, callbacks) { function requestTimeout (opts) { opts = opts || {}; - return opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; + return opts.requestTimeoutMs || opts.timeoutMs || (opts.timeout ? opts.timeout * 1000 : undefined); } function encodeConnectEnvelope (message) { @@ -308,6 +308,10 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { }); res.on('error', fail); res.on('end', () => { + if (responseBuffer.length > 0) { + fail(new Error('Sandbox envd stream truncated unexpectedly')); + return; + } if (!isConnectStream) { cleanupStartTimer(); let events; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 7606141..bdfac2e 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -331,6 +331,8 @@ function watchDir (sandbox, path, onEvent, opts) { let handle; let responseBuffer = Buffer.alloc(0); let startTimer; + let pendingCallbacks = 0; + let streamEnded = false; function cleanupStartTimer () { if (startTimer) { @@ -339,6 +341,12 @@ function watchDir (sandbox, path, onEvent, opts) { } } + function finishIfIdle () { + if (streamEnded && handle && pendingCallbacks === 0) { + handle._finish(); + } + } + function fail (err) { cleanupStartTimer(); if (!settled) { @@ -364,11 +372,20 @@ function watchDir (sandbox, path, onEvent, opts) { const type = normalizeFilesystemEventType(event.filesystem.type); if (type) { try { - onEvent({ + pendingCallbacks += 1; + Promise.resolve(onEvent({ name: event.filesystem.name, type + })).then(() => { + pendingCallbacks -= 1; + finishIfIdle(); + }, err => { + pendingCallbacks -= 1; + fail(err); + req.destroy(); }); } catch (err) { + pendingCallbacks -= 1; fail(err); req.destroy(); } @@ -433,14 +450,17 @@ function watchDir (sandbox, path, onEvent, opts) { }); res.on('error', fail); res.on('end', () => { + if (responseBuffer.length > 0) { + fail(new SandboxError('Sandbox envd stream truncated unexpectedly')); + return; + } cleanupStartTimer(); if (!settled) { fail(new Error('WatchDir stream ended before start event')); return; } - if (handle) { - handle._finish(); - } + streamEnded = true; + finishIfIdle(); }); }); req.on('error', err => { diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 62ea76e..8f4e116 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -310,7 +310,7 @@ Git.prototype.configureUser = function () { }; Git.prototype.branches = function (repoPath, opts) { - return this._runGit(repoPath, ['branch', '--format=%(HEAD) %(refname:short)'], opts) + return this._runGit(repoPath, ['branch', shellQuote('--format=%(HEAD) %(refname:short)')], opts) .then(result => String(result.stdout || '').split(/\r?\n/) .filter(Boolean) .map(line => ({ diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 593abe9..6534523 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -23,11 +23,7 @@ function dataToBuffer (value) { return Buffer.from(value); } if (typeof value === 'string') { - const decoded = Buffer.from(value, 'base64'); - if (decoded.length && decoded.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) { - return decoded; - } - return Buffer.from(value); + return Buffer.from(value, 'base64'); } return Buffer.from(String(value || '')); } @@ -198,6 +194,10 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { }); res.on('error', fail); res.on('end', () => { + if (responseBuffer.length > 0) { + fail(new Error('Sandbox envd stream truncated unexpectedly')); + return; + } if (!settled) { fail(new Error('PTY stream ended before process start')); return; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 9413a03..f16011c 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -175,6 +175,21 @@ describe('test sandbox module', function () { }); }); + it('throws a clear error when Qiniu-auth APIs are called without AK/SK credentials', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + + try { + client.listInjectionRules(); + throw new Error('expected missing Qiniu credentials error'); + } catch (err) { + err.name.should.eql('SandboxError'); + err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + } + }); + it('keeps Qiniu sandbox extensions in create body', function () { return startServer((req, res) => { res.statusCode = 201; @@ -1841,6 +1856,56 @@ describe('test sandbox module', function () { }); it('fails watchDir when the event callback throws', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: {} } })); + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + })); + setTimeout(() => res.end(), 50); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_callback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => { + throw new Error('callback failed'); + }, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('callback failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the event callback rejects asynchronously', function () { let exitError; return startServer((req, res) => { if (req.url === '/filesystem.Filesystem/WatchDir') { @@ -1863,7 +1928,7 @@ describe('test sandbox module', function () { res.end(); }).then(fixture => { const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_callback', + sandboxId: 'sbx_watch_async_callback', envdUrl: fixture.endpoint, info: { envdAccessToken: 'token', @@ -1871,9 +1936,7 @@ describe('test sandbox module', function () { } }); - return sandbox.files.watchDir('/workspace', () => { - throw new Error('callback failed'); - }, { + return sandbox.files.watchDir('/workspace', () => Promise.reject(new Error('async callback failed')), { recursive: true, onExit: err => { exitError = err; @@ -1882,7 +1945,49 @@ describe('test sandbox module', function () { return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); }).then(handle => { handle._stopped.should.eql(true); - exitError.message.should.eql('callback failed'); + exitError.message.should.eql('async callback failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the live Connect stream ends with a partial frame after start', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('Sandbox envd stream truncated unexpectedly'); }).then(() => closeServer(fixture.server), err => { return closeServer(fixture.server).then(() => { throw err; @@ -1931,12 +2036,88 @@ describe('test sandbox module', function () { }); }); + it('treats command timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 81 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 1', { + timeout: 1 + }).then(handle => { + handle.pid.should.eql(81); + return handle.wait(); + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command wait when the live Connect stream ends with a partial frame after start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 82 } } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(82); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ run: function (cmd, opts) { commandsSeen.push({ cmd, opts }); - if (cmd.indexOf('branch --format') >= 0) { + if (cmd.indexOf('branch ') >= 0 && cmd.indexOf('%(refname:short)') >= 0) { return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); } if (cmd.indexOf('remote get-url') >= 0) { @@ -1976,7 +2157,7 @@ describe('test sandbox module', function () { GIT_USERNAME: 'u', GIT_PASSWORD: 'p' }); - commandText.should.containEql('branch --format'); + commandText.should.containEql('branch \'--format=%(HEAD) %(refname:short)\''); commandText.should.containEql('reset --hard \'HEAD~1\''); commandText.should.containEql('restore --staged -- \'a.txt\''); commandText.should.containEql('remote remove \'origin\''); @@ -2458,7 +2639,7 @@ describe('test sandbox module', function () { return sandbox.commands.run('echo bad').then(() => { throw new Error('expected truncated stream error'); }, err => { - err.message.should.eql('Command stream ended before process start'); + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); }).then(() => closeServer(fixture.server), err => { return closeServer(fixture.server).then(() => { throw err; @@ -2768,7 +2949,7 @@ describe('test sandbox module', function () { res.setHeader('Content-Type', 'application/connect+json'); res.end(Buffer.concat([ encodeConnectEnvelope({ event: { start: { pid: 44 } } }), - encodeConnectEnvelope({ event: { data: { pty: [111, 107] } } }), + encodeConnectEnvelope({ event: { data: { pty: Buffer.from('ok').toString('base64') } } }), encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) ])); return; @@ -2902,6 +3083,46 @@ describe('test sandbox module', function () { }); }); + it('rejects PTY wait when the live Connect stream ends with a partial frame after start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 46 } } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(46); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects oversized PTY stream envelopes', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { From 44b3b199d9232d0c4cad6191260494f62a00863a Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 19:58:13 +0800 Subject: [PATCH 15/48] fix(sandbox): normalize timeout aliases --- qiniu/sandbox/client.js | 2 +- qiniu/sandbox/commands.js | 6 +- qiniu/sandbox/filesystem.js | 6 +- qiniu/sandbox/pty.js | 6 +- qiniu/sandbox/sandbox.js | 2 +- qiniu/sandbox/template.js | 35 ++++++- qiniu/sandbox/util.js | 17 +++- test/sandbox.test.js | 194 ++++++++++++++++++++++++++++++++++-- 8 files changed, 249 insertions(+), 19 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index d3f794a..bf57764 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -218,7 +218,7 @@ SandboxClient.prototype.listSandboxesV2 = function (opts) { SandboxClient.prototype.createSandbox = function (opts) { const body = normalizeSandboxCreateOptions(opts); return this._request('POST', '/sandboxes', { - authType: hasKodoResource(body) && this.mac ? 'qiniu' : undefined, + authType: hasKodoResource(body) ? 'qiniu' : undefined, body }); }; diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 09e80e8..7efbe9b 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -1,6 +1,6 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); const { CommandExitError } = require('./errors'); -const { parseJSON, parseRequestUrl } = require('./util'); +const { millisecondsFromOptions, parseJSON, parseRequestUrl } = require('./util'); const http = require('http'); const https = require('https'); @@ -75,7 +75,9 @@ function commandResultFromEvents (events, callbacks) { function requestTimeout (opts) { opts = opts || {}; - return opts.requestTimeoutMs || opts.timeoutMs || (opts.timeout ? opts.timeout * 1000 : undefined); + return opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout'); } function encodeConnectEnvelope (message) { diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index bdfac2e..d74b1f0 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -1,6 +1,6 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); const { SandboxError } = require('./errors'); -const { parseRequestUrl, rawRequest } = require('./util'); +const { millisecondsFromOptions, parseRequestUrl, rawRequest } = require('./util'); const { Readable } = require('stream'); const zlib = require('zlib'); const http = require('http'); @@ -393,7 +393,9 @@ function watchDir (sandbox, path, onEvent, opts) { } } - const startTimeout = opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; + const startTimeout = opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout'); if (startTimeout) { startTimer = setTimeout(() => { fail(new SandboxError('Sandbox filesystem watch start timed out')); diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 6534523..bf897c6 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -1,5 +1,5 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); -const { parseRequestUrl } = require('./util'); +const { millisecondsFromOptions, parseRequestUrl } = require('./util'); const http = require('http'); const https = require('https'); @@ -209,7 +209,9 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { }); }); req.on('error', fail); - const startTimeout = opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; + const startTimeout = opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout'); if (startTimeout) { startTimer = setTimeout(() => { fail(new Error('PTY stream start timed out')); diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index cc74d6e..5c448b2 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -270,7 +270,7 @@ Sandbox.prototype.isRunning = function () { dataType: 'text' }).then(() => true, err => { const resp = err.response || err.resp; - if (resp && resp.statusCode === 502) { + if (resp && [502, 503, 504].indexOf(resp.statusCode) >= 0) { return false; } if (['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].indexOf(err.code) >= 0) { diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index b90cc7c..e8e01be 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -136,6 +136,14 @@ function joinDockerfileLines (content) { let current = ''; String(content || '').split(/\r?\n/).forEach(rawLine => { const line = rawLine.trim(); + if (!line || line[0] === '#') { + if (current) { + lines.push(current); + current = ''; + } + lines.push(line); + return; + } if (line.charAt(line.length - 1) === '\\') { current += line.slice(0, -1) + ' '; return; @@ -150,6 +158,17 @@ function joinDockerfileLines (content) { } function splitDockerfileArgs (value) { + value = String(value || '').trim(); + if (value.charAt(0) === '[' && value.charAt(value.length - 1) === ']') { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.map(item => String(item)); + } + } catch (err) { + // Fall through to the shell-style parser below. + } + } const args = []; let current = ''; let quote = ''; @@ -195,6 +214,20 @@ function splitDockerfileArgs (value) { return args; } +function dockerfileCopyArgs (value) { + const args = splitDockerfileArgs(value); + while (args.length && /^--/.test(args[0])) { + const flag = args.shift(); + if (/^--from(?:=|$)/i.test(flag)) { + return []; + } + if (flag.indexOf('=') < 0 && args.length > 2) { + args.shift(); + } + } + return args; +} + function dockerfileFromImage (value) { const parts = splitDockerfileArgs(value); for (let i = 0; i < parts.length; i += 1) { @@ -247,7 +280,7 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { addStep(this, 'ENV', args); } } else if (instruction === 'COPY' || instruction === 'ADD') { - const parts = splitDockerfileArgs(rest); + const parts = dockerfileCopyArgs(rest); if (parts.length >= 2) { this.copy(parts.slice(0, -1), parts[parts.length - 1]); } diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 56054f8..88ddc6d 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -43,6 +43,18 @@ function timeoutSecondsFromOptions (opts) { return undefined; } +function millisecondsFromOptions (opts, key, defaultValue) { + opts = opts || {}; + const msKey = `${key}Ms`; + if (opts[msKey] !== undefined) { + return opts[msKey]; + } + if (opts[key] !== undefined) { + return opts[key] * 1000; + } + return defaultValue; +} + function copyDefined (target, source, key, outKey) { if (source[key] !== undefined) { target[outKey || key] = source[key]; @@ -51,8 +63,8 @@ function copyDefined (target, source, key, outKey) { function poll (fn, opts, done) { opts = opts || {}; - const interval = opts.interval || opts.intervalMs || 1000; - const timeout = opts.timeout || opts.timeoutMs || 60000; + const interval = millisecondsFromOptions(opts, 'interval', 1000); + const timeout = millisecondsFromOptions(opts, 'timeout', 60000); const startedAt = Date.now(); function tick () { @@ -173,6 +185,7 @@ exports.normalizeEndpoint = normalizeEndpoint; exports.encodePath = encodePath; exports.appendQuery = appendQuery; exports.timeoutSecondsFromOptions = timeoutSecondsFromOptions; +exports.millisecondsFromOptions = millisecondsFromOptions; exports.copyDefined = copyDefined; exports.poll = poll; exports.basicAuth = basicAuth; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index f16011c..854cf03 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -190,6 +190,28 @@ describe('test sandbox module', function () { } }); + it('requires Qiniu AK/SK before creating sandboxes with Kodo resources', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + + try { + client.createSandbox({ + template: 'base', + resources: [{ + type: 'kodo', + bucket: 'bucket', + mountPath: '/workspace/kodo' + }] + }); + throw new Error('expected missing Qiniu credentials error'); + } catch (err) { + err.name.should.eql('SandboxError'); + err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + } + }); + it('keeps Qiniu sandbox extensions in create body', function () { return startServer((req, res) => { res.statusCode = 201; @@ -1369,6 +1391,24 @@ describe('test sandbox module', function () { ]); }); + it('does not merge Dockerfile comments or blank lines that end with backslash', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\n# ignored comment \\\nRUN echo ok\n\nRUN echo next'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo ok' }, + { type: 'run', cmd: 'echo next' } + ]); + }); + + it('parses JSON Dockerfile COPY args and skips unsupported COPY --from flags', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nCOPY ["file name.txt", "/app/data dir/"]\nCOPY --chown=node package.json /app/\nCOPY --from=builder /app/dist /app/dist'); + template.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['file name.txt', '/app/data dir/', '', ''] }, + { type: 'COPY', args: ['package.json', '/app/', '', ''] } + ]); + }); + it('exposes network constants and maps updateNetwork to Qiniu API', function () { return startServer((req, res) => { res.statusCode = 200; @@ -1996,6 +2036,41 @@ describe('test sandbox module', function () { }); }); + it('treats watchDir timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(encodeConnectEnvelope({ event: { start: {} } })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + timeout: 1 + }).then(handle => { + handle._stopped.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects command start when the process stream does not start before timeout', function () { let commandResponse; return startServer((req, res) => { @@ -2426,7 +2501,7 @@ describe('test sandbox module', function () { return qiniu.sandbox.Sandbox.list({ client, limit: 1 }); }).then(sandboxes => { sandboxes[0].sandboxId.should.eql('sbx_9'); - return client.createAndWait({ template: 'base' }, { interval: 1, timeout: 100 }); + return client.createAndWait({ template: 'base' }, { intervalMs: 1, timeoutMs: 100 }); }).then(sandbox => { sandbox.sandboxId.should.eql('sbx_10'); sandbox.envdAccessToken.should.eql('token'); @@ -2477,8 +2552,52 @@ describe('test sandbox module', function () { }); return sandbox.waitForReady({ - interval: 1, - timeout: 50 + intervalMs: 1, + timeoutMs: 50 + }).then(info => { + info.state.should.eql('running'); + infoCalls.should.eql(2); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats waitForReady timeout alias as seconds while polling', function () { + let infoCalls = 0; + return startServer((req, res) => { + if (req.method === 'GET' && req.url === '/sandboxes/sbx_poll_seconds') { + infoCalls += 1; + if (infoCalls === 1) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'starting' })); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'running' })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_poll_seconds', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ + intervalMs: 1, + timeout: 1 }).then(info => { info.state.should.eql('running'); infoCalls.should.eql(2); @@ -2507,7 +2626,7 @@ describe('test sandbox module', function () { info: {} }); - return sandbox.waitForReady({ interval: 1, timeout: 100 }).then(() => { + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { throw new Error('expected waitForReady to fail'); }, err => { err.response.statusCode.should.eql(404); @@ -2520,8 +2639,20 @@ describe('test sandbox module', function () { }); }); - it('rethrows non-502 envd health errors', function () { + it('returns false for transient envd gateway health errors and rethrows others', function () { + let calls = 0; return startServer((req, res) => { + calls += 1; + if (calls === 1) { + res.statusCode = 503; + res.end('starting'); + return; + } + if (calls === 2) { + res.statusCode = 504; + res.end('timeout'); + return; + } res.statusCode = 500; res.end('broken'); }).then(fixture => { @@ -2533,7 +2664,13 @@ describe('test sandbox module', function () { } }); - return sandbox.isRunning().then(() => { + return sandbox.isRunning().then(running => { + running.should.eql(false); + return sandbox.isRunning(); + }).then(running => { + running.should.eql(false); + return sandbox.isRunning(); + }).then(() => { throw new Error('expected health error'); }, err => { err.name.should.eql('SandboxError'); @@ -2594,7 +2731,7 @@ describe('test sandbox module', function () { return sandbox.commands.run('echo ok') .then(result => { result.stdout.should.eql('ok'); - return sandbox.waitForReady({ interval: 1, timeout: 5 }); + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 5 }); }) .then(() => { throw new Error('expected waitForReady timeout'); @@ -2664,7 +2801,7 @@ describe('test sandbox module', function () { apiKey: 'sandbox-key' }); - return client.waitForBuild('tpl_1', 'bld_1', { interval: 1, timeout: 20 }).then(() => { + return client.waitForBuild('tpl_1', 'bld_1', { intervalMs: 1, timeoutMs: 20 }).then(() => { throw new Error('expected template build error'); }, err => { err.name.should.eql('TemplateBuildError'); @@ -3199,6 +3336,47 @@ describe('test sandbox module', function () { }); }); + it('treats PTY timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 47 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24, + timeout: 1 + }).then(handle => { + handle.pid.should.eql(47); + return handle.wait(); + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('supports filesystem gzip and octet-stream write compatibility options', function () { return startServer((req, res) => { const parsed = parseUrl(req.url); From 7693291d3e7ed187627700e19fba5eed89943352 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 20:12:47 +0800 Subject: [PATCH 16/48] fix(sandbox): harden stream and path handling --- qiniu/sandbox/commands.js | 7 +- qiniu/sandbox/filesystem.js | 17 +++- qiniu/sandbox/git.js | 8 +- qiniu/sandbox/pty.js | 5 +- qiniu/sandbox/util.js | 9 +++ test/sandbox.test.js | 156 ++++++++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 12 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 7efbe9b..adeb8ca 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -1,6 +1,6 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); const { CommandExitError } = require('./errors'); -const { millisecondsFromOptions, parseJSON, parseRequestUrl } = require('./util'); +const { agentFromClient, millisecondsFromOptions, parseJSON, parseRequestUrl } = require('./util'); const http = require('http'); const https = require('https'); @@ -157,7 +157,8 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { hostname: target.hostname, port: target.port, path: target.path, - headers + headers, + agent: agentFromClient(commands.sandbox.client, target.protocol) }); let settled = false; @@ -343,7 +344,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { }); }); req.on('error', fail); - const startTimeout = opts.requestTimeoutMs || opts.timeoutMs || opts.timeout; + const startTimeout = requestTimeout(opts); if (startTimeout) { startTimer = setTimeout(() => { fail(new Error('Command stream start timed out')); diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index d74b1f0..f9e64c4 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -1,6 +1,6 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); const { SandboxError } = require('./errors'); -const { millisecondsFromOptions, parseRequestUrl, rawRequest } = require('./util'); +const { agentFromClient, millisecondsFromOptions, parseRequestUrl, rawRequest } = require('./util'); const { Readable } = require('stream'); const zlib = require('zlib'); const http = require('http'); @@ -23,7 +23,7 @@ const ENVD_VERSION_RECURSIVE_WATCH = '0.1.4'; function versionGte (version, minimum) { if (!version) { - return true; + return false; } const left = String(version).split('.').map(value => parseInt(value, 10) || 0); const right = String(minimum).split('.').map(value => parseInt(value, 10) || 0); @@ -127,11 +127,19 @@ WatchHandle.prototype._finish = function (err) { } }; +function multipartFilename (value) { + return String(value || '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); +} + function multipartBody (boundary, parts) { const chunks = []; parts.forEach(part => { chunks.push(Buffer.from(`--${boundary}\r\n`)); - chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.field}"; filename="${part.filename}"\r\n`)); + chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.field}"; filename="${multipartFilename(part.filename)}"\r\n`)); chunks.push(Buffer.from('Content-Type: application/octet-stream\r\n\r\n')); chunks.push(Buffer.isBuffer(part.data) ? part.data : Buffer.from(String(part.data || ''))); chunks.push(Buffer.from('\r\n')); @@ -324,7 +332,8 @@ function watchDir (sandbox, path, onEvent, opts) { hostname: target.hostname, port: target.port, path: target.path, - headers + headers, + agent: agentFromClient(sandbox.client, target.protocol) }); let settled = false; diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 8f4e116..ee0b143 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -29,7 +29,7 @@ function authUrl (repoUrl, opts) { if (!opts.username || !opts.password) { throw new GitAuthError('Both username and password are required for git authentication'); } - return repoUrl.replace(/^(https?):\/\//, `$1://${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@`); + return stripAuth(repoUrl).replace(/^(https?):\/\//, `$1://${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@`); } function stripAuth (repoUrl) { @@ -333,7 +333,8 @@ Git.prototype.reset = function (repoPath, opts) { if (target) { args.push(shellQuote(target)); } - const paths = opts.paths || opts.files || []; + const rawPaths = opts.paths || opts.files || []; + const paths = Array.isArray(rawPaths) ? rawPaths : [rawPaths]; if (paths.length) { args.push('--'); paths.forEach(path => args.push(shellQuote(path))); @@ -360,7 +361,8 @@ Git.prototype.restore = function (repoPath, opts) { if (opts.source) { args.push('--source', shellQuote(opts.source)); } - const paths = opts.paths || opts.files || []; + const rawPaths = opts.paths || opts.files || []; + const paths = Array.isArray(rawPaths) ? rawPaths : [rawPaths]; if (paths.length) { args.push('--'); paths.forEach(path => args.push(shellQuote(path))); diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index bf897c6..ecab13c 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -1,5 +1,5 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYTES } = require('./envd'); -const { millisecondsFromOptions, parseRequestUrl } = require('./util'); +const { agentFromClient, millisecondsFromOptions, parseRequestUrl } = require('./util'); const http = require('http'); const https = require('https'); @@ -73,7 +73,8 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { hostname: target.hostname, port: target.port, path: target.path, - headers + headers, + agent: agentFromClient(sandbox.client, target.protocol) }); let settled = false; diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 88ddc6d..71f461b 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -120,6 +120,14 @@ function rawRequest (requestUrl, options) { }); } +function agentFromClient (client, protocol) { + if (!client) { + return undefined; + } + const source = client.httpClient || client; + return protocol === 'https:' ? source.httpsAgent : source.httpAgent; +} + function parseRequestUrl (requestUrl) { const URLParser = (typeof URL !== 'undefined' && URL) || null; if (URLParser) { @@ -191,6 +199,7 @@ exports.poll = poll; exports.basicAuth = basicAuth; exports.fileSignature = fileSignature; exports.rawRequest = rawRequest; +exports.agentFromClient = agentFromClient; exports.parseRequestUrl = parseRequestUrl; exports.parseJSON = parseJSON; exports.shellQuote = shellQuote; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 854cf03..2da10cf 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -468,6 +468,7 @@ describe('test sandbox module', function () { if (req.method === 'POST' && parsed.pathname === '/files') { parsed.searchParams.get('path').should.eql('/hello.txt'); should(req.headers['content-type']).startWith('multipart/form-data; boundary='); + req.body.should.containEql('filename="/hello.txt"'); req.body.should.containEql('hello'); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); @@ -562,6 +563,44 @@ describe('test sandbox module', function () { }); }); + it('escapes file paths in multipart filenames', function () { + const unsafePath = '/tmp/a"\r\nX-Injected: y.txt'; + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql(unsafePath); + req.body.should.containEql('filename="/tmp/a\\"%0D%0AX-Injected: y.txt"'); + req.body.should.not.containEql('\r\nX-Injected'); + req.body.should.not.containEql('a"\r\n'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'a.txt', path: unsafePath, type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_multipart_safe', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.5' + } + }); + + return sandbox.files.write(unsafePath, 'hello') + .then(info => { + info.path.should.eql(unsafePath); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('uses Connect RPC paths for filesystem metadata operations', function () { return startServer((req, res) => { res.statusCode = 200; @@ -735,6 +774,25 @@ describe('test sandbox module', function () { }); }); + it('rejects recursive directory watching when envd version is unknown', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_unknown_watch', + envdUrl: 'http://127.0.0.1:9', + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject without envd version'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/recursive watching/i); + }); + }); + it('returns false from exists for err.resp 404 responses', function () { const sandbox = new qiniu.sandbox.Sandbox({ sandboxId: 'sbx_exists_resp', @@ -2187,6 +2245,78 @@ describe('test sandbox module', function () { }); }); + it('uses configured HTTP agents for live envd streams', function () { + const agent = new http.Agent(); + const requestsThroughAgent = []; + const originalAddRequest = agent.addRequest; + agent.addRequest = function (req, options) { + requestsThroughAgent.push(options.path); + return originalAddRequest.call(this, req, options); + }; + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 83 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: {} } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_agent', + envdUrl: fixture.endpoint, + httpAgent: agent, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.commands.start('echo ok') + .then(handle => handle.wait()) + .then(() => sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + })) + .then(handle => { + handle._stopped.should.eql(true); + return sandbox.pty.create({ + cols: 80, + rows: 24 + }); + }) + .then(handle => handle.wait()) + .then(() => { + requestsThroughAgent.should.eql([ + '/process.Process/Start', + '/filesystem.Filesystem/WatchDir', + '/process.Process/Start' + ]); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }).then(result => { + agent.destroy(); + return result; + }, err => { + agent.destroy(); + throw err; + }); + }); + it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ @@ -2216,6 +2346,8 @@ describe('test sandbox module', function () { return git.reset('/repo', { hard: true, ref: 'HEAD~1' }); }) .then(() => git.restore('/repo', { staged: true, paths: ['a.txt'] })) + .then(() => git.reset('/repo', { hard: true, ref: 'HEAD', paths: 'single.txt' })) + .then(() => git.restore('/repo', { files: 'restore.txt' })) .then(() => git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git', { overwrite: true, fetch: true })) .then(() => git.commit('/repo', 'msg', { authorName: 'Alice', @@ -2235,6 +2367,8 @@ describe('test sandbox module', function () { commandText.should.containEql('branch \'--format=%(HEAD) %(refname:short)\''); commandText.should.containEql('reset --hard \'HEAD~1\''); commandText.should.containEql('restore --staged -- \'a.txt\''); + commandText.should.containEql('reset --hard \'HEAD\' -- \'single.txt\''); + commandText.should.containEql('restore --worktree -- \'restore.txt\''); commandText.should.containEql('remote remove \'origin\''); commandText.should.containEql('remote add \'origin\''); commandText.should.containEql('fetch \'origin\''); @@ -2397,6 +2531,28 @@ describe('test sandbox module', function () { }); }); + it('replaces existing git remote credentials when dangerously authenticating', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://old:secret@example.com/acme/repo.git\n', + stderr: '', + exitCode: 0 + }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { + commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); + commandsSeen[1].cmd.should.not.containEql('old:secret'); + }); + }); + it('keeps original git push error without credential cleanup', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ From 2516874a8445fbd7b2af4c96830f95b7da6afd07 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 20:25:16 +0800 Subject: [PATCH 17/48] fix(sandbox): align timeout and helper semantics --- qiniu/sandbox/envd.js | 10 +++-- qiniu/sandbox/git.js | 2 +- qiniu/sandbox/sandbox.js | 5 ++- qiniu/sandbox/template.js | 17 ++++++--- test/sandbox.test.js | 78 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 99 insertions(+), 13 deletions(-) diff --git a/qiniu/sandbox/envd.js b/qiniu/sandbox/envd.js index 8d0bd94..175154c 100644 --- a/qiniu/sandbox/envd.js +++ b/qiniu/sandbox/envd.js @@ -1,4 +1,4 @@ -const { basicAuth, parseJSON, rawRequest } = require('./util'); +const { basicAuth, millisecondsFromOptions, parseJSON, rawRequest } = require('./util'); const MAX_CONNECT_ENVELOPE_BYTES = 10 * 1024 * 1024; @@ -33,7 +33,9 @@ function connectRPC (sandbox, procedure, body, opts) { content: JSON.stringify(body || {}), dataType: 'text', headers, - timeout: opts.requestTimeoutMs || opts.timeoutMs || opts.timeout + timeout: opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout') }).then(({ data }) => parseConnectResponse(parseJSON(data))); } @@ -117,7 +119,9 @@ function connectStreamRPC (sandbox, procedure, body, opts) { content: encodeConnectEnvelope(body), dataType: 'buffer', headers, - timeout: opts.requestTimeoutMs || opts.timeoutMs || opts.timeout + timeout: opts.requestTimeoutMs !== undefined + ? opts.requestTimeoutMs + : millisecondsFromOptions(opts, 'timeout') }).then(({ data, resp }) => { const contentType = (resp.headers && resp.headers['content-type']) || ''; if (contentType.indexOf('application/connect+json') >= 0) { diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index ee0b143..a9f78e6 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -258,7 +258,7 @@ Git.prototype.deleteBranch = function (repoPath, branch, opts) { Git.prototype.remoteAdd = function (repoPath, name, repoUrl, opts) { opts = opts || {}; const add = () => this._runGit(repoPath, ['remote', 'add', shellQuote(name), shellQuote(repoUrl)], opts); - const afterAdd = () => opts.fetch ? this._runGit(repoPath, ['fetch', shellQuote(name)], opts) : null; + const afterAdd = result => opts.fetch ? this._runGit(repoPath, ['fetch', shellQuote(name)], opts) : result; if (opts.overwrite) { return this._runGit(repoPath, ['remote', 'remove', shellQuote(name)], opts) .then(add, add) diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index 5c448b2..2aa38be 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -299,7 +299,10 @@ Sandbox.prototype.fileUrl = function (path, operation, opts) { username: user }; if (this.envdAccessToken) { - const expiration = opts.signatureExpiration || opts.signature_expiration || 300; + let expiration = opts.signatureExpiration || opts.signature_expiration || 300; + if (expiration < 1000000000) { + expiration = Math.floor(Date.now() / 1000) + expiration; + } query.signature = fileSignature(path, operation, user, this.envdAccessToken, expiration); query.signature_expiration = expiration; } diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index e8e01be..009f9e6 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -436,16 +436,21 @@ Template.prototype.npmInstall = function (packages, options) { Template.prototype.bunInstall = function (packages, options) { options = options || {}; - const args = ['bun', 'install']; - if (options.g) { - args.push('-g'); + if (packages) { + const args = ['bun', 'add']; + if (options.g) { + args.push('-g'); + } + if (options.dev) { + args.push('--dev'); + } + args.push.apply(args, asArray(packages).map(shellQuote)); + return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); } + const args = ['bun', 'install']; if (options.dev) { args.push('--dev'); } - if (packages) { - args.push.apply(args, asArray(packages).map(shellQuote)); - } return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); }; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 2da10cf..259d4ff 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -449,8 +449,15 @@ describe('test sandbox module', function () { parsed.pathname.should.eql('/files'); parsed.searchParams.get('path').should.eql('/home/user/a.txt'); parsed.searchParams.get('username').should.eql('admin'); - parsed.searchParams.get('signature_expiration').should.eql('60'); + const expiration = Number(parsed.searchParams.get('signature_expiration')); + expiration.should.be.above(Math.floor(Date.now() / 1000)); + expiration.should.be.below(Math.floor(Date.now() / 1000) + 120); should(parsed.searchParams.get('signature')).startWith('v1_'); + + const absolute = parseUrl(sandbox.uploadUrl('/home/user/a.txt', { + signatureExpiration: 2000000000 + })); + absolute.searchParams.get('signature_expiration').should.eql('2000000000'); }); it('reads and writes files through envd HTTP API', function () { @@ -1359,7 +1366,7 @@ describe('test sandbox module', function () { { type: 'RUN', args: ['pip install --user \'numpy\' \'pandas\''] }, { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, - { type: 'RUN', args: ['bun install --dev \'elysia\''] }, + { type: 'RUN', args: ['bun add --dev \'elysia\''] }, { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, { type: 'RUN', args: ['echo one && echo two', 'root'] } @@ -2377,6 +2384,26 @@ describe('test sandbox module', function () { }); }); + it('returns git remote add result when fetch is not requested', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ + stdout: 'added', + stderr: '', + exitCode: 0 + }); + } + }); + + return git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git').then(result => { + result.should.eql({ + stdout: 'added', + stderr: '', + exitCode: 0 + }); + }); + }); + it('passes git push credentials through a helper when push fails', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ @@ -2903,6 +2930,53 @@ describe('test sandbox module', function () { }); }); + it('treats envd RPC timeout alias as seconds', function () { + const envd = require('../qiniu/sandbox/envd'); + return startServer((req, res) => { + if (req.url === '/test.RPC/Call') { + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ result: { ok: true } })); + }, 20); + return; + } + if (req.url === '/test.RPC/Stream') { + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { ok: true } })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_envd_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return envd.connectRPC(sandbox, '/test.RPC/Call', {}, { + timeout: 1 + }).then(result => { + result.should.eql({ ok: true }); + return envd.connectStreamRPC(sandbox, '/test.RPC/Stream', {}, { + timeout: 1 + }); + }).then(events => { + events.should.eql([{ event: { ok: true } }]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects truncated buffered Connect stream responses', function () { try { require('../qiniu/sandbox/envd').decodeConnectEnvelopes(encodeTruncatedConnectHeader()); From 3daec8eabcafee87f1c02de88aa90cb0ac0851e8 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 20:35:53 +0800 Subject: [PATCH 18/48] fix(sandbox): preserve helper edge cases --- qiniu/sandbox/filesystem.js | 3 +- qiniu/sandbox/sandbox.js | 28 +++++++++++++- qiniu/sandbox/template.js | 5 ++- test/sandbox.test.js | 76 +++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index f9e64c4..89b729c 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -141,7 +141,8 @@ function multipartBody (boundary, parts) { chunks.push(Buffer.from(`--${boundary}\r\n`)); chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.field}"; filename="${multipartFilename(part.filename)}"\r\n`)); chunks.push(Buffer.from('Content-Type: application/octet-stream\r\n\r\n')); - chunks.push(Buffer.isBuffer(part.data) ? part.data : Buffer.from(String(part.data || ''))); + const data = part.data !== undefined && part.data !== null ? part.data : ''; + chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(String(data))); chunks.push(Buffer.from('\r\n')); }); chunks.push(Buffer.from(`--${boundary}--\r\n`)); diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index 2aa38be..1d931fa 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -131,11 +131,35 @@ function Sandbox (opts) { this.git = new Git(this.commands); } +function sandboxClientOptions (opts) { + const clientOpts = {}; + [ + 'endpoint', + 'apiUrl', + 'apiKey', + 'accessToken', + 'mac', + 'accessKey', + 'secretKey', + 'macOptions', + 'httpAgent', + 'httpsAgent' + ].forEach(key => { + if (opts[key] !== undefined) { + clientOpts[key] = opts[key]; + } + }); + if (opts.requestTimeoutMs !== undefined) { + clientOpts.timeout = opts.requestTimeoutMs; + } + return clientOpts; +} + Sandbox.create = function (templateOrOpts, maybeOpts) { const opts = typeof templateOrOpts === 'string' ? Object.assign({}, maybeOpts || {}, { templateID: templateOrOpts }) : (templateOrOpts || {}); - const client = opts.client || new SandboxClient(opts); + const client = opts.client || new SandboxClient(sandboxClientOptions(opts)); return client.createSandbox(opts).then(info => { const sandbox = new Sandbox({ client, info }); return sandbox.refreshEnvdTokenIfNeeded(); @@ -144,7 +168,7 @@ Sandbox.create = function (templateOrOpts, maybeOpts) { Sandbox.connect = function (sandboxID, opts) { opts = opts || {}; - const client = opts.client || new SandboxClient(opts); + const client = opts.client || new SandboxClient(sandboxClientOptions(opts)); return client.connectSandbox(sandboxID, opts).then(info => { const sandbox = new Sandbox({ client, diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 009f9e6..cbb31ee 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -70,7 +70,10 @@ Template.prototype.fromTemplate = function (templateID) { }; function padOctal (value) { - let text = Number(value).toString(8); + let text = typeof value === 'number' ? value.toString(8) : String(value || ''); + if (text.startsWith('0o') || text.startsWith('0O')) { + text = text.slice(2); + } while (text.length < 4) { text = `0${text}`; } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 259d4ff..fd7946c 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -364,6 +364,34 @@ describe('test sandbox module', function () { }); }); + it('keeps sandbox lifetime timeout separate from HTTP request timeout in static helpers', function () { + return startServer((req, res) => { + setTimeout(() => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_timeout', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }, 40); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + template: 'base', + timeout: 30 + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_timeout'); + JSON.parse(fixture.requests[0].body).timeout.should.eql(30); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('exposes typed sandbox compatibility errors', function () { const err = new qiniu.sandbox.CommandExitError({ command: 'false', @@ -608,6 +636,41 @@ describe('test sandbox module', function () { }); }); + it('preserves falsy multipart payload values', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/zero.txt'); + req.body.should.containEql('\r\n0\r\n'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zero.txt', path: '/zero.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_multipart_falsy', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.5' + } + }); + + return sandbox.files.write('/zero.txt', 0) + .then(info => { + info.path.should.eql('/zero.txt'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('uses Connect RPC paths for filesystem metadata operations', function () { return startServer((req, res) => { res.statusCode = 200; @@ -1436,6 +1499,19 @@ describe('test sandbox module', function () { template.buildConfig.steps[0].cmd.should.match(/^x+$/); }); + it('preserves octal permission strings in Template helpers', function () { + const template = qiniu.sandbox.Template() + .copy('app.js', '/app/', { mode: '755' }) + .copy('bin.js', '/app/', { mode: '0o755' }) + .makeDir('/app/cache', { mode: '0755' }); + + template.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['app.js', '/app/', '', '0755'] }, + { type: 'COPY', args: ['bin.js', '/app/', '', '0755'] }, + { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/cache\''] } + ]); + }); + it('parses escaped quotes in Dockerfile ENV values', function () { const template = qiniu.sandbox.Template() .fromDockerfile('FROM node:22\nENV FOO="bar\\"baz" QUOTED=\'it\\\'s ok\''); From 0434265ee3d48e744895f800e2806dad0f00c0f8 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 20:44:42 +0800 Subject: [PATCH 19/48] fix(sandbox): harden template helper handling --- qiniu/sandbox/template.js | 47 ++++++++++++++++++++++++++++++++++----- test/sandbox.test.js | 20 ++++++++++++++--- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index cbb31ee..cb36ddf 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -106,11 +106,14 @@ function runShellStep (template, command, options) { function parseEnvArgs (value) { const args = []; - if (value.indexOf('=') < 0) { - const index = value.search(/\s+/); - if (index > 0) { - args.push(value.slice(0, index).trim(), unquoteDockerfileValue(value.slice(index + 1).trim())); + const index = value.search(/\s+/); + if (index > 0) { + const firstWord = value.slice(0, index); + if (firstWord.indexOf('=') < 0) { + args.push(firstWord.trim(), unquoteDockerfileValue(value.slice(index + 1).trim())); + return args; } + } else if (value.indexOf('=') < 0) { return args; } const pattern = /([A-Za-z_][A-Za-z0-9_]*)=("(\\.|[^"\\])*"|'(\\.|[^'\\])*'|\S+)/g; @@ -373,10 +376,11 @@ Template.prototype.remove = function (path, options) { Template.prototype.rename = function (src, dest, options) { options = options || {}; - const args = ['mv', shellQuote(src), shellQuote(dest)]; + const args = ['mv']; if (options.force) { args.push('-f'); } + args.push(shellQuote(src), shellQuote(dest)); return this.runCmd(args.join(' '), { user: options.user }); }; @@ -499,9 +503,33 @@ Template.prototype.setReadyCmd = function (cmd) { return this; }; +function templateClientOptions (opts) { + const clientOpts = {}; + [ + 'endpoint', + 'apiUrl', + 'apiKey', + 'accessToken', + 'mac', + 'accessKey', + 'secretKey', + 'macOptions', + 'httpAgent', + 'httpsAgent' + ].forEach(key => { + if (opts[key] !== undefined) { + clientOpts[key] = opts[key]; + } + }); + if (opts.requestTimeoutMs !== undefined) { + clientOpts.timeout = opts.requestTimeoutMs; + } + return clientOpts; +} + Template.prototype.build = function (opts) { opts = opts || {}; - const client = opts.client || new SandboxClient(opts); + const client = opts.client || new SandboxClient(templateClientOptions(opts)); const body = Object.assign({}, opts, { buildConfig: this.buildConfig }); @@ -511,6 +539,13 @@ Template.prototype.build = function (opts) { delete body.apiKey; delete body.accessToken; delete body.mac; + delete body.accessKey; + delete body.secretKey; + delete body.macOptions; + delete body.httpAgent; + delete body.httpsAgent; + delete body.timeout; + delete body.requestTimeoutMs; return client.createTemplateV3(body); }; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index fd7946c..ce0806b 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1332,12 +1332,25 @@ describe('test sandbox module', function () { return template.build({ apiKey: 'sandbox-key', + accessKey: 'ak', + secretKey: 'sk', + macOptions: { + disableQiniuTimestampSignature: true + }, endpoint: fixture.endpoint, + timeout: 1000, + requestTimeoutMs: 1000, name: 'node-template:test' }).then(result => { result.templateID.should.eql('tpl_1'); const body = JSON.parse(fixture.requests[0].body); body.name.should.eql('node-template:test'); + should.not.exist(body.apiKey); + should.not.exist(body.accessKey); + should.not.exist(body.secretKey); + should.not.exist(body.macOptions); + should.not.exist(body.timeout); + should.not.exist(body.requestTimeoutMs); body.buildConfig.fromImage.should.eql('ubuntu:22.04'); body.buildConfig.steps.should.eql([ { type: 'apt', packages: ['git'] }, @@ -1420,7 +1433,7 @@ describe('test sandbox module', function () { { type: 'COPY', args: ['package.json', '/app/', '', ''] }, { type: 'COPY', args: ['package-lock.json', '/app/', '', ''] }, { type: 'RUN', args: ['rm -r -f \'/tmp/cache dir\' \'/tmp/old\'', 'root'] }, - { type: 'RUN', args: ['mv \'/tmp/a file\' \'/tmp/b file\' -f'] }, + { type: 'RUN', args: ['mv -f \'/tmp/a file\' \'/tmp/b file\''] }, { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/data dir\' \'/app/logs\''] }, { type: 'RUN', args: ['ln -s -f \'/usr/bin/node\' \'/usr/local/bin/node link\'', 'root'] }, { type: 'WORKDIR', args: ['/app'] }, @@ -1514,9 +1527,10 @@ describe('test sandbox module', function () { it('parses escaped quotes in Dockerfile ENV values', function () { const template = qiniu.sandbox.Template() - .fromDockerfile('FROM node:22\nENV FOO="bar\\"baz" QUOTED=\'it\\\'s ok\''); + .fromDockerfile('FROM node:22\nENV FOO="bar\\"baz" QUOTED=\'it\\\'s ok\'\nENV MY_VAR some=value'); template.buildConfig.steps.should.eql([ - { type: 'ENV', args: ['FOO', 'bar"baz', 'QUOTED', 'it\'s ok'] } + { type: 'ENV', args: ['FOO', 'bar"baz', 'QUOTED', 'it\'s ok'] }, + { type: 'ENV', args: ['MY_VAR', 'some=value'] } ]); }); From 802fde46905f8e8b736a2a5103f9bec946e7573e Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 20:48:55 +0800 Subject: [PATCH 20/48] fix(sandbox): sign batch upload urls --- qiniu/sandbox/filesystem.js | 2 +- qiniu/sandbox/sandbox.js | 22 ++++++++++++++++++---- test/sandbox.test.js | 5 ++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 89b729c..fb739c0 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -248,7 +248,7 @@ Filesystem.prototype.writeFiles = function (files, opts) { data: file.data })); - return rawRequest(this.sandbox.batchUploadUrl(opts.user), { + return rawRequest(this.sandbox.batchUploadUrl(opts), { method: 'POST', content: multipartBody(boundary, parts), dataType: 'json', diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index 1d931fa..fa4e606 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -345,10 +345,24 @@ Sandbox.prototype.uploadUrl = function (path, opts) { Sandbox.prototype.UploadURL = Sandbox.prototype.uploadUrl; -Sandbox.prototype.batchUploadUrl = function (user) { - return this.envdUrl() + appendQuery('/files', { - username: user || DEFAULT_USER - }); +Sandbox.prototype.batchUploadUrl = function (opts) { + if (typeof opts === 'string') { + opts = { user: opts }; + } + opts = opts || {}; + const user = opts.user || DEFAULT_USER; + const query = { + username: user + }; + if (this.envdAccessToken) { + let expiration = opts.signatureExpiration || opts.signature_expiration || 300; + if (expiration < 1000000000) { + expiration = Math.floor(Date.now() / 1000) + expiration; + } + query.signature = fileSignature('', 'write', user, this.envdAccessToken, expiration); + query.signature_expiration = expiration; + } + return this.envdUrl() + appendQuery('/files', query); }; exports.Sandbox = Sandbox; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index ce0806b..0aa9a2a 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1684,6 +1684,9 @@ describe('test sandbox module', function () { } if (req.method === 'POST' && parsed.pathname === '/files') { parsed.searchParams.get('path') === null ? true.should.eql(true) : false.should.eql(true); + parsed.searchParams.get('username').should.eql('user'); + should(parsed.searchParams.get('signature')).startWith('v1_'); + Number(parsed.searchParams.get('signature_expiration')).should.be.above(Math.floor(Date.now() / 1000)); req.body.should.containEql('/a.txt'); req.body.should.containEql('/b.txt'); res.statusCode = 200; @@ -1723,7 +1726,7 @@ describe('test sandbox module', function () { return sandbox.files.writeFiles([ { path: '/a.txt', data: 'a' }, { path: '/b.txt', data: 'b' } - ]); + ], { user: 'user' }); }) .then(entries => { entries.map(entry => entry.path).should.eql(['/a.txt', '/b.txt']); From ffb9674b0ddf39c10e138606c0510c3a8914fb29 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 20:57:24 +0800 Subject: [PATCH 21/48] fix(sandbox): align envd and template edge cases --- qiniu/sandbox/client.js | 11 +++++- qiniu/sandbox/commands.js | 20 ++++++---- qiniu/sandbox/envd.js | 12 ++++-- qiniu/sandbox/filesystem.js | 20 ++++++++-- qiniu/sandbox/sandbox.js | 9 +++-- qiniu/sandbox/template.js | 13 ++++-- test/sandbox.test.js | 79 +++++++++++++++++++++++++++++++++---- 7 files changed, 133 insertions(+), 31 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index bf57764..c48fdbc 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -224,7 +224,11 @@ SandboxClient.prototype.createSandbox = function (opts) { }; SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { - const ids = Array.isArray(sandboxIDs) ? sandboxIDs : (typeof sandboxIDs === 'string' ? [sandboxIDs] : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs)) || []); + const ids = Array.isArray(sandboxIDs) + ? sandboxIDs + : (typeof sandboxIDs === 'string' + ? [sandboxIDs] + : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs || (sandboxIDs.sandboxId || sandboxIDs.sandboxID ? [sandboxIDs.sandboxId || sandboxIDs.sandboxID] : null))) || []); return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); }; @@ -413,7 +417,10 @@ SandboxClient.prototype.waitForBuild = function (templateID, buildID, opts) { return info && (info.status === 'ready' || info.status === 'error'); }).then(info => { if (info && info.status === 'error') { - throw new TemplateBuildError(info.error || info.message || 'Sandbox template build failed', { info }); + const errorMessage = (info.error && typeof info.error === 'object' ? info.error.message : info.error) || + info.message || + 'Sandbox template build failed'; + throw new TemplateBuildError(errorMessage, { info }); } return info; }); diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index adeb8ca..1125383 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -164,6 +164,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { let settled = false; let handle; let responseBuffer = Buffer.alloc(0); + let responseOffset = 0; const jsonChunks = []; let isConnectStream = true; let result = { @@ -269,20 +270,23 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { jsonChunks.push(chunk); return; } - responseBuffer = Buffer.concat([responseBuffer, chunk]); - while (responseBuffer.length >= 5) { - const flags = responseBuffer[0]; - const length = responseBuffer.readUInt32BE(1); + responseBuffer = responseOffset < responseBuffer.length + ? Buffer.concat([responseBuffer.slice(responseOffset), chunk]) + : chunk; + responseOffset = 0; + while (responseBuffer.length - responseOffset >= 5) { + const flags = responseBuffer[responseOffset]; + const length = responseBuffer.readUInt32BE(responseOffset + 1); if (length > MAX_CONNECT_ENVELOPE_BYTES) { fail(new Error(`Sandbox envd stream envelope too large: ${length}`)); req.destroy(); return; } - if (responseBuffer.length < 5 + length) { + if (responseBuffer.length - responseOffset < 5 + length) { break; } - const payload = responseBuffer.slice(5, 5 + length).toString(); - responseBuffer = responseBuffer.slice(5 + length); + const payload = responseBuffer.toString('utf8', responseOffset + 5, responseOffset + 5 + length); + responseOffset += 5 + length; if (flags & 2) { try { const err = connectEndStreamError(payload); @@ -311,7 +315,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { }); res.on('error', fail); res.on('end', () => { - if (responseBuffer.length > 0) { + if (responseBuffer.length - responseOffset > 0) { fail(new Error('Sandbox envd stream truncated unexpectedly')); return; } diff --git a/qiniu/sandbox/envd.js b/qiniu/sandbox/envd.js index 175154c..ec2f37e 100644 --- a/qiniu/sandbox/envd.js +++ b/qiniu/sandbox/envd.js @@ -1,4 +1,4 @@ -const { basicAuth, millisecondsFromOptions, parseJSON, rawRequest } = require('./util'); +const { agentFromClient, basicAuth, millisecondsFromOptions, parseJSON, parseRequestUrl, rawRequest } = require('./util'); const MAX_CONNECT_ENVELOPE_BYTES = 10 * 1024 * 1024; @@ -28,11 +28,14 @@ function connectRPC (sandbox, procedure, body, opts) { headers['Keepalive-Ping-Interval'] = '50'; } - return rawRequest(sandbox.envdUrl() + procedure, { + const requestUrl = sandbox.envdUrl() + procedure; + const target = parseRequestUrl(requestUrl); + return rawRequest(requestUrl, { method: 'POST', content: JSON.stringify(body || {}), dataType: 'text', headers, + agent: agentFromClient(sandbox.client, target.protocol), timeout: opts.requestTimeoutMs !== undefined ? opts.requestTimeoutMs : millisecondsFromOptions(opts, 'timeout') @@ -114,11 +117,14 @@ function connectStreamRPC (sandbox, procedure, body, opts) { headers['Keepalive-Ping-Interval'] = '50'; } - return rawRequest(sandbox.envdUrl() + procedure, { + const requestUrl = sandbox.envdUrl() + procedure; + const target = parseRequestUrl(requestUrl); + return rawRequest(requestUrl, { method: 'POST', content: encodeConnectEnvelope(body), dataType: 'buffer', headers, + agent: agentFromClient(sandbox.client, target.protocol), timeout: opts.requestTimeoutMs !== undefined ? opts.requestTimeoutMs : millisecondsFromOptions(opts, 'timeout') diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index fb739c0..d6b7b91 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -173,16 +173,22 @@ function formatReadResult (data, opts) { return buffer.toString(); } +function envdAgent (sandbox, requestUrl) { + return agentFromClient(sandbox.client, parseRequestUrl(requestUrl).protocol); +} + Filesystem.prototype.read = function (path, opts) { opts = opts || {}; const headers = {}; if (opts.gzip) { headers['Accept-Encoding'] = 'gzip'; } - return rawRequest(this.sandbox.downloadUrl(path, opts), { + const requestUrl = this.sandbox.downloadUrl(path, opts); + return rawRequest(requestUrl, { method: 'GET', dataType: 'buffer', headers, + agent: envdAgent(this.sandbox, requestUrl), gzip: !!opts.gzip }).then(({ data }) => formatReadResult(data, opts)); }; @@ -209,10 +215,12 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { content = zlib.gzipSync(content); } - return rawRequest(this.sandbox.uploadUrl(path, opts), { + const requestUrl = this.sandbox.uploadUrl(path, opts); + return rawRequest(requestUrl, { method: 'POST', content, dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), headers }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); } @@ -231,10 +239,12 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { body = zlib.gzipSync(body); } - return rawRequest(this.sandbox.uploadUrl(path, opts), { + const requestUrl = this.sandbox.uploadUrl(path, opts); + return rawRequest(requestUrl, { method: 'POST', content: body, dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), headers }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); }; @@ -248,10 +258,12 @@ Filesystem.prototype.writeFiles = function (files, opts) { data: file.data })); - return rawRequest(this.sandbox.batchUploadUrl(opts), { + const requestUrl = this.sandbox.batchUploadUrl(opts); + return rawRequest(requestUrl, { method: 'POST', content: multipartBody(boundary, parts), dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` } diff --git a/qiniu/sandbox/sandbox.js b/qiniu/sandbox/sandbox.js index fa4e606..4e4b08f 100644 --- a/qiniu/sandbox/sandbox.js +++ b/qiniu/sandbox/sandbox.js @@ -4,7 +4,7 @@ const { Filesystem } = require('./filesystem'); const { Git } = require('./git'); const { Pty } = require('./pty'); const { SandboxClient } = require('./client'); -const { appendQuery, fileSignature, poll, rawRequest } = require('./util'); +const { agentFromClient, appendQuery, fileSignature, parseRequestUrl, poll, rawRequest } = require('./util'); function getSandboxID (data) { return data && (data.sandboxID || data.sandboxId || data.sandbox_id || data.id); @@ -289,9 +289,12 @@ Sandbox.prototype.waitForReady = function (opts) { }; Sandbox.prototype.isRunning = function () { - return rawRequest(this.envdUrl() + '/health', { + const requestUrl = this.envdUrl() + '/health'; + const target = parseRequestUrl(requestUrl); + return rawRequest(requestUrl, { method: 'GET', - dataType: 'text' + dataType: 'text', + agent: agentFromClient(this.client, target.protocol) }).then(() => true, err => { const resp = err.response || err.resp; if (resp && [502, 503, 504].indexOf(resp.statusCode) >= 0) { diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index cb36ddf..0502133 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -255,9 +255,14 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { dockerfileContentOrPath.indexOf('\n') < 0 && dockerfileContentOrPath.indexOf('\r') < 0 && fs.existsSync(dockerfileContentOrPath); - const content = isPath - ? fs.readFileSync(dockerfileContentOrPath, 'utf8') - : dockerfileContentOrPath; + let content = dockerfileContentOrPath; + if (isPath) { + try { + content = fs.readFileSync(dockerfileContentOrPath, 'utf8'); + } catch (err) { + throw new Error(`Failed to read Dockerfile at ${dockerfileContentOrPath}: ${err.message}`); + } + } joinDockerfileLines(content).forEach(line => { line = line.trim(); if (!line || line[0] === '#') { @@ -458,7 +463,7 @@ Template.prototype.bunInstall = function (packages, options) { if (options.dev) { args.push('--dev'); } - return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); + return this.runCmd(args.join(' ')); }; Template.prototype.gitClone = function (url, path, options) { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 0aa9a2a..7cd2816 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1,5 +1,6 @@ const should = require('should'); const http = require('http'); +const fs = require('fs'); const qiniu = require('../index'); @@ -166,11 +167,13 @@ describe('test sandbox module', function () { return client.listSandboxes() .then(() => client.getSandboxesMetrics()) .then(() => client.getSandboxesMetrics('sbx_one')) + .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) .then(() => { urls.should.eql([ 'http://sandbox.test/sandboxes', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one' + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object' ]); }); }); @@ -1415,6 +1418,7 @@ describe('test sandbox module', function () { .npmInstall('typescript', { dev: true }) .npmInstall('tsx', { g: true }) .bunInstall(['elysia'], { dev: true }) + .bunInstall(null, { g: true }) .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { branch: 'sandbox', @@ -1443,6 +1447,7 @@ describe('test sandbox module', function () { { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, { type: 'RUN', args: ['bun add --dev \'elysia\''] }, + { type: 'run', cmd: 'bun install' }, { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, { type: 'RUN', args: ['echo one && echo two', 'root'] } @@ -1512,6 +1517,21 @@ describe('test sandbox module', function () { template.buildConfig.steps[0].cmd.should.match(/^x+$/); }); + it('wraps Dockerfile path read errors with path context', function () { + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = function (path) { + if (path === __filename) { + throw new Error('permission denied'); + } + return originalReadFileSync.apply(this, arguments); + }; + try { + (() => qiniu.sandbox.Template().fromDockerfile(__filename)).should.throw(/Failed to read Dockerfile/); + } finally { + fs.readFileSync = originalReadFileSync; + } + }); + it('preserves octal permission strings in Template helpers', function () { const template = qiniu.sandbox.Template() .copy('app.js', '/app/', { mode: '755' }) @@ -2370,6 +2390,33 @@ describe('test sandbox module', function () { res.end(encodeConnectEnvelope({ event: { start: {} } })); return; } + if (req.url === '/filesystem.Filesystem/Stat') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + result: { + entry: { name: 'agent.txt', path: '/agent.txt', type: 'FILE_TYPE_FILE', size: 5 } + } + })); + return; + } + if (req.method === 'GET' && req.url.indexOf('/files') === 0) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end('agent'); + return; + } + if (req.method === 'POST' && req.url.indexOf('/files') === 0) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'agent.txt', path: '/agent.txt', type: 'file' }])); + return; + } + if (req.url === '/health') { + res.statusCode = 204; + res.end(); + return; + } res.statusCode = 404; res.end(); }).then(fixture => { @@ -2390,6 +2437,22 @@ describe('test sandbox module', function () { })) .then(handle => { handle._stopped.should.eql(true); + return sandbox.files.getInfo('/agent.txt'); + }) + .then(info => { + info.path.should.eql('/agent.txt'); + return sandbox.files.readText('/agent.txt'); + }) + .then(text => { + text.should.eql('agent'); + return sandbox.files.write('/agent.txt', 'agent'); + }) + .then(info => { + info.path.should.eql('/agent.txt'); + return sandbox.isRunning(); + }) + .then(running => { + running.should.eql(true); return sandbox.pty.create({ cols: 80, rows: 24 @@ -2397,11 +2460,13 @@ describe('test sandbox module', function () { }) .then(handle => handle.wait()) .then(() => { - requestsThroughAgent.should.eql([ - '/process.Process/Start', - '/filesystem.Filesystem/WatchDir', - '/process.Process/Start' - ]); + requestsThroughAgent[0].should.eql('/process.Process/Start'); + requestsThroughAgent[1].should.eql('/filesystem.Filesystem/WatchDir'); + requestsThroughAgent[2].should.eql('/filesystem.Filesystem/Stat'); + requestsThroughAgent[3].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); + requestsThroughAgent[4].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); + requestsThroughAgent[5].should.eql('/health'); + requestsThroughAgent[6].should.eql('/process.Process/Start'); }) .then(() => closeServer(fixture.server), err => { return closeServer(fixture.server).then(() => { @@ -3113,7 +3178,7 @@ describe('test sandbox module', function () { if (req.url === '/templates/tpl_1/builds/bld_1/status') { res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ status: 'error', error: 'compile failed' })); + res.end(JSON.stringify({ status: 'error', error: { message: 'compile failed' } })); return; } res.statusCode = 404; From 831e7a5bc082cb03938d61b5c4c03fdb8d148765 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:06:08 +0800 Subject: [PATCH 22/48] fix(sandbox): harden stream termination --- qiniu/sandbox/commands.js | 2 +- qiniu/sandbox/pty.js | 3 +- qiniu/sandbox/template.js | 3 +- qiniu/sandbox/util.js | 5 +- test/sandbox.test.js | 99 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 1125383..d27e57e 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -343,7 +343,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { return; } if (result.exitCode === -1) { - finish({ exitCode: 0 }); + fail(new Error('Command stream ended before process end')); } }); }); diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index ecab13c..19a491e 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -204,7 +204,8 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { return; } if (result.exitCode === -1) { - result.exitCode = 0; + fail(new Error('PTY stream ended before process end')); + return; } resolveWait(result); }); diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 0502133..d936999 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -150,7 +150,8 @@ function joinDockerfileLines (content) { lines.push(line); return; } - if (line.charAt(line.length - 1) === '\\') { + const trailingBackslashes = (line.match(/\\+$/) || [''])[0].length; + if (trailingBackslashes % 2 === 1) { current += line.slice(0, -1) + ' '; return; } diff --git a/qiniu/sandbox/util.js b/qiniu/sandbox/util.js index 71f461b..4f81eb0 100644 --- a/qiniu/sandbox/util.js +++ b/qiniu/sandbox/util.js @@ -79,7 +79,10 @@ function poll (fn, opts, done) { }, err => { const statusCode = (err.response && err.response.statusCode) || (err.resp && err.resp.statusCode); const fatalClientError = statusCode >= 400 && statusCode < 500 && statusCode !== 408 && statusCode !== 429; - if (fatalClientError || Date.now() - startedAt >= timeout) { + const programmingError = err instanceof TypeError || + err instanceof ReferenceError || + err instanceof SyntaxError; + if (fatalClientError || programmingError || Date.now() - startedAt >= timeout) { throw err; } return new Promise(resolve => setTimeout(resolve, interval)).then(tick); diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 7cd2816..1287fdf 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1566,6 +1566,15 @@ describe('test sandbox module', function () { ]); }); + it('does not continue Dockerfile lines with escaped trailing backslashes', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nRUN echo \\\\\nRUN echo next'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo \\\\' }, + { type: 'run', cmd: 'echo next' } + ]); + }); + it('does not merge Dockerfile comments or blank lines that end with backslash', function () { const template = qiniu.sandbox.Template() .fromDockerfile('FROM node:22\n# ignored comment \\\nRUN echo ok\n\nRUN echo next'); @@ -2365,6 +2374,40 @@ describe('test sandbox module', function () { }); }); + it('rejects command wait when the live Connect stream ends before process end', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: { pid: 84 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_missing_end', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(84); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('Command stream ended before process end'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('uses configured HTTP agents for live envd streams', function () { const agent = new http.Agent(); const requestsThroughAgent = []; @@ -2980,6 +3023,25 @@ describe('test sandbox module', function () { }); }); + it('does not retry programming errors while polling', function () { + let calls = 0; + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_programming_error', + info: {} + }); + sandbox.getInfo = function () { + calls += 1; + return Promise.reject(new TypeError('bad poll logic')); + }; + + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { + throw new Error('expected waitForReady to fail'); + }, err => { + err.message.should.eql('bad poll logic'); + calls.should.eql(1); + }); + }); + it('returns false for transient envd gateway health errors and rethrows others', function () { let calls = 0; return startServer((req, res) => { @@ -3648,6 +3710,43 @@ describe('test sandbox module', function () { }); }); + it('rejects PTY wait when the live Connect stream ends before process end', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: { pid: 47 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_missing_end', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(47); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('PTY stream ended before process end'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects oversized PTY stream envelopes', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { From 9d6d523d33a1a8c030d1863a512f1612c7fe9f84 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:10:26 +0800 Subject: [PATCH 23/48] fix(sandbox): preserve ids and clone auth --- qiniu/sandbox/client.js | 12 ++++++++---- qiniu/sandbox/git.js | 3 ++- test/sandbox.test.js | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index c48fdbc..5aca0d7 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -224,11 +224,15 @@ SandboxClient.prototype.createSandbox = function (opts) { }; SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { - const ids = Array.isArray(sandboxIDs) + const values = Array.isArray(sandboxIDs) ? sandboxIDs - : (typeof sandboxIDs === 'string' - ? [sandboxIDs] - : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs || (sandboxIDs.sandboxId || sandboxIDs.sandboxID ? [sandboxIDs.sandboxId || sandboxIDs.sandboxID] : null))) || []); + : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs)) || [sandboxIDs]; + const ids = values.map(value => { + if (typeof value === 'string') { + return value; + } + return value && (value.sandboxId || value.sandboxID || value.sandbox_id || value.id); + }).filter(Boolean); return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); }; diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index a9f78e6..2f0700c 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -167,7 +167,8 @@ Git.prototype._runGit = function (repoPath, args, opts) { Git.prototype.clone = function (repoUrl, pathOrOpts, maybeOpts) { const normalized = pathAndOptions(pathOrOpts, maybeOpts); const opts = gitCredentialOptions(normalized.opts); - const args = gitConfigArgs(opts).concat(credentialHelperArgs(opts)).concat(['clone', shellQuote(stripAuth(repoUrl))]); + const targetUrl = opts.username || opts.password ? stripAuth(repoUrl) : repoUrl; + const args = gitConfigArgs(opts).concat(credentialHelperArgs(opts)).concat(['clone', shellQuote(targetUrl)]); if (opts.depth) { args.push('--depth', shellQuote(opts.depth)); } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 1287fdf..0f28e57 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -168,12 +168,14 @@ describe('test sandbox module', function () { .then(() => client.getSandboxesMetrics()) .then(() => client.getSandboxesMetrics('sbx_one')) .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) + .then(() => client.getSandboxesMetrics([{ sandboxID: 'sbx_array_object' }, 'sbx_array_string'])) .then(() => { urls.should.eql([ 'http://sandbox.test/sandboxes', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object' + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_array_object%2Csbx_array_string' ]); }); }); @@ -2687,6 +2689,22 @@ describe('test sandbox module', function () { }); }); + it('keeps embedded git clone credentials when no helper credentials are provided', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://u:p@github.com/acme/private.git').then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('clone \'https://u:p@github.com/acme/private.git\''); + should.not.exist(commandsSeen[0].opts.envs); + }); + }); + it('passes http git clone credentials through a helper instead of the command line', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ From 0bff3b6fdaebf6d76f7e9792c6b3ac4f6a370a46 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:12:23 +0800 Subject: [PATCH 24/48] fix(sandbox): handle async watch exit errors --- qiniu/sandbox/filesystem.js | 2 +- test/sandbox.test.js | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index d6b7b91..8ff0ff2 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -123,7 +123,7 @@ WatchHandle.prototype._finish = function (err) { } this._stopped = true; if (this._onExit) { - this._onExit(err); + Promise.resolve(this._onExit(err)).catch(() => {}); } }; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 0f28e57..f72f192 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -2183,6 +2183,46 @@ describe('test sandbox module', function () { }); }); + it('handles rejected async watchDir onExit callbacks', function () { + let exitCalled = false; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: {} } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_async_exit', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: () => { + exitCalled = true; + return Promise.reject(new Error('async exit failed')); + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitCalled.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('fails watchDir when the live Connect stream ends with a partial frame after start', function () { let exitError; return startServer((req, res) => { From 7e14583ab453d27dd988650aae26b5d5409a2c99 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:16:45 +0800 Subject: [PATCH 25/48] fix(sandbox): guard file and copy edge cases --- qiniu/sandbox/commands.js | 2 +- qiniu/sandbox/filesystem.js | 3 +++ qiniu/sandbox/template.js | 15 ++++++----- test/sandbox.test.js | 50 ++++++++++++++++++++++++++----------- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index d27e57e..da40998 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -258,6 +258,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { } req.on('response', res => { + cleanupStartTimer(); if (res.statusCode < 200 || res.statusCode >= 300) { fail(new Error(`Sandbox envd request failed with status ${res.statusCode}`)); res.resume(); @@ -320,7 +321,6 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { return; } if (!isConnectStream) { - cleanupStartTimer(); let events; try { events = eventListFromResponse(parseJSON(Buffer.concat(jsonChunks))); diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 8ff0ff2..878f882 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -204,6 +204,9 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { const path = pathOrFiles; const opts = maybeOpts || {}; + if (dataOrOpts && typeof dataOrOpts === 'object' && !Buffer.isBuffer(dataOrOpts) && typeof dataOrOpts.pipe === 'function') { + return Promise.reject(new TypeError('Streams are not supported as data in filesystem.write')); + } const supportsEncodedUpload = versionGte(this.sandbox.envdVersion, '0.5.7'); if (opts.useOctetStream && supportsEncodedUpload) { const headers = { diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index d936999..f227d3f 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -330,12 +330,15 @@ Template.prototype.runCmd = function (cmd, options) { Template.prototype.copy = function (src, dest, options) { if (Array.isArray(src) || options) { asArray(src).forEach(item => { - const args = [ - item, - dest, - options && options.user ? options.user : '', - options && options.mode ? padOctal(options.mode) : '' - ]; + const args = [item, dest]; + if (options && options.user) { + args.push(options.user); + } else if (options && options.mode) { + args.push(''); + } + if (options && options.mode) { + args.push(padOctal(options.mode)); + } const extra = {}; if (options && options.forceUpload) { extra.forceUpload = options.forceUpload; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index f72f192..e718f64 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1,6 +1,7 @@ const should = require('should'); const http = require('http'); const fs = require('fs'); +const stream = require('stream'); const qiniu = require('../index'); @@ -641,6 +642,24 @@ describe('test sandbox module', function () { }); }); + it('rejects Readable streams passed directly to filesystem write', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_stream_write', + envdUrl: 'http://127.0.0.1:9', + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.write('/stream.txt', new stream.Readable({ + read: function () {} + })).then(() => { + throw new Error('expected stream write to reject'); + }, err => { + err.message.should.eql('Streams are not supported as data in filesystem.write'); + }); + }); + it('preserves falsy multipart payload values', function () { return startServer((req, res) => { const parsed = parseUrl(req.url); @@ -1436,8 +1455,8 @@ describe('test sandbox module', function () { const body = JSON.parse(fixture.requests[0].body); body.buildConfig.steps.should.eql([ { type: 'COPY', args: ['app.js', '/app/', 'root', '0755'] }, - { type: 'COPY', args: ['package.json', '/app/', '', ''] }, - { type: 'COPY', args: ['package-lock.json', '/app/', '', ''] }, + { type: 'COPY', args: ['package.json', '/app/'] }, + { type: 'COPY', args: ['package-lock.json', '/app/'] }, { type: 'RUN', args: ['rm -r -f \'/tmp/cache dir\' \'/tmp/old\'', 'root'] }, { type: 'RUN', args: ['mv -f \'/tmp/a file\' \'/tmp/b file\''] }, { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/data dir\' \'/app/logs\''] }, @@ -1501,7 +1520,7 @@ describe('test sandbox module', function () { { type: 'WORKDIR', args: ['/app'], force: true }, { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '3000'], force: true }, { type: 'RUN', args: ['npm ci'], force: true }, - { type: 'COPY', args: ['package.json', '/app/', '', ''], force: true }, + { type: 'COPY', args: ['package.json', '/app/'], force: true }, { type: 'USER', args: ['node'], force: true } ]); }).then(() => closeServer(fixture.server), err => { @@ -1564,7 +1583,7 @@ describe('test sandbox module', function () { { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] }, { type: 'ENV', args: ['PORT', '3000'] }, - { type: 'COPY', args: ['file name.txt', '/app/data dir/', '', ''] } + { type: 'COPY', args: ['file name.txt', '/app/data dir/'] } ]); }); @@ -1590,8 +1609,8 @@ describe('test sandbox module', function () { const template = qiniu.sandbox.Template() .fromDockerfile('FROM node:22\nCOPY ["file name.txt", "/app/data dir/"]\nCOPY --chown=node package.json /app/\nCOPY --from=builder /app/dist /app/dist'); template.buildConfig.steps.should.eql([ - { type: 'COPY', args: ['file name.txt', '/app/data dir/', '', ''] }, - { type: 'COPY', args: ['package.json', '/app/', '', ''] } + { type: 'COPY', args: ['file name.txt', '/app/data dir/'] }, + { type: 'COPY', args: ['package.json', '/app/'] } ]); }); @@ -3161,13 +3180,16 @@ describe('test sandbox module', function () { if (req.url === '/process.Process/Start') { res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - events: [ - { event: { start: { pid: 22 } } }, - { event: { data: { stdout: Buffer.from('ok').toString('base64') } } }, - { event: { end: { exitCode: 0 } } } - ] - })); + res.flushHeaders(); + setTimeout(() => { + res.end(JSON.stringify({ + events: [ + { event: { start: { pid: 22 } } }, + { event: { data: { stdout: Buffer.from('ok').toString('base64') } } }, + { event: { end: { exitCode: 0 } } } + ] + })); + }, 20); return; } if (req.url === '/sandboxes/sbx_pending') { @@ -3189,7 +3211,7 @@ describe('test sandbox module', function () { info: {} }); - return sandbox.commands.run('echo ok') + return sandbox.commands.run('echo ok', { timeoutMs: 5 }) .then(result => { result.stdout.should.eql('ok'); return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 5 }); From 33e3ca62b97c5e7a1ae454101250eda3a8bc3e2b Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:18:13 +0800 Subject: [PATCH 26/48] fix(sandbox): normalize pip install options --- qiniu/sandbox/template.js | 4 ++++ test/sandbox.test.js | 2 ++ 2 files changed, 6 insertions(+) diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index f227d3f..5b40a18 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -422,6 +422,10 @@ Template.prototype.setUser = function (user) { }; Template.prototype.pipInstall = function (packages, options) { + if (packages && typeof packages === 'object' && !Array.isArray(packages) && !Buffer.isBuffer(packages) && options === undefined) { + options = packages; + packages = undefined; + } options = options || {}; const args = ['pip', 'install']; if (options.g === false) { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index e718f64..1ecee67 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1436,6 +1436,7 @@ describe('test sandbox module', function () { .setUser('node') .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) .pipInstall(['numpy', 'pandas'], { g: false }) + .pipInstall({ g: false }) .npmInstall('typescript', { dev: true }) .npmInstall('tsx', { g: true }) .bunInstall(['elysia'], { dev: true }) @@ -1465,6 +1466,7 @@ describe('test sandbox module', function () { { type: 'USER', args: ['node'] }, { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '8080'] }, { type: 'RUN', args: ['pip install --user \'numpy\' \'pandas\''] }, + { type: 'RUN', args: ['pip install --user .'] }, { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, { type: 'RUN', args: ['bun add --dev \'elysia\''] }, From daff3fe9da2603f5e82d5847f191d59434e537f4 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:22:58 +0800 Subject: [PATCH 27/48] fix(sandbox): normalize install helpers --- qiniu/sandbox/client.js | 6 +++++- qiniu/sandbox/filesystem.js | 2 +- qiniu/sandbox/template.js | 18 +++++++++++++----- test/sandbox.test.js | 21 +++++++++++++++++---- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 5aca0d7..02af3ad 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -34,7 +34,11 @@ function normalizeSandboxCreateOptions (opts) { copyDefined(body, opts, 'envVars'); copyDefined(body, opts, 'envs', 'envVars'); copyDefined(body, opts, 'mcp'); - copyDefined(body, opts, 'injections'); + if (Array.isArray(opts.injections)) { + body.injections = opts.injections.map(normalizeInjection); + } else { + copyDefined(body, opts, 'injections'); + } copyDefined(body, opts, 'resources'); return body; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 878f882..ed81dae 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -212,7 +212,7 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { const headers = { 'Content-Type': 'application/octet-stream' }; - let content = Buffer.isBuffer(dataOrOpts) ? dataOrOpts : Buffer.from(String(dataOrOpts || '')); + let content = Buffer.isBuffer(dataOrOpts) ? dataOrOpts : Buffer.from(String(dataOrOpts !== undefined && dataOrOpts !== null ? dataOrOpts : '')); if (opts.gzip && supportsEncodedUpload) { headers['Content-Encoding'] = 'gzip'; content = zlib.gzipSync(content); diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 5b40a18..1632e97 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -440,6 +440,10 @@ Template.prototype.pipInstall = function (packages, options) { }; Template.prototype.npmInstall = function (packages, options) { + if (packages && typeof packages === 'object' && !Array.isArray(packages) && !Buffer.isBuffer(packages) && options === undefined) { + options = packages; + packages = undefined; + } options = options || {}; const args = ['npm', 'install']; if (options.g) { @@ -455,6 +459,10 @@ Template.prototype.npmInstall = function (packages, options) { }; Template.prototype.bunInstall = function (packages, options) { + if (packages && typeof packages === 'object' && !Array.isArray(packages) && !Buffer.isBuffer(packages) && options === undefined) { + options = packages; + packages = undefined; + } options = options || {}; if (packages) { const args = ['bun', 'add']; @@ -467,14 +475,14 @@ Template.prototype.bunInstall = function (packages, options) { args.push.apply(args, asArray(packages).map(shellQuote)); return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); } - const args = ['bun', 'install']; - if (options.dev) { - args.push('--dev'); - } - return this.runCmd(args.join(' ')); + return this.runCmd('bun install'); }; Template.prototype.gitClone = function (url, path, options) { + if (path && typeof path === 'object' && !Array.isArray(path) && !Buffer.isBuffer(path) && options === undefined) { + options = path; + path = undefined; + } options = options || {}; const args = ['git', 'clone', shellQuote(url)]; if (options.branch) { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 1ecee67..9742a95 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -2,6 +2,7 @@ const should = require('should'); const http = require('http'); const fs = require('fs'); const stream = require('stream'); +const zlib = require('zlib'); const qiniu = require('../index'); @@ -114,7 +115,9 @@ describe('test sandbox module', function () { injections: [ { type: 'qiniu', - key: 'value' + apiKey: 'ak', + baseUrl: 'https://example.com', + ruleId: 'rule_1' } ] }).then(ret => { @@ -137,7 +140,9 @@ describe('test sandbox module', function () { injections: [ { type: 'qiniu', - key: 'value' + api_key: 'ak', + base_url: 'https://example.com', + ruleID: 'rule_1' } ] }); @@ -1439,14 +1444,19 @@ describe('test sandbox module', function () { .pipInstall({ g: false }) .npmInstall('typescript', { dev: true }) .npmInstall('tsx', { g: true }) + .npmInstall({ dev: true }) .bunInstall(['elysia'], { dev: true }) - .bunInstall(null, { g: true }) + .bunInstall({ dev: true }) .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { branch: 'sandbox', depth: 1, user: 'root' }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', { + branch: 'sandbox', + depth: 1 + }) .runCmd(['echo one', 'echo two'], { user: 'root' }) .build({ apiKey: 'sandbox-key', @@ -1469,10 +1479,12 @@ describe('test sandbox module', function () { { type: 'RUN', args: ['pip install --user .'] }, { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, + { type: 'RUN', args: ['npm install --save-dev'] }, { type: 'RUN', args: ['bun add --dev \'elysia\''] }, { type: 'run', cmd: 'bun install' }, { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, + { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\''] }, { type: 'RUN', args: ['echo one && echo two', 'root'] } ]); }).then(() => closeServer(fixture.server), err => { @@ -3960,6 +3972,7 @@ describe('test sandbox module', function () { req.headers['content-type'].should.eql('application/octet-stream'); req.headers['content-encoding'].should.eql('gzip'); parsed.searchParams.get('path').should.eql('/zip.txt'); + zlib.gunzipSync(req.rawBody).toString().should.eql('0'); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); @@ -3980,7 +3993,7 @@ describe('test sandbox module', function () { return sandbox.files.read('/zip.txt', { gzip: true }) .then(text => { text.should.eql('zip'); - return sandbox.files.write('/zip.txt', 'zip', { + return sandbox.files.write('/zip.txt', 0, { gzip: true, useOctetStream: true }); From dd40947e9a924315be88bf09e7b012db71fbfd89 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:27:35 +0800 Subject: [PATCH 28/48] fix(sandbox): clear stale auth state --- qiniu/sandbox/commands.js | 4 ++- qiniu/sandbox/git.js | 2 +- qiniu/sandbox/template.js | 2 ++ test/sandbox.test.js | 51 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index da40998..5ef8ce3 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -258,7 +258,6 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { } req.on('response', res => { - cleanupStartTimer(); if (res.statusCode < 200 || res.statusCode >= 300) { fail(new Error(`Sandbox envd request failed with status ${res.statusCode}`)); res.resume(); @@ -266,6 +265,9 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { } const contentType = (res.headers && res.headers['content-type']) || ''; isConnectStream = contentType.indexOf('application/connect+json') >= 0; + if (!isConnectStream) { + cleanupStartTimer(); + } res.on('data', chunk => { if (!isConnectStream) { jsonChunks.push(chunk); diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 2f0700c..08f1c29 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -33,7 +33,7 @@ function authUrl (repoUrl, opts) { } function stripAuth (repoUrl) { - return String(repoUrl || '').replace(/^(https?):\/\/[^/@]+:[^/@]+@/, '$1://'); + return String(repoUrl || '').replace(/^(https?):\/\/[^/@]+@/, '$1://'); } function credentialHelperArgs (opts) { diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 1632e97..4cc5138 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -21,6 +21,8 @@ Template.prototype.fromImage = function (image, credentials) { username: credentials.username, password: credentials.password }; + } else { + delete this.buildConfig.fromImageRegistry; } if (this._forceNextLayer) { this.buildConfig.force = true; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 9742a95..95c9b91 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1545,6 +1545,34 @@ describe('test sandbox module', function () { }); }); + it('clears stale registry credentials when switching to a public image', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_public', buildID: 'bld_public' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromImage('registry.example.com/private/app:latest', { + username: 'alice', + password: 'secret' + }) + .fromImage('node:22') + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'public-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromImage.should.eql('node:22'); + should.not.exist(body.buildConfig.fromImageRegistry); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('treats long Dockerfile text as content instead of probing it as a path', function () { const content = 'FROM node:22\nRUN ' + new Array(1200).join('x'); const template = qiniu.sandbox.Template().fromDockerfile(content); @@ -2340,6 +2368,7 @@ describe('test sandbox module', function () { commandResponse = res; res.statusCode = 200; res.setHeader('Content-Type', 'application/connect+json'); + res.flushHeaders(); return; } res.statusCode = 404; @@ -2872,6 +2901,28 @@ describe('test sandbox module', function () { }); }); + it('replaces token-only git remote credentials when dangerously authenticating', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://old-token@example.com/acme/repo.git\n', + stderr: '', + exitCode: 0 + }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { + commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); + commandsSeen[1].cmd.should.not.containEql('old-token@'); + }); + }); + it('keeps original git push error without credential cleanup', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ From d873b324b37e8fa01f0ee889863d1833e39964ca Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:30:20 +0800 Subject: [PATCH 29/48] fix(sandbox): harden file and git helpers --- qiniu/sandbox/filesystem.js | 66 ++++++++++++++++++++----------------- qiniu/sandbox/git.js | 14 +++++--- test/sandbox.test.js | 28 +++++++++++++--- 3 files changed, 68 insertions(+), 40 deletions(-) diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index ed81dae..c1d5282 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -2,9 +2,11 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYT const { SandboxError } = require('./errors'); const { agentFromClient, millisecondsFromOptions, parseRequestUrl, rawRequest } = require('./util'); const { Readable } = require('stream'); +const { promisify } = require('util'); const zlib = require('zlib'); const http = require('http'); const https = require('https'); +const gzip = promisify(zlib.gzip); const FileType = { FILE: 'file', @@ -161,11 +163,7 @@ function formatReadResult (data, opts) { return buffer; } if (format === 'stream') { - const stream = new Readable(); - stream._read = function () {}; - stream.push(buffer); - stream.push(null); - return stream; + return Readable.from([buffer]); } if (format === 'blob') { return typeof global.Blob !== 'undefined' ? new global.Blob([buffer]) : buffer; @@ -212,24 +210,28 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { const headers = { 'Content-Type': 'application/octet-stream' }; - let content = Buffer.isBuffer(dataOrOpts) ? dataOrOpts : Buffer.from(String(dataOrOpts !== undefined && dataOrOpts !== null ? dataOrOpts : '')); - if (opts.gzip && supportsEncodedUpload) { - headers['Content-Encoding'] = 'gzip'; - content = zlib.gzipSync(content); - } - - const requestUrl = this.sandbox.uploadUrl(path, opts); - return rawRequest(requestUrl, { - method: 'POST', - content, - dataType: 'json', - agent: envdAgent(this.sandbox, requestUrl), - headers + const content = Buffer.isBuffer(dataOrOpts) ? dataOrOpts : Buffer.from(String(dataOrOpts !== undefined && dataOrOpts !== null ? dataOrOpts : '')); + const compressed = opts.gzip && supportsEncodedUpload + ? gzip(content).then(result => { + headers['Content-Encoding'] = 'gzip'; + return result; + }) + : Promise.resolve(content); + + return compressed.then(content => { + const requestUrl = this.sandbox.uploadUrl(path, opts); + return rawRequest(requestUrl, { + method: 'POST', + content, + dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), + headers + }); }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); } const boundary = `qiniu-sandbox-${Date.now()}-${Math.random().toString(16).slice(2)}`; - let body = multipartBody(boundary, [{ + const body = multipartBody(boundary, [{ field: 'file', filename: path, data: dataOrOpts @@ -237,18 +239,22 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { const headers = { 'Content-Type': `multipart/form-data; boundary=${boundary}` }; - if (opts.gzip && supportsEncodedUpload) { - headers['Content-Encoding'] = 'gzip'; - body = zlib.gzipSync(body); - } + const compressed = opts.gzip && supportsEncodedUpload + ? gzip(body).then(result => { + headers['Content-Encoding'] = 'gzip'; + return result; + }) + : Promise.resolve(body); - const requestUrl = this.sandbox.uploadUrl(path, opts); - return rawRequest(requestUrl, { - method: 'POST', - content: body, - dataType: 'json', - agent: envdAgent(this.sandbox, requestUrl), - headers + return compressed.then(body => { + const requestUrl = this.sandbox.uploadUrl(path, opts); + return rawRequest(requestUrl, { + method: 'POST', + content: body, + dataType: 'json', + agent: envdAgent(this.sandbox, requestUrl), + headers + }); }).then(({ data }) => Array.isArray(data) ? normalizeEntry(data[0]) : normalizeEntry(data)); }; diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 08f1c29..2de6879 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -323,8 +323,13 @@ Git.prototype.branches = function (repoPath, opts) { Git.prototype.reset = function (repoPath, opts) { opts = opts || {}; const args = ['reset']; + const rawPaths = opts.paths || opts.files || []; + const paths = Array.isArray(rawPaths) ? rawPaths : [rawPaths]; const mode = opts.mode || (opts.hard ? 'hard' : null) || (opts.soft ? 'soft' : null) || (opts.mixed ? 'mixed' : null); if (mode) { + if (paths.length) { + throw new InvalidArgumentError('Git reset mode cannot be used when paths are specified'); + } if (['soft', 'mixed', 'hard', 'merge', 'keep'].indexOf(mode) === -1) { throw new InvalidArgumentError(`Invalid git reset mode: ${mode}`); } @@ -334,8 +339,6 @@ Git.prototype.reset = function (repoPath, opts) { if (target) { args.push(shellQuote(target)); } - const rawPaths = opts.paths || opts.files || []; - const paths = Array.isArray(rawPaths) ? rawPaths : [rawPaths]; if (paths.length) { args.push('--'); paths.forEach(path => args.push(shellQuote(path))); @@ -364,10 +367,11 @@ Git.prototype.restore = function (repoPath, opts) { } const rawPaths = opts.paths || opts.files || []; const paths = Array.isArray(rawPaths) ? rawPaths : [rawPaths]; - if (paths.length) { - args.push('--'); - paths.forEach(path => args.push(shellQuote(path))); + if (!paths.length) { + throw new InvalidArgumentError('At least one path must be specified for git restore'); } + args.push('--'); + paths.forEach(path => args.push(shellQuote(path))); return this._runGit(repoPath, args, opts); }; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 95c9b91..8898799 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -2658,7 +2658,7 @@ describe('test sandbox module', function () { return git.reset('/repo', { hard: true, ref: 'HEAD~1' }); }) .then(() => git.restore('/repo', { staged: true, paths: ['a.txt'] })) - .then(() => git.reset('/repo', { hard: true, ref: 'HEAD', paths: 'single.txt' })) + .then(() => git.reset('/repo', { ref: 'HEAD', paths: 'single.txt' })) .then(() => git.restore('/repo', { files: 'restore.txt' })) .then(() => git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git', { overwrite: true, fetch: true })) .then(() => git.commit('/repo', 'msg', { @@ -2679,7 +2679,7 @@ describe('test sandbox module', function () { commandText.should.containEql('branch \'--format=%(HEAD) %(refname:short)\''); commandText.should.containEql('reset --hard \'HEAD~1\''); commandText.should.containEql('restore --staged -- \'a.txt\''); - commandText.should.containEql('reset --hard \'HEAD\' -- \'single.txt\''); + commandText.should.containEql('reset \'HEAD\' -- \'single.txt\''); commandText.should.containEql('restore --worktree -- \'restore.txt\''); commandText.should.containEql('remote remove \'origin\''); commandText.should.containEql('remote add \'origin\''); @@ -2847,6 +2847,25 @@ describe('test sandbox module', function () { err.name.should.eql('InvalidArgumentError'); err.message.should.match(/Invalid git reset mode/); } + + try { + git.reset('/repo', { + mode: 'hard', + paths: ['a.txt'] + }); + throw new Error('expected git reset to reject mode with paths'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/mode cannot be used when paths are specified/); + } + + try { + git.restore('/repo'); + throw new Error('expected git restore to reject missing paths'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/At least one path/); + } }); it('surfaces git auth and validation errors on auth helpers', function () { @@ -4219,8 +4238,7 @@ describe('test sandbox module', function () { }); }).then(() => git.reset('/repo', { mode: 'hard', - target: 'HEAD~1', - paths: ['a.txt'] + target: 'HEAD~1' })).then(() => git.restore('/repo', { paths: ['a.txt'] })).then(() => { @@ -4229,7 +4247,7 @@ describe('test sandbox module', function () { 'git config --local --get \'user.name\'', 'git config --local \'user.name\' \'Alice\'', 'git config --local \'user.email\' \'alice@example.com\'', - 'git reset --hard \'HEAD~1\' -- \'a.txt\'', + 'git reset --hard \'HEAD~1\'', 'git restore --worktree -- \'a.txt\'' ]); commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); From ed307ca6c7a37045cde8c941af8ed037179b1f45 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:31:55 +0800 Subject: [PATCH 30/48] fix(sandbox): align request and url types --- index.d.ts | 2 +- qiniu/sandbox/client.js | 2 +- test/sandbox.test.js | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/index.d.ts b/index.d.ts index 39ed7ba..a2cd37b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -468,7 +468,7 @@ export declare namespace sandbox { DownloadURL(path: string, options?: FileUrlOptions): string; uploadUrl(path: string, options?: FileUrlOptions): string; UploadURL(path: string, options?: FileUrlOptions): string; - batchUploadUrl(user?: string): string; + batchUploadUrl(options?: string | FileUrlOptions): string; } } diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 02af3ad..3d2a81f 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -183,7 +183,7 @@ SandboxClient.prototype._request = function (method, path, options) { if (hasBody) { urllibOptions.content = JSON.stringify(body); urllibOptions.contentType = 'application/json'; - } else { + } else if (method !== 'GET' && method !== 'HEAD') { urllibOptions.contentType = urllibOptions.headers['Content-Type']; urllibOptions.headers['Content-Length'] = '0'; } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 8898799..18a4675 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -164,6 +164,8 @@ describe('test sandbox module', function () { client.httpClient.sendRequest = req => { urls.push(req.url); req.urllibOptions.timeout.should.eql(1234); + should.not.exist(req.urllibOptions.headers['Content-Length']); + should.not.exist(req.urllibOptions.contentType); return Promise.resolve({ ok: () => true, data: { ok: true } From a8ffc898ee8522a7c1e237ac6c56bb3bceda9fba Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:35:05 +0800 Subject: [PATCH 31/48] fix(sandbox): preserve copy boolean flags --- qiniu/sandbox/filesystem.js | 27 ++++++++++++++++++++++----- qiniu/sandbox/template.js | 9 ++++++++- test/sandbox.test.js | 5 +++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index c1d5282..7a93fef 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -2,11 +2,9 @@ const { connectEndStreamError, connectRPC, envdHeaders, MAX_CONNECT_ENVELOPE_BYT const { SandboxError } = require('./errors'); const { agentFromClient, millisecondsFromOptions, parseRequestUrl, rawRequest } = require('./util'); const { Readable } = require('stream'); -const { promisify } = require('util'); const zlib = require('zlib'); const http = require('http'); const https = require('https'); -const gzip = promisify(zlib.gzip); const FileType = { FILE: 'file', @@ -163,7 +161,14 @@ function formatReadResult (data, opts) { return buffer; } if (format === 'stream') { - return Readable.from([buffer]); + if (typeof Readable.from === 'function') { + return Readable.from([buffer]); + } + const stream = new Readable(); + stream._read = function () {}; + stream.push(buffer); + stream.push(null); + return stream; } if (format === 'blob') { return typeof global.Blob !== 'undefined' ? new global.Blob([buffer]) : buffer; @@ -175,6 +180,18 @@ function envdAgent (sandbox, requestUrl) { return agentFromClient(sandbox.client, parseRequestUrl(requestUrl).protocol); } +function gzipAsync (content) { + return new Promise((resolve, reject) => { + zlib.gzip(content, (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result); + }); + }); +} + Filesystem.prototype.read = function (path, opts) { opts = opts || {}; const headers = {}; @@ -212,7 +229,7 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { }; const content = Buffer.isBuffer(dataOrOpts) ? dataOrOpts : Buffer.from(String(dataOrOpts !== undefined && dataOrOpts !== null ? dataOrOpts : '')); const compressed = opts.gzip && supportsEncodedUpload - ? gzip(content).then(result => { + ? gzipAsync(content).then(result => { headers['Content-Encoding'] = 'gzip'; return result; }) @@ -240,7 +257,7 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { 'Content-Type': `multipart/form-data; boundary=${boundary}` }; const compressed = opts.gzip && supportsEncodedUpload - ? gzip(body).then(result => { + ? gzipAsync(body).then(result => { headers['Content-Encoding'] = 'gzip'; return result; }) diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 4cc5138..4d7eef1 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -225,12 +225,19 @@ function splitDockerfileArgs (value) { function dockerfileCopyArgs (value) { const args = splitDockerfileArgs(value); + const flagsWithValue = { + '--checksum': true, + '--chmod': true, + '--chown': true, + '--exclude': true + }; while (args.length && /^--/.test(args[0])) { const flag = args.shift(); if (/^--from(?:=|$)/i.test(flag)) { return []; } - if (flag.indexOf('=') < 0 && args.length > 2) { + const flagName = flag.split('=')[0].toLowerCase(); + if (flag.indexOf('=') < 0 && flagsWithValue[flagName] && args.length > 2) { args.shift(); } } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 18a4675..52c8940 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1651,10 +1651,11 @@ describe('test sandbox module', function () { it('parses JSON Dockerfile COPY args and skips unsupported COPY --from flags', function () { const template = qiniu.sandbox.Template() - .fromDockerfile('FROM node:22\nCOPY ["file name.txt", "/app/data dir/"]\nCOPY --chown=node package.json /app/\nCOPY --from=builder /app/dist /app/dist'); + .fromDockerfile('FROM node:22\nCOPY ["file name.txt", "/app/data dir/"]\nCOPY --chown=node package.json /app/\nCOPY --link linked.txt /linked/\nCOPY --from=builder /app/dist /app/dist'); template.buildConfig.steps.should.eql([ { type: 'COPY', args: ['file name.txt', '/app/data dir/'] }, - { type: 'COPY', args: ['package.json', '/app/'] } + { type: 'COPY', args: ['package.json', '/app/'] }, + { type: 'COPY', args: ['linked.txt', '/linked/'] } ]); }); From b201e544ccb9cc09f66c39acbad9a86a9e36d922 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:37:12 +0800 Subject: [PATCH 32/48] fix(sandbox): validate helper inputs --- qiniu/sandbox/client.js | 3 +++ qiniu/sandbox/pty.js | 2 +- qiniu/sandbox/template.js | 15 ++++++++++----- test/sandbox.test.js | 18 +++++++++++++----- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 3d2a81f..a6d5f80 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -237,6 +237,9 @@ SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { } return value && (value.sandboxId || value.sandboxID || value.sandbox_id || value.id); }).filter(Boolean); + if (!ids.length) { + return Promise.reject(new TypeError('No valid sandbox IDs found')); + } return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); }; diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 19a491e..51da046 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -295,7 +295,7 @@ Pty.prototype.sendInput = function (pid, data, opts) { selector: { pid } }, input: { - pty: Buffer.isBuffer(data) ? data.toString('base64') : Buffer.from(data).toString('base64') + pty: Buffer.isBuffer(data) ? data.toString('base64') : Buffer.from(String(data)).toString('base64') } }, opts).then(() => null); }; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 4d7eef1..3363a44 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -260,11 +260,16 @@ function dockerfileFromImage (value) { } Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { - const isPath = typeof dockerfileContentOrPath === 'string' && - dockerfileContentOrPath.length < 1024 && - dockerfileContentOrPath.indexOf('\n') < 0 && - dockerfileContentOrPath.indexOf('\r') < 0 && - fs.existsSync(dockerfileContentOrPath); + if (typeof dockerfileContentOrPath !== 'string') { + throw new TypeError('Dockerfile content or path must be a string'); + } + const hasNewlines = dockerfileContentOrPath.indexOf('\n') >= 0 || dockerfileContentOrPath.indexOf('\r') >= 0; + const startsWithInstruction = /^\s*(ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|ONBUILD|RUN|SHELL|STOPSIGNAL|USER|VOLUME|WORKDIR)\b/i.test(dockerfileContentOrPath); + const isLikelyPath = dockerfileContentOrPath.length < 1024 && !hasNewlines && !startsWithInstruction; + if (isLikelyPath && !fs.existsSync(dockerfileContentOrPath)) { + throw new Error(`Dockerfile file not found at path: ${dockerfileContentOrPath}`); + } + const isPath = isLikelyPath && fs.existsSync(dockerfileContentOrPath); let content = dockerfileContentOrPath; if (isPath) { try { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 52c8940..55c5b59 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -154,7 +154,7 @@ describe('test sandbox module', function () { }); }); - it('passes client timeout to urllib requests and handles empty metrics ids', function () { + it('passes client timeout to urllib requests and validates metrics ids', function () { const client = new qiniu.sandbox.SandboxClient({ endpoint: 'http://sandbox.test', apiKey: 'sandbox-key', @@ -173,14 +173,18 @@ describe('test sandbox module', function () { }; return client.listSandboxes() - .then(() => client.getSandboxesMetrics()) + .then(() => client.getSandboxesMetrics().then(() => { + throw new Error('expected empty metrics ids to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.match(/No valid sandbox IDs/); + })) .then(() => client.getSandboxesMetrics('sbx_one')) .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) .then(() => client.getSandboxesMetrics([{ sandboxID: 'sbx_array_object' }, 'sbx_array_string'])) .then(() => { urls.should.eql([ 'http://sandbox.test/sandboxes', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_array_object%2Csbx_array_string' @@ -1597,6 +1601,10 @@ describe('test sandbox module', function () { } }); + it('throws when Dockerfile path does not exist', function () { + (() => qiniu.sandbox.Template().fromDockerfile('/tmp/missing-qiniu-sdk-Dockerfile')).should.throw(/Dockerfile file not found/); + }); + it('preserves octal permission strings in Template helpers', function () { const template = qiniu.sandbox.Template() .copy('app.js', '/app/', { mode: '755' }) @@ -3742,14 +3750,14 @@ describe('test sandbox module', function () { }).then(() => sandbox.pty.connect(44, { onData: chunk => data.push(Buffer.from(chunk).toString()) })).then(handle => handle.wait()) - .then(() => sandbox.pty.sendInput(44, Buffer.from('ls\n'))) + .then(() => sandbox.pty.sendInput(44, 123)) .then(() => sandbox.pty.resize(44, { cols: 100, rows: 30 })) .then(() => sandbox.pty.kill(44)) .then(killed => { killed.should.eql(true); data.should.eql(['ok', 'ok']); const sendBody = JSON.parse(fixture.requests[3].body); - sendBody.input.pty.should.eql(Buffer.from('ls\n').toString('base64')); + sendBody.input.pty.should.eql(Buffer.from('123').toString('base64')); const resizeBody = JSON.parse(fixture.requests[4].body); resizeBody.pty.size.should.eql({ cols: 100, rows: 30 }); const killBody = JSON.parse(fixture.requests[5].body); From a57ba44924c4321d2fdd7bf349f576c4e011ef4c Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:40:52 +0800 Subject: [PATCH 33/48] fix(sandbox): harden helper validation --- qiniu/sandbox/client.js | 8 +++++- qiniu/sandbox/git.js | 4 +-- qiniu/sandbox/template.js | 6 +++-- test/sandbox.test.js | 57 +++++++++++++++++++++++++++++++++++---- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index a6d5f80..1ea005e 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -97,6 +97,9 @@ function normalizeSandboxListOptions (opts) { function normalizeClientOptions (opts) { opts = opts || {}; + if ((opts.accessKey && !opts.secretKey) || (!opts.accessKey && opts.secretKey)) { + throw new SandboxError('Both accessKey and secretKey must be provided'); + } const mac = opts.mac || (opts.accessKey || opts.secretKey ? new digest.Mac(opts.accessKey, opts.secretKey, opts.macOptions) : null); @@ -238,7 +241,7 @@ SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { return value && (value.sandboxId || value.sandboxID || value.sandbox_id || value.id); }).filter(Boolean); if (!ids.length) { - return Promise.reject(new TypeError('No valid sandbox IDs found')); + return Promise.reject(new SandboxError('At least one sandbox ID must be provided')); } return this._request('GET', appendQuery('/sandboxes/metrics', { sandbox_ids: ids })); }; @@ -252,6 +255,9 @@ SandboxClient.prototype.getSandbox = function (sandboxID) { }; SandboxClient.prototype.deleteSandbox = function (sandboxID) { + if (!sandboxID) { + return Promise.reject(new SandboxError('sandboxID is required')); + } return this._request('DELETE', `/sandboxes/${encodePath(sandboxID)}`, { empty: true }); }; diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 2de6879..607c820 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -259,7 +259,7 @@ Git.prototype.deleteBranch = function (repoPath, branch, opts) { Git.prototype.remoteAdd = function (repoPath, name, repoUrl, opts) { opts = opts || {}; const add = () => this._runGit(repoPath, ['remote', 'add', shellQuote(name), shellQuote(repoUrl)], opts); - const afterAdd = result => opts.fetch ? this._runGit(repoPath, ['fetch', shellQuote(name)], opts) : result; + const afterAdd = result => opts.fetch && (!result || result.exitCode === 0) ? this._runGit(repoPath, ['fetch', shellQuote(name)], opts) : result; if (opts.overwrite) { return this._runGit(repoPath, ['remote', 'remove', shellQuote(name)], opts) .then(add, add) @@ -297,7 +297,7 @@ Git.prototype.getConfig = function () { } args.push('--get', shellQuote(normalized.key)); return this._runGit(normalized.repoPath, args, opts) - .then(result => result.stdout.trim()); + .then(result => result.exitCode ? undefined : result.stdout.trim()); }; Git.prototype.configureUser = function () { diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 3363a44..a378ad0 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -445,7 +445,8 @@ Template.prototype.pipInstall = function (packages, options) { if (options.g === false) { args.push('--user'); } - if (packages) { + const hasPackages = packages && (!Array.isArray(packages) || packages.length > 0); + if (hasPackages) { args.push.apply(args, asArray(packages).map(shellQuote)); } else { args.push('.'); @@ -478,7 +479,8 @@ Template.prototype.bunInstall = function (packages, options) { packages = undefined; } options = options || {}; - if (packages) { + const hasPackages = packages && (!Array.isArray(packages) || packages.length > 0); + if (hasPackages) { const args = ['bun', 'add']; if (options.g) { args.push('-g'); diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 55c5b59..90118bc 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -176,8 +176,8 @@ describe('test sandbox module', function () { .then(() => client.getSandboxesMetrics().then(() => { throw new Error('expected empty metrics ids to fail'); }, err => { - err.name.should.eql('TypeError'); - err.message.should.match(/No valid sandbox IDs/); + err.name.should.eql('SandboxError'); + err.message.should.match(/At least one sandbox ID/); })) .then(() => client.getSandboxesMetrics('sbx_one')) .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) @@ -205,6 +205,12 @@ describe('test sandbox module', function () { err.name.should.eql('SandboxError'); err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); } + + (() => new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + accessKey: 'ak' + })).should.throw(/Both accessKey and secretKey/); }); it('requires Qiniu AK/SK before creating sandboxes with Kodo resources', function () { @@ -1167,12 +1173,17 @@ describe('test sandbox module', function () { endpoint: fixture.endpoint }); - return client.list({ + return client.deleteSandbox().then(() => { + throw new Error('expected missing sandboxID to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/sandboxID is required/); + }).then(() => client.list({ metadata: 'user=alice', state: ['running', 'paused'], limit: 10, nextToken: 'n1' - }).then(() => client.getInfo('sbx_1')) + })).then(() => client.getInfo('sbx_1')) .then(() => client.connect('sbx_1', { timeoutMs: 20000 })) .then(() => client.setTimeout('sbx_1', { timeoutMs: 30000 })) .then(() => client.refreshSandbox('sbx_1', { duration: 60 })) @@ -1448,11 +1459,13 @@ describe('test sandbox module', function () { .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) .pipInstall(['numpy', 'pandas'], { g: false }) .pipInstall({ g: false }) + .pipInstall([], { g: false }) .npmInstall('typescript', { dev: true }) .npmInstall('tsx', { g: true }) .npmInstall({ dev: true }) .bunInstall(['elysia'], { dev: true }) .bunInstall({ dev: true }) + .bunInstall([]) .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { branch: 'sandbox', @@ -1483,11 +1496,13 @@ describe('test sandbox module', function () { { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '8080'] }, { type: 'RUN', args: ['pip install --user \'numpy\' \'pandas\''] }, { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['pip install --user .'] }, { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, { type: 'RUN', args: ['npm install --save-dev'] }, { type: 'RUN', args: ['bun add --dev \'elysia\''] }, { type: 'run', cmd: 'bun install' }, + { type: 'run', cmd: 'bun install' }, { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\''] }, @@ -2720,6 +2735,26 @@ describe('test sandbox module', function () { }); }); + it('does not fetch after git remote add fails', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd) { + commandsSeen.push(cmd); + if (cmd.indexOf(' remote add ') >= 0) { + return Promise.resolve({ stdout: '', stderr: 'bad remote', exitCode: 1 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.remoteAdd('/repo', 'origin', 'bad-url', { fetch: true }).then(result => { + result.exitCode.should.eql(1); + commandsSeen.should.eql([ + 'git remote add \'origin\' \'bad-url\'' + ]); + }); + }); + it('passes git push credentials through a helper when push fails', function () { const commandsSeen = []; const git = new qiniu.sandbox.Git({ @@ -2983,9 +3018,13 @@ describe('test sandbox module', function () { it('normalizes git config helpers when options are omitted', function () { const commandsSeen = []; + let missingConfig = false; const git = new qiniu.sandbox.Git({ run: function (cmd, opts) { commandsSeen.push({ cmd, opts }); + if (missingConfig && cmd.indexOf(' config --get ') >= 0) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); + } return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); } }); @@ -2996,13 +3035,21 @@ describe('test sandbox module', function () { value.should.eql('Alice'); return git.configureUser('Alice', 'alice@example.com'); }) + .then(() => { + missingConfig = true; + return git.getConfig('missing.name'); + }) + .then(value => { + should.not.exist(value); + }) .then(() => { const shellQuote = require('../qiniu/sandbox/util').shellQuote; commandsSeen.map(item => item.cmd).should.eql([ 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), 'git config --get ' + shellQuote('user.name'), 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), - 'git config ' + shellQuote('user.email') + ' ' + shellQuote('alice@example.com') + 'git config ' + shellQuote('user.email') + ' ' + shellQuote('alice@example.com'), + 'git config --get ' + shellQuote('missing.name') ]); commandsSeen.forEach(item => { should.not.exist(item.opts.cwd); From de5805f35c7f37a82f485327a05ff29707a6f1a0 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:43:36 +0800 Subject: [PATCH 34/48] fix(sandbox): normalize async validation --- qiniu/sandbox/client.js | 16 +++++++++++---- qiniu/sandbox/template.js | 4 +++- test/sandbox.test.js | 43 ++++++++++++++++++++++----------------- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index 1ea005e..ec9e1c7 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -191,9 +191,16 @@ SandboxClient.prototype._request = function (method, path, options) { urllibOptions.headers['Content-Length'] = '0'; } + let middlewares; + try { + middlewares = this._middlewares(options.authType); + } catch (err) { + return Promise.reject(err); + } + return this.httpClient.sendRequest({ url: this.endpoint + path, - middlewares: this._middlewares(options.authType), + middlewares, urllibOptions }).then(wrapper => this._handleResponse(wrapper, options.empty)); }; @@ -231,9 +238,10 @@ SandboxClient.prototype.createSandbox = function (opts) { }; SandboxClient.prototype.getSandboxesMetrics = function (sandboxIDs) { - const values = Array.isArray(sandboxIDs) - ? sandboxIDs - : (sandboxIDs && (sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs)) || [sandboxIDs]; + const raw = (sandboxIDs && !Array.isArray(sandboxIDs) && typeof sandboxIDs === 'object') + ? sandboxIDs.sandbox_ids || sandboxIDs.sandboxIDs || sandboxIDs + : sandboxIDs; + const values = Array.isArray(raw) ? raw : [raw]; const ids = values.map(value => { if (typeof value === 'string') { return value; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index a378ad0..7a24118 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -265,7 +265,9 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { } const hasNewlines = dockerfileContentOrPath.indexOf('\n') >= 0 || dockerfileContentOrPath.indexOf('\r') >= 0; const startsWithInstruction = /^\s*(ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|ONBUILD|RUN|SHELL|STOPSIGNAL|USER|VOLUME|WORKDIR)\b/i.test(dockerfileContentOrPath); - const isLikelyPath = dockerfileContentOrPath.length < 1024 && !hasNewlines && !startsWithInstruction; + const startsWithComment = /^\s*#/.test(dockerfileContentOrPath); + const looksLikePath = !startsWithComment && (/[\\/]/.test(dockerfileContentOrPath) || /(^|[\\/])Dockerfile(?:\.[^\\/]+)?$/i.test(dockerfileContentOrPath)); + const isLikelyPath = dockerfileContentOrPath.length < 1024 && !hasNewlines && !startsWithInstruction && looksLikePath; if (isLikelyPath && !fs.existsSync(dockerfileContentOrPath)) { throw new Error(`Dockerfile file not found at path: ${dockerfileContentOrPath}`); } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 90118bc..53d5fca 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -180,12 +180,14 @@ describe('test sandbox module', function () { err.message.should.match(/At least one sandbox ID/); })) .then(() => client.getSandboxesMetrics('sbx_one')) + .then(() => client.getSandboxesMetrics({ sandbox_ids: 'sbx_field' })) .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) .then(() => client.getSandboxesMetrics([{ sandboxID: 'sbx_array_object' }, 'sbx_array_string'])) .then(() => { urls.should.eql([ 'http://sandbox.test/sandboxes', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_field', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object', 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_array_object%2Csbx_array_string' ]); @@ -198,19 +200,18 @@ describe('test sandbox module', function () { apiKey: 'sandbox-key' }); - try { - client.listInjectionRules(); - throw new Error('expected missing Qiniu credentials error'); - } catch (err) { - err.name.should.eql('SandboxError'); - err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); - } - (() => new qiniu.sandbox.SandboxClient({ endpoint: 'http://sandbox.test', apiKey: 'sandbox-key', accessKey: 'ak' })).should.throw(/Both accessKey and secretKey/); + + return client.listInjectionRules().then(() => { + throw new Error('expected missing Qiniu credentials rejection'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + }); }); it('requires Qiniu AK/SK before creating sandboxes with Kodo resources', function () { @@ -219,20 +220,19 @@ describe('test sandbox module', function () { apiKey: 'sandbox-key' }); - try { - client.createSandbox({ - template: 'base', - resources: [{ - type: 'kodo', - bucket: 'bucket', - mountPath: '/workspace/kodo' - }] - }); + return client.createSandbox({ + template: 'base', + resources: [{ + type: 'kodo', + bucket: 'bucket', + mountPath: '/workspace/kodo' + }] + }).then(() => { throw new Error('expected missing Qiniu credentials error'); - } catch (err) { + }, err => { err.name.should.eql('SandboxError'); err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); - } + }); }); it('keeps Qiniu sandbox extensions in create body', function () { @@ -1620,6 +1620,11 @@ describe('test sandbox module', function () { (() => qiniu.sandbox.Template().fromDockerfile('/tmp/missing-qiniu-sdk-Dockerfile')).should.throw(/Dockerfile file not found/); }); + it('allows single-line Dockerfile comments as content', function () { + const template = qiniu.sandbox.Template().fromDockerfile('# syntax=docker/dockerfile:1'); + template.buildConfig.steps.should.eql([]); + }); + it('preserves octal permission strings in Template helpers', function () { const template = qiniu.sandbox.Template() .copy('app.js', '/app/', { mode: '755' }) From d7bc0fe10effc3caa7452d11f247820b1df29191 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:46:37 +0800 Subject: [PATCH 35/48] fix(sandbox): align helper types --- index.d.ts | 8 ++++++-- test/sandbox_types.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index a2cd37b..33f2f50 100644 --- a/index.d.ts +++ b/index.d.ts @@ -268,8 +268,8 @@ export declare namespace sandbox { remoteGet(repoPath: string, name: string, options?: CommandOptions): Promise; setConfig(repoPath: string, key: string, value: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; setConfig(key: string, value: string, options?: GitConfigOptions): Promise; - getConfig(repoPath: string, key: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; - getConfig(key: string, options?: GitConfigOptions): Promise; + getConfig(repoPath: string, key: string, options?: CommandOptions & {scope?: 'local' | 'global' | 'system'}): Promise; + getConfig(key: string, options?: GitConfigOptions): Promise; configureUser(repoPath: string, name: string, email: string, options?: CommandOptions): Promise; configureUser(name: string, email: string, options?: GitConfigOptions): Promise; dangerouslyAuthenticate(repoPath: string, remote: string, username: string, password: string, options?: CommandOptions): Promise; @@ -337,9 +337,13 @@ export declare namespace sandbox { makeSymlink(src: string, dest: string, options?: {force?: boolean; user?: string}): this; setWorkdir(workdir: string): this; setUser(user: string): this; + pipInstall(options?: {g?: boolean}): this; pipInstall(packages?: string | string[], options?: {g?: boolean}): this; + npmInstall(options?: {g?: boolean; dev?: boolean}): this; npmInstall(packages?: string | string[], options?: {g?: boolean; dev?: boolean}): this; + bunInstall(options?: {g?: boolean; dev?: boolean}): this; bunInstall(packages?: string | string[], options?: {g?: boolean; dev?: boolean}): this; + gitClone(url: string, options?: {branch?: string; depth?: number; user?: string}): this; gitClone(url: string, path?: string, options?: {branch?: string; depth?: number; user?: string}): this; setEnvs(envs: {[key: string]: string}): this; skipCache(): this; diff --git a/test/sandbox_types.ts b/test/sandbox_types.ts index 1e3f054..aeac1ec 100644 --- a/test/sandbox_types.ts +++ b/test/sandbox_types.ts @@ -69,9 +69,9 @@ async function useSandboxTypes () { password: 'p' }); await sandbox.git.setConfig('user.name', 'Alice', { path: '/repo', scope: 'local' }); - const gitUser: string = await sandbox.git.getConfig('user.name', { path: '/repo', scope: 'local' }); + const gitUser: string | undefined = await sandbox.git.getConfig('user.name', { path: '/repo', scope: 'local' }); await sandbox.git.configureUser('Alice', 'alice@example.com', { path: '/repo', scope: 'local' }); - await sandbox.git.reset('/repo', { mode: 'hard', target: 'HEAD~1', paths: ['a.txt'] }); + await sandbox.git.reset('/repo', { mode: 'hard', target: 'HEAD~1' }); await sandbox.git.restore('/repo', { paths: ['a.txt'] }); const template = qiniu.sandbox.Template() .fromImage('ubuntu:22.04') @@ -97,9 +97,13 @@ async function useSandboxTypes () { .setWorkdir('/app') .setUser('node') .setEnvs({ NODE_ENV: 'production' }) + .pipInstall({ g: false }) .pipInstall(['numpy'], { g: false }) + .npmInstall({ dev: true }) .npmInstall('typescript', { dev: true }) + .bunInstall({ dev: true }) .bunInstall(undefined, { g: true }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', { branch: 'sandbox', depth: 1 }) .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk', { branch: 'sandbox', depth: 1 }) .runCmd(['echo one', 'echo two'], { user: 'root' }); await template.build({ client, name: 'typed-template:test' }); @@ -110,7 +114,9 @@ async function useSandboxTypes () { stream.read; fileType.length; eventType.length; - gitUser.length; + if (gitUser) { + gitUser.length; + } } void useSandboxTypes; From 9082027e8b4d1fd59161a406b8894076fe280844 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:49:14 +0800 Subject: [PATCH 36/48] fix(sandbox): harden stream helpers --- qiniu/sandbox/commands.js | 8 +++++ qiniu/sandbox/filesystem.js | 6 ++++ qiniu/sandbox/git.js | 2 +- qiniu/sandbox/template.js | 3 +- test/sandbox.test.js | 69 +++++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 5ef8ce3..4c44154 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -136,6 +136,7 @@ CommandHandle.prototype.kill = function () { CommandHandle.prototype.disconnect = function () { if (this._request) { + this._disconnected = true; this._request.destroy(); this._request = null; } @@ -192,6 +193,13 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { function fail (err) { cleanupStartTimer(); + if (handle && handle._disconnected) { + if (result.exitCode === -1) { + result.exitCode = 0; + } + resolveWait(result); + return; + } if (!settled) { settled = true; reject(err); diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 7a93fef..39d7222 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -277,6 +277,12 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { Filesystem.prototype.writeFiles = function (files, opts) { opts = opts || {}; + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + if (file && file.data && typeof file.data === 'object' && !Buffer.isBuffer(file.data) && typeof file.data.pipe === 'function') { + return Promise.reject(new TypeError('Streams are not supported as data in filesystem.writeFiles')); + } + } const boundary = `qiniu-sandbox-${Date.now()}-${Math.random().toString(16).slice(2)}`; const parts = files.map(file => ({ field: 'file', diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 607c820..6b6aa39 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -46,7 +46,7 @@ function credentialHelperArgs (opts) { } return [ '-c', - shellQuote('credential.helper=!f() { echo username=$GIT_USERNAME; echo password=$GIT_PASSWORD; }; f'), + shellQuote('credential.helper=!f() { echo "username=$GIT_USERNAME"; echo "password=$GIT_PASSWORD"; }; f'), '-c', shellQuote('credential.useHttpPath=true') ]; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 7a24118..49423d1 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -266,8 +266,9 @@ Template.prototype.fromDockerfile = function (dockerfileContentOrPath) { const hasNewlines = dockerfileContentOrPath.indexOf('\n') >= 0 || dockerfileContentOrPath.indexOf('\r') >= 0; const startsWithInstruction = /^\s*(ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|ONBUILD|RUN|SHELL|STOPSIGNAL|USER|VOLUME|WORKDIR)\b/i.test(dockerfileContentOrPath); const startsWithComment = /^\s*#/.test(dockerfileContentOrPath); + const existingPath = dockerfileContentOrPath.length < 1024 && !hasNewlines && fs.existsSync(dockerfileContentOrPath); const looksLikePath = !startsWithComment && (/[\\/]/.test(dockerfileContentOrPath) || /(^|[\\/])Dockerfile(?:\.[^\\/]+)?$/i.test(dockerfileContentOrPath)); - const isLikelyPath = dockerfileContentOrPath.length < 1024 && !hasNewlines && !startsWithInstruction && looksLikePath; + const isLikelyPath = existingPath || (dockerfileContentOrPath.length < 1024 && !hasNewlines && !startsWithInstruction && looksLikePath); if (isLikelyPath && !fs.existsSync(dockerfileContentOrPath)) { throw new Error(`Dockerfile file not found at path: ${dockerfileContentOrPath}`); } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 53d5fca..bf223a4 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1620,6 +1620,20 @@ describe('test sandbox module', function () { (() => qiniu.sandbox.Template().fromDockerfile('/tmp/missing-qiniu-sdk-Dockerfile')).should.throw(/Dockerfile file not found/); }); + it('reads existing custom Dockerfile paths', function () { + const file = `/tmp/qiniu-sdk-Containerfile-${Date.now()}`; + fs.writeFileSync(file, 'FROM node:22\nRUN echo ok'); + try { + const template = qiniu.sandbox.Template().fromDockerfile(file); + template.buildConfig.fromImage.should.eql('node:22'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo ok' } + ]); + } finally { + fs.unlinkSync(file); + } + }); + it('allows single-line Dockerfile comments as content', function () { const template = qiniu.sandbox.Template().fromDockerfile('# syntax=docker/dockerfile:1'); template.buildConfig.steps.should.eql([]); @@ -1853,6 +1867,16 @@ describe('test sandbox module', function () { }) .then(entries => { entries.map(entry => entry.path).should.eql(['/a.txt', '/b.txt']); + return sandbox.files.writeFiles([ + { path: '/stream.txt', data: new stream.Readable() } + ]).then(() => { + throw new Error('expected writeFiles stream data to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.match(/Streams are not supported/); + }); + }) + .then(() => { return sandbox.files.exists('/missing.txt'); }) .then(exists => { @@ -2472,6 +2496,49 @@ describe('test sandbox module', function () { }); }); + it('does not reject command wait after disconnecting the live stream', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 83 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_disconnect', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 30').then(handle => { + return handle.disconnect() + .then(() => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + }); + }).then(() => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects command wait when the live Connect stream ends with a partial frame after start', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -2783,6 +2850,8 @@ describe('test sandbox module', function () { err.message.should.eql('push failed'); commandsSeen.length.should.eql(1); commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('echo "username=$GIT_USERNAME"'); + commandsSeen[0].cmd.should.containEql('echo "password=$GIT_PASSWORD"'); commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); commandsSeen[0].cmd.should.not.containEql('u:p'); commandsSeen[0].opts.envs.should.eql({ From d8493601abdfc067ac457b47e23a691e49adb0ab Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:57:31 +0800 Subject: [PATCH 37/48] fix(sandbox): align type export test --- test/sandbox_types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox_types.ts b/test/sandbox_types.ts index aeac1ec..09bc149 100644 --- a/test/sandbox_types.ts +++ b/test/sandbox_types.ts @@ -108,7 +108,7 @@ async function useSandboxTypes () { .runCmd(['echo one', 'echo two'], { user: 'root' }); await template.build({ client, name: 'typed-template:test' }); await sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }); - await qiniu.CommandExitError; + qiniu.CommandExitError.name; bytes.length; text.length; stream.read; From ba0391976a2c899b255e335e20d32b1e8ca54593 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 21:58:58 +0800 Subject: [PATCH 38/48] fix(sandbox): align pty disconnect wait --- qiniu/sandbox/pty.js | 9 +++++++++ test/sandbox.test.js | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 51da046..2df4778 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -53,7 +53,9 @@ LivePtyHandle.prototype.kill = function () { LivePtyHandle.prototype.disconnect = function () { if (this._request) { + this._disconnected = true; this._request.destroy(); + this._request = null; } return Promise.resolve(); }; @@ -103,6 +105,13 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { function fail (err) { cleanupStartTimer(); + if (handle && handle._disconnected) { + if (result.exitCode === -1) { + result.exitCode = 0; + } + resolveWait(result); + return; + } if (!settled) { settled = true; reject(err); diff --git a/test/sandbox.test.js b/test/sandbox.test.js index bf223a4..cd3c63e 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -4043,6 +4043,52 @@ describe('test sandbox module', function () { }); }); + it('does not reject PTY wait after disconnecting the live stream', function () { + let ptyResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + ptyResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 48 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_disconnect', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + return handle.disconnect() + .then(() => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + }); + }).then(() => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects oversized PTY stream envelopes', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { From e62cee32012e1e076dca34d4328d0233fe3e4ae0 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 22:00:08 +0800 Subject: [PATCH 39/48] fix(sandbox): honor explicit mac credentials --- qiniu/sandbox/client.js | 2 +- test/sandbox.test.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index ec9e1c7..d6a2052 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -97,7 +97,7 @@ function normalizeSandboxListOptions (opts) { function normalizeClientOptions (opts) { opts = opts || {}; - if ((opts.accessKey && !opts.secretKey) || (!opts.accessKey && opts.secretKey)) { + if (!opts.mac && ((opts.accessKey && !opts.secretKey) || (!opts.accessKey && opts.secretKey))) { throw new SandboxError('Both accessKey and secretKey must be provided'); } const mac = opts.mac || (opts.accessKey || opts.secretKey diff --git a/test/sandbox.test.js b/test/sandbox.test.js index cd3c63e..3c4782b 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -205,6 +205,14 @@ describe('test sandbox module', function () { apiKey: 'sandbox-key', accessKey: 'ak' })).should.throw(/Both accessKey and secretKey/); + const mac = new qiniu.auth.digest.Mac('ak', 'sk'); + const clientWithMac = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + accessKey: 'ak', + mac + }); + clientWithMac.mac.should.equal(mac); return client.listInjectionRules().then(() => { throw new Error('expected missing Qiniu credentials rejection'); From 9e4fb25fef582db262dea1eb9ac4e24b1ae3a300 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 22:03:55 +0800 Subject: [PATCH 40/48] fix(sandbox): harden helper edge cases --- qiniu/sandbox/filesystem.js | 6 ++++++ qiniu/sandbox/git.js | 2 +- qiniu/sandbox/template.js | 2 +- test/sandbox.test.js | 25 +++++++++++++++++++++---- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 39d7222..5d5bb20 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -277,8 +277,14 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { Filesystem.prototype.writeFiles = function (files, opts) { opts = opts || {}; + if (!Array.isArray(files)) { + return Promise.reject(new TypeError('files must be an array')); + } for (let i = 0; i < files.length; i += 1) { const file = files[i]; + if (!file || typeof file !== 'object') { + return Promise.reject(new TypeError('Each file must be an object')); + } if (file && file.data && typeof file.data === 'object' && !Buffer.isBuffer(file.data) && typeof file.data.pipe === 'function') { return Promise.reject(new TypeError('Streams are not supported as data in filesystem.writeFiles')); } diff --git a/qiniu/sandbox/git.js b/qiniu/sandbox/git.js index 6b6aa39..7b64861 100644 --- a/qiniu/sandbox/git.js +++ b/qiniu/sandbox/git.js @@ -46,7 +46,7 @@ function credentialHelperArgs (opts) { } return [ '-c', - shellQuote('credential.helper=!f() { echo "username=$GIT_USERNAME"; echo "password=$GIT_PASSWORD"; }; f'), + shellQuote('credential.helper=!f() { printf "username=%s\\npassword=%s\\n" "$GIT_USERNAME" "$GIT_PASSWORD"; }; f'), '-c', shellQuote('credential.useHttpPath=true') ]; diff --git a/qiniu/sandbox/template.js b/qiniu/sandbox/template.js index 49423d1..84b19b0 100644 --- a/qiniu/sandbox/template.js +++ b/qiniu/sandbox/template.js @@ -494,7 +494,7 @@ Template.prototype.bunInstall = function (packages, options) { args.push.apply(args, asArray(packages).map(shellQuote)); return this.runCmd(args.join(' '), { user: options.g ? 'root' : undefined }); } - return this.runCmd('bun install'); + return this.runCmd('bun install', { user: options.g ? 'root' : undefined }); }; Template.prototype.gitClone = function (url, path, options) { diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 3c4782b..79ed2c6 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1474,6 +1474,7 @@ describe('test sandbox module', function () { .bunInstall(['elysia'], { dev: true }) .bunInstall({ dev: true }) .bunInstall([]) + .bunInstall(undefined, { g: true }) .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { branch: 'sandbox', @@ -1509,8 +1510,9 @@ describe('test sandbox module', function () { { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, { type: 'RUN', args: ['npm install --save-dev'] }, { type: 'RUN', args: ['bun add --dev \'elysia\''] }, - { type: 'run', cmd: 'bun install' }, - { type: 'run', cmd: 'bun install' }, + { type: 'RUN', args: ['bun install'] }, + { type: 'RUN', args: ['bun install'] }, + { type: 'RUN', args: ['bun install', 'root'] }, { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\''] }, @@ -1875,6 +1877,22 @@ describe('test sandbox module', function () { }) .then(entries => { entries.map(entry => entry.path).should.eql(['/a.txt', '/b.txt']); + return sandbox.files.writeFiles(null).then(() => { + throw new Error('expected writeFiles invalid files to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.eql('files must be an array'); + }); + }) + .then(() => { + return sandbox.files.writeFiles([null]).then(() => { + throw new Error('expected writeFiles invalid file item to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.eql('Each file must be an object'); + }); + }) + .then(() => { return sandbox.files.writeFiles([ { path: '/stream.txt', data: new stream.Readable() } ]).then(() => { @@ -2858,8 +2876,7 @@ describe('test sandbox module', function () { err.message.should.eql('push failed'); commandsSeen.length.should.eql(1); commandsSeen[0].cmd.should.containEql('credential.helper='); - commandsSeen[0].cmd.should.containEql('echo "username=$GIT_USERNAME"'); - commandsSeen[0].cmd.should.containEql('echo "password=$GIT_PASSWORD"'); + commandsSeen[0].cmd.should.containEql('printf "username=%s\\npassword=%s\\n" "$GIT_USERNAME" "$GIT_PASSWORD"'); commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); commandsSeen[0].cmd.should.not.containEql('u:p'); commandsSeen[0].opts.envs.should.eql({ From 93bea18f3dc630f68c0d944f0238e08b8100bc9b Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 22:09:21 +0800 Subject: [PATCH 41/48] fix(sandbox): preserve pty args and binary writes --- qiniu/sandbox/client.js | 3 ++ qiniu/sandbox/filesystem.js | 20 ++++++++++--- qiniu/sandbox/pty.js | 2 +- test/sandbox.test.js | 56 ++++++++++++++++++++++++++++++++++--- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/qiniu/sandbox/client.js b/qiniu/sandbox/client.js index d6a2052..12fdddf 100644 --- a/qiniu/sandbox/client.js +++ b/qiniu/sandbox/client.js @@ -259,6 +259,9 @@ SandboxClient.prototype.getSandboxLogs = function (sandboxID, opts) { }; SandboxClient.prototype.getSandbox = function (sandboxID) { + if (!sandboxID) { + return Promise.reject(new SandboxError('sandboxID is required')); + } return this._request('GET', `/sandboxes/${encodePath(sandboxID)}`); }; diff --git a/qiniu/sandbox/filesystem.js b/qiniu/sandbox/filesystem.js index 5d5bb20..5affa37 100644 --- a/qiniu/sandbox/filesystem.js +++ b/qiniu/sandbox/filesystem.js @@ -135,14 +135,26 @@ function multipartFilename (value) { .replace(/\n/g, '%0A'); } +function dataToUploadBuffer (data) { + if (Buffer.isBuffer(data)) { + return data; + } + if (data instanceof Uint8Array) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + return Buffer.from(String(data !== undefined && data !== null ? data : '')); +} + function multipartBody (boundary, parts) { const chunks = []; parts.forEach(part => { chunks.push(Buffer.from(`--${boundary}\r\n`)); chunks.push(Buffer.from(`Content-Disposition: form-data; name="${part.field}"; filename="${multipartFilename(part.filename)}"\r\n`)); chunks.push(Buffer.from('Content-Type: application/octet-stream\r\n\r\n')); - const data = part.data !== undefined && part.data !== null ? part.data : ''; - chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(String(data))); + chunks.push(dataToUploadBuffer(part.data)); chunks.push(Buffer.from('\r\n')); }); chunks.push(Buffer.from(`--${boundary}--\r\n`)); @@ -227,7 +239,7 @@ Filesystem.prototype.write = function (pathOrFiles, dataOrOpts, maybeOpts) { const headers = { 'Content-Type': 'application/octet-stream' }; - const content = Buffer.isBuffer(dataOrOpts) ? dataOrOpts : Buffer.from(String(dataOrOpts !== undefined && dataOrOpts !== null ? dataOrOpts : '')); + const content = dataToUploadBuffer(dataOrOpts); const compressed = opts.gzip && supportsEncodedUpload ? gzipAsync(content).then(result => { headers['Content-Encoding'] = 'gzip'; @@ -285,7 +297,7 @@ Filesystem.prototype.writeFiles = function (files, opts) { if (!file || typeof file !== 'object') { return Promise.reject(new TypeError('Each file must be an object')); } - if (file && file.data && typeof file.data === 'object' && !Buffer.isBuffer(file.data) && typeof file.data.pipe === 'function') { + if (file.data && typeof file.data === 'object' && !Buffer.isBuffer(file.data) && typeof file.data.pipe === 'function') { return Promise.reject(new TypeError('Streams are not supported as data in filesystem.writeFiles')); } } diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 2df4778..698d265 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -240,7 +240,7 @@ function Pty (sandbox) { Pty.prototype.create = function (opts) { opts = opts || {}; - if (!opts.cols && !opts.rows && !opts.onData) { + if (!opts.cols && !opts.rows && !opts.onData && !opts.args) { return this.commands.start(opts.cmd || '/bin/bash', Object.assign({}, opts, { stdin: true })); diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 79ed2c6..94f87d8 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1237,6 +1237,13 @@ describe('test sandbox module', function () { JSON.parse(fixture.requests[0].body).should.eql({ timeout: 15 }); err.name.should.eql('SandboxError'); err.message.should.containEql('teapot'); + return client.getSandbox(''); + }) + .then(() => { + throw new Error('expected missing sandboxID to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); }) .then(() => closeServer(fixture.server), err => { return closeServer(fixture.server).then(() => { @@ -1836,6 +1843,8 @@ describe('test sandbox module', function () { Number(parsed.searchParams.get('signature_expiration')).should.be.above(Math.floor(Date.now() / 1000)); req.body.should.containEql('/a.txt'); req.body.should.containEql('/b.txt'); + req.rawBody.includes(Buffer.from([1, 2, 3])).should.eql(true); + req.rawBody.includes(Buffer.from([4, 5, 6])).should.eql(true); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify([ @@ -1871,8 +1880,8 @@ describe('test sandbox module', function () { Buffer.isBuffer(data).should.eql(true); data.length.should.eql(3); return sandbox.files.writeFiles([ - { path: '/a.txt', data: 'a' }, - { path: '/b.txt', data: 'b' } + { path: '/a.txt', data: new Uint8Array([1, 2, 3]) }, + { path: '/b.txt', data: new Uint8Array([4, 5, 6]).buffer } ], { user: 'user' }); }) .then(entries => { @@ -3950,6 +3959,45 @@ describe('test sandbox module', function () { }); }); + it('uses live PTY creation when args are provided without size or data callbacks', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.cmd.should.eql('node'); + body.process.args.should.eql(['-i']); + should.exist(body.pty); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 49 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_args', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ cmd: 'node', args: ['-i'] }) + .then(handle => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + it('rejects PTY wait when Connect end-stream carries an error', function () { return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -4245,7 +4293,7 @@ describe('test sandbox module', function () { req.headers['content-type'].should.eql('application/octet-stream'); req.headers['content-encoding'].should.eql('gzip'); parsed.searchParams.get('path').should.eql('/zip.txt'); - zlib.gunzipSync(req.rawBody).toString().should.eql('0'); + Array.from(zlib.gunzipSync(req.rawBody)).should.eql([1, 2, 3]); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); @@ -4266,7 +4314,7 @@ describe('test sandbox module', function () { return sandbox.files.read('/zip.txt', { gzip: true }) .then(text => { text.should.eql('zip'); - return sandbox.files.write('/zip.txt', 0, { + return sandbox.files.write('/zip.txt', new Uint8Array([1, 2, 3]), { gzip: true, useOctetStream: true }); From e9ed56f72c61cf55e8be7dff40152a913fd99808 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Mon, 15 Jun 2026 22:18:24 +0800 Subject: [PATCH 42/48] fix(sandbox): always create live pty --- qiniu/sandbox/pty.js | 6 ------ test/sandbox.test.js | 33 ++++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index 698d265..ba3d6ed 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -240,12 +240,6 @@ function Pty (sandbox) { Pty.prototype.create = function (opts) { opts = opts || {}; - if (!opts.cols && !opts.rows && !opts.onData && !opts.args) { - return this.commands.start(opts.cmd || '/bin/bash', Object.assign({}, opts, { - stdin: true - })); - } - const envs = Object.assign({}, opts.envs || {}); if (!envs.TERM) { envs.TERM = 'xterm-256color'; diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 94f87d8..6194b8c 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1946,7 +1946,6 @@ describe('test sandbox module', function () { } }; const git = new qiniu.sandbox.Git(fakeCommands); - const pty = new qiniu.sandbox.Pty({ commands: fakeCommands }); return startServer((req, res) => { if (req.url === '/process.Process/Start') { @@ -2009,7 +2008,7 @@ describe('test sandbox module', function () { return sandbox.commands.closeStdin(12); }) .then(() => sandbox.commands.kill(12)) - .then(() => handleGitAndPty(git, pty, commandsSeen)) + .then(() => handleGitAndPty(git, sandbox.pty, commandsSeen, fixture)) .then(() => closeServer(fixture.server), err => { return closeServer(fixture.server).then(() => { throw err; @@ -3959,17 +3958,24 @@ describe('test sandbox module', function () { }); }); - it('uses live PTY creation when args are provided without size or data callbacks', function () { + it('uses live PTY creation for default and args-based create calls', function () { + let starts = 0; return startServer((req, res) => { if (req.url === '/process.Process/Start') { + starts += 1; const body = decodeConnectEnvelope(req.rawBody); - body.process.cmd.should.eql('node'); - body.process.args.should.eql(['-i']); + if (starts === 1) { + body.process.cmd.should.eql('/bin/bash'); + body.process.args.should.eql(['-i', '-l']); + } else { + body.process.cmd.should.eql('node'); + body.process.args.should.eql(['-i']); + } should.exist(body.pty); res.statusCode = 200; res.setHeader('Content-Type', 'application/connect+json'); res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 49 } } }), + encodeConnectEnvelope({ event: { start: { pid: 48 + starts } } }), encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) ])); return; @@ -3985,10 +3991,13 @@ describe('test sandbox module', function () { } }); - return sandbox.pty.create({ cmd: 'node', args: ['-i'] }) + return sandbox.pty.create() + .then(handle => handle.wait()) + .then(() => sandbox.pty.create({ cmd: 'node', args: ['-i'] })) .then(handle => handle.wait()) .then(result => { result.exitCode.should.eql(0); + starts.should.eql(2); }) .then(() => closeServer(fixture.server), err => { return closeServer(fixture.server).then(() => { @@ -4529,7 +4538,7 @@ describe('test sandbox module', function () { }); }); -function handleGitAndPty (git, pty, commandsSeen) { +function handleGitAndPty (git, pty, commandsSeen, fixture) { return git.clone('https://example.com/repo.git', { path: '/repo' }) .then(() => git.init('/repo')) .then(() => git.add('/repo', { all: true })) @@ -4552,12 +4561,14 @@ function handleGitAndPty (git, pty, commandsSeen) { }) .then(() => pty.create({ cmd: 'bash', cwd: '/repo' })) .then(handle => { - handle.pid.should.eql(9); + handle.pid.should.eql(12); commandsSeen[0].cmd.should.eql('git clone \'https://example.com/repo.git\' \'/repo\''); commandsSeen[1].cmd.should.eql('git init'); commandsSeen[1].opts.cwd.should.eql('/repo'); commandsSeen.some(item => item.cmd.indexOf('git commit -m') === 0).should.eql(true); - commandsSeen[commandsSeen.length - 1].cmd.should.eql('bash'); - commandsSeen[commandsSeen.length - 1].opts.stdin.should.eql(true); + const ptyBody = decodeConnectEnvelope(fixture.requests[6].rawBody); + ptyBody.process.cmd.should.eql('bash'); + ptyBody.process.cwd.should.eql('/repo'); + should.exist(ptyBody.pty); }); } From f48f9a6d6bfd235f0630fd592fcdc5a01543f4b9 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Tue, 16 Jun 2026 10:39:34 +0800 Subject: [PATCH 43/48] test(sandbox): cover helper edge cases --- test/sandbox.test.js | 184 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 6194b8c..ecb153f 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -4536,6 +4536,190 @@ describe('test sandbox module', function () { commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); }); }); + + it('rejects missing sandbox ids and encodes nested template file paths', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + const requests = []; + client.httpClient.sendRequest = req => { + requests.push(req); + return Promise.resolve({ + ok: () => true, + data: { ok: true } + }); + }; + + return client.getSandbox('').then(() => { + throw new Error('expected getSandbox to reject missing id'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + return client.deleteSandbox(''); + }).then(() => { + throw new Error('expected deleteSandbox to reject missing id'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + return client.getSandboxLogs('sbx/with space', { cursor: 'next/page' }); + }).then(() => client.getTemplateFiles('tpl/with space', 'dir/file hash')) + .then(() => { + requests.map(req => req.url).should.eql([ + 'http://sandbox.test/sandboxes/sbx%2Fwith%20space/logs?cursor=next%2Fpage', + 'http://sandbox.test/templates/tpl%2Fwith%20space/files/dir%2Ffile%20hash' + ]); + }); + }); + + it('parses envd stream fallback responses shaped as events, arrays, and single events', function () { + const envd = require('../qiniu/sandbox/envd'); + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/stream.EventsObject') { + res.end(JSON.stringify({ + events: [{ event: { start: { pid: 1 } } }] + })); + return; + } + if (req.url === '/stream.Array') { + res.end(JSON.stringify([{ event: { end: { exitCode: 0 } } }])); + return; + } + if (req.url === '/stream.Single') { + res.end(JSON.stringify({ event: { data: { stdout: 'aGVsbG8=' } } })); + return; + } + if (req.url === '/stream.Empty') { + res.end(JSON.stringify({ ok: true })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_envd_fallbacks', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return envd.connectStreamRPC(sandbox, '/stream.EventsObject', {}) + .then(events => { + events.should.eql([{ event: { start: { pid: 1 } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Array', {}); + }) + .then(events => { + events.should.eql([{ event: { end: { exitCode: 0 } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Single', {}); + }) + .then(events => { + events.should.eql([{ event: { data: { stdout: 'aGVsbG8=' } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Empty', {}); + }) + .then(events => { + events.should.eql([]); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns false for PTY kill 404 responses and rethrows other failures', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/SendSignal') { + const body = JSON.parse(req.body); + if (body.process.selector.pid === 404) { + res.statusCode = 404; + res.end('missing'); + return; + } + res.statusCode = 500; + res.end('boom'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_kill', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.kill(404) + .then(killed => { + killed.should.eql(false); + return sandbox.pty.kill(500); + }) + .then(() => { + throw new Error('expected non-404 PTY kill to reject'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.containEql('status 500'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers Template package helper option branches and build option cleanup', function () { + const bodySeen = []; + const client = { + createTemplateV3: body => { + bodySeen.push(body); + return Promise.resolve({ templateID: 'tpl_1' }); + } + }; + const template = qiniu.sandbox.Template() + .pipInstall({ g: false }) + .npmInstall({ g: true, dev: true }) + .bunInstall({ g: true }) + .setEnvs({}) + .setStartCmd('npm start') + .setReadyCmd('curl -f http://localhost:3000'); + + return template.build({ + client, + endpoint: 'https://sandbox.example.com', + apiKey: 'api-key', + accessToken: 'access-token', + accessKey: 'ak', + secretKey: 'sk', + timeout: 1, + requestTimeoutMs: 2000, + alias: 'tpl-alias' + }).then(ret => { + ret.templateID.should.eql('tpl_1'); + bodySeen.length.should.eql(1); + bodySeen[0].alias.should.eql('tpl-alias'); + should.not.exist(bodySeen[0].client); + should.not.exist(bodySeen[0].endpoint); + should.not.exist(bodySeen[0].apiKey); + should.not.exist(bodySeen[0].accessToken); + should.not.exist(bodySeen[0].accessKey); + should.not.exist(bodySeen[0].secretKey); + should.not.exist(bodySeen[0].timeout); + should.not.exist(bodySeen[0].requestTimeoutMs); + bodySeen[0].buildConfig.startCmd.should.eql('npm start'); + bodySeen[0].buildConfig.readyCmd.should.eql('curl -f http://localhost:3000'); + bodySeen[0].buildConfig.steps.should.eql([ + { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['npm install -g --save-dev', 'root'] }, + { type: 'RUN', args: ['bun install', 'root'] } + ]); + }); + }); }); function handleGitAndPty (git, pty, commandsSeen, fixture) { From 370b768906b371c584196d3bace05ff9e7b37582 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Tue, 16 Jun 2026 11:18:47 +0800 Subject: [PATCH 44/48] test(sandbox): split coverage tests Run sandbox unit coverage before env-gated integration tests so PR coverage includes the sandbox modules even when Qiniu secrets are unavailable. --- .github/workflows/ci-test.yml | 3 +- package.json | 1 + test/00_sandbox_client.test.js | 900 ++++++ test/00_sandbox_commands.test.js | 1049 ++++++ test/00_sandbox_facade.test.js | 267 ++ test/00_sandbox_filesystem.test.js | 980 ++++++ test/00_sandbox_git.test.js | 471 +++ test/00_sandbox_pty.test.js | 494 +++ test/00_sandbox_template.test.js | 551 ++++ test/sandbox.test.js | 4758 ---------------------------- test/sandbox_helpers.js | 141 + 11 files changed, 4856 insertions(+), 4759 deletions(-) create mode 100644 test/00_sandbox_client.test.js create mode 100644 test/00_sandbox_commands.test.js create mode 100644 test/00_sandbox_facade.test.js create mode 100644 test/00_sandbox_filesystem.test.js create mode 100644 test/00_sandbox_git.test.js create mode 100644 test/00_sandbox_pty.test.js create mode 100644 test/00_sandbox_template.test.js delete mode 100644 test/sandbox.test.js create mode 100644 test/sandbox_helpers.js diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 7afa692..da28875 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -24,7 +24,8 @@ jobs: - name: Run cases run: | npm run check-type - nyc --reporter=lcov npm test + nyc --reporter=lcov npm run test:sandbox + nyc --no-clean --reporter=lcov npm test env: QINIU_ACCESS_KEY: ${{ secrets.QINIU_ACCESS_KEY }} QINIU_SECRET_KEY: ${{ secrets.QINIU_SECRET_KEY }} diff --git a/package.json b/package.json index 71d904c..71d57d0 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "check-type": "tsc --noEmit", "test": "NODE_ENV=test mocha -t 300000 --retries 3", + "test:sandbox": "NODE_ENV=test mocha -t 300000 --retries 3 test/00_sandbox_client.test.js test/00_sandbox_commands.test.js test/00_sandbox_facade.test.js test/00_sandbox_filesystem.test.js test/00_sandbox_git.test.js test/00_sandbox_pty.test.js test/00_sandbox_template.test.js", "cover": "nyc npm run test", "report": "nyc report --reporter=html", "lint": "eslint ." diff --git a/test/00_sandbox_client.test.js b/test/00_sandbox_client.test.js new file mode 100644 index 0000000..69d968a --- /dev/null +++ b/test/00_sandbox_client.test.js @@ -0,0 +1,900 @@ +const { + should, + qiniu, + startServer, + closeServer, + parseUrl +} = require('./sandbox_helpers'); + +describe('test sandbox client module', function () { + it('creates sandbox with E2B compatible options and API key auth', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_1', + domain: 'sbx.local', + envdVersion: '0.0.1', + envdAccessToken: 'token' + })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return client.createSandbox({ + template: 'base', + timeoutMs: 15000, + metadata: { + user: 'alice' + }, + envs: { + FOO: 'bar' + }, + injections: [ + { + type: 'qiniu', + apiKey: 'ak', + baseUrl: 'https://example.com', + ruleId: 'rule_1' + } + ] + }).then(ret => { + should.equal(ret.sandboxID, 'sbx_1'); + fixture.requests.length.should.eql(1); + fixture.requests[0].method.should.eql('POST'); + fixture.requests[0].url.should.eql('/sandboxes'); + fixture.requests[0].headers['x-api-key'].should.eql('sandbox-key'); + fixture.requests[0].headers.authorization.should.eql('Bearer sandbox-key'); + fixture.requests[0].headers['content-type'].should.eql('application/json'); + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'base', + timeout: 15, + metadata: { + user: 'alice' + }, + envVars: { + FOO: 'bar' + }, + injections: [ + { + type: 'qiniu', + api_key: 'ak', + base_url: 'https://example.com', + ruleID: 'rule_1' + } + ] + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('passes client timeout to urllib requests and validates metrics ids', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + timeout: 1234 + }); + const urls = []; + client.httpClient.sendRequest = req => { + urls.push(req.url); + req.urllibOptions.timeout.should.eql(1234); + should.not.exist(req.urllibOptions.headers['Content-Length']); + should.not.exist(req.urllibOptions.contentType); + return Promise.resolve({ + ok: () => true, + data: { ok: true } + }); + }; + + return client.listSandboxes() + .then(() => client.getSandboxesMetrics().then(() => { + throw new Error('expected empty metrics ids to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/At least one sandbox ID/); + })) + .then(() => client.getSandboxesMetrics('sbx_one')) + .then(() => client.getSandboxesMetrics({ sandbox_ids: 'sbx_field' })) + .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) + .then(() => client.getSandboxesMetrics([{ sandboxID: 'sbx_array_object' }, 'sbx_array_string'])) + .then(() => { + urls.should.eql([ + 'http://sandbox.test/sandboxes', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_field', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object', + 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_array_object%2Csbx_array_string' + ]); + }); + }); + + it('throws a clear error when Qiniu-auth APIs are called without AK/SK credentials', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + + (() => new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + accessKey: 'ak' + })).should.throw(/Both accessKey and secretKey/); + const mac = new qiniu.auth.digest.Mac('ak', 'sk'); + const clientWithMac = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key', + accessKey: 'ak', + mac + }); + clientWithMac.mac.should.equal(mac); + + return client.listInjectionRules().then(() => { + throw new Error('expected missing Qiniu credentials rejection'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + }); + }); + + it('requires Qiniu AK/SK before creating sandboxes with Kodo resources', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + + return client.createSandbox({ + template: 'base', + resources: [{ + type: 'kodo', + bucket: 'bucket', + mountPath: '/workspace/kodo' + }] + }).then(() => { + throw new Error('expected missing Qiniu credentials error'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); + }); + }); + + it('keeps Qiniu sandbox extensions in create body', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_qiniu', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + mcp: { enabled: true }, + injections: [{ injectionRuleID: 'rule_1' }], + resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] + }).then(() => { + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'base', + mcp: { enabled: true }, + injections: [{ injectionRuleID: 'rule_1' }], + resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses Qiniu AK/SK signing when creating sandbox with Kodo resources', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_kodo', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key', + mac + }); + + return client.createSandbox({ + template: 'base', + resources: [ + { + type: 'kodo', + bucket: 'bucket', + mount_path: '/workspace/kodo', + read_only: true + } + ] + }).then(() => { + should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); + should.not.exist(fixture.requests[0].headers['x-api-key']); + JSON.parse(fixture.requests[0].body).resources[0].type.should.eql('kodo'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes E2B style Sandbox.create and kill helpers', function () { + return startServer((req, res) => { + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_2', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + return; + } + + if (req.method === 'DELETE' && req.url === '/sandboxes/sbx_2') { + res.statusCode = 204; + res.end(); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + template: 'base' + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_2'); + return sandbox.kill(); + }).then(() => { + fixture.requests.map(req => `${req.method} ${req.url}`).should.eql([ + 'POST /sandboxes', + 'DELETE /sandboxes/sbx_2' + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exports E2B style top-level Sandbox and client classes', function () { + qiniu.Sandbox.should.equal(qiniu.sandbox.Sandbox); + qiniu.SandboxClient.should.equal(qiniu.sandbox.SandboxClient); + qiniu.CommandExitError.should.equal(qiniu.sandbox.CommandExitError); + }); + + it('supports Sandbox.create(template, opts) overload', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_template', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create('nodejs', { + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + metadata: { + source: 'e2b-overload' + } + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_template'); + JSON.parse(fixture.requests[0].body).should.eql({ + templateID: 'nodejs', + metadata: { + source: 'e2b-overload' + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('keeps sandbox lifetime timeout separate from HTTP request timeout in static helpers', function () { + return startServer((req, res) => { + setTimeout(() => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_timeout', + domain: 'sbx.local', + envdAccessToken: 'token' + })); + }, 40); + }).then(fixture => { + return qiniu.sandbox.Sandbox.create({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + template: 'base', + timeout: 30 + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_timeout'); + JSON.parse(fixture.requests[0].body).timeout.should.eql(30); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes typed sandbox compatibility errors', function () { + const err = new qiniu.sandbox.CommandExitError({ + command: 'false', + exitCode: 1, + stdout: 'out', + stderr: 'err' + }); + + err.should.be.instanceOf(Error); + err.name.should.eql('CommandExitError'); + err.exitCode.should.eql(1); + err.stdout.should.eql('out'); + err.stderr.should.eql('err'); + new qiniu.sandbox.GitAuthError('bad credentials').name.should.eql('GitAuthError'); + new qiniu.sandbox.GitUpstreamError('missing upstream').name.should.eql('GitUpstreamError'); + new qiniu.sandbox.NotImplementedError('volume').name.should.eql('NotImplementedError'); + }); + + it('uses Qiniu AK/SK signing for injection rule APIs', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + id: 'rule_1', + name: 'openai', + injection: { + type: 'openai', + apiKey: 'secret' + } + })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + mac, + endpoint: fixture.endpoint + }); + + return client.createInjectionRule({ + name: 'openai', + injection: { + type: 'openai', + apiKey: 'secret' + } + }).then(() => { + fixture.requests[0].method.should.eql('POST'); + fixture.requests[0].url.should.eql('/injection-rules'); + should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); + should.not.exist(fixture.requests[0].headers['x-api-key']); + JSON.parse(fixture.requests[0].body).should.eql({ + name: 'openai', + injection: { + type: 'openai', + api_key: 'secret' + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('maps sandbox lifecycle and metrics APIs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/v2/sandboxes?metadata=user%3Dalice&state=running%2Cpaused&limit=10&nextToken=n1') { + res.statusCode = 200; + res.end(JSON.stringify([{ sandboxID: 'sbx_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_1', state: 'running' })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_1', envdAccessToken: 'token' })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/timeout') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/refreshes') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/pause') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/metrics?start=1&end=2') { + res.statusCode = 200; + res.end(JSON.stringify([{ cpuCount: 1 }])); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/logs?start=10&limit=20') { + res.statusCode = 200; + res.end(JSON.stringify({ logs: [] })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/metrics?sandbox_ids=sbx_1%2Csbx_2') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxes: [] })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return client.deleteSandbox().then(() => { + throw new Error('expected missing sandboxID to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/sandboxID is required/); + }).then(() => client.list({ + metadata: 'user=alice', + state: ['running', 'paused'], + limit: 10, + nextToken: 'n1' + })).then(() => client.getInfo('sbx_1')) + .then(() => client.connect('sbx_1', { timeoutMs: 20000 })) + .then(() => client.setTimeout('sbx_1', { timeoutMs: 30000 })) + .then(() => client.refreshSandbox('sbx_1', { duration: 60 })) + .then(() => client.pauseSandbox('sbx_1')) + .then(() => client.getMetrics('sbx_1', { start: 1, end: 2 })) + .then(() => client.getLogs('sbx_1', { start: 10, limit: 20 })) + .then(() => client.getSandboxesMetrics(['sbx_1', 'sbx_2'])) + .then(() => { + JSON.parse(fixture.requests[3].body).should.eql({ timeout: 30 }); + JSON.parse(fixture.requests[4].body).should.eql({ duration: 60 }); + fixture.requests.every(req => req.headers['x-api-key'] === 'sandbox-key').should.eql(true); + fixture.requests.every(req => req.headers.authorization === 'Bearer sandbox-key').should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('surfaces sandbox API string errors and default connect timeout', function () { + return startServer((req, res) => { + if (req.method === 'POST' && req.url === '/sandboxes/sbx_default/connect') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_default' })); + return; + } + res.statusCode = 418; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify('teapot')); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + + return client.connectSandbox('sbx_default') + .then(() => client.getSandbox('sbx_error')) + .then(() => { + throw new Error('expected string error'); + }, err => { + JSON.parse(fixture.requests[0].body).should.eql({ timeout: 15 }); + err.name.should.eql('SandboxError'); + err.message.should.containEql('teapot'); + return client.getSandbox(''); + }) + .then(() => { + throw new Error('expected missing sandboxID to fail'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exposes network constants and maps updateNetwork to Qiniu API', function () { + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + sandboxID: 'sbx_net', + network: JSON.parse(req.body).network + })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_net', + client, + info: {} + }); + qiniu.sandbox.ALL_TRAFFIC.should.eql('0.0.0.0/0'); + return sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }) + .then(info => { + info.network.allowOut[0].should.eql('0.0.0.0/0'); + fixture.requests[0].method.should.eql('PATCH'); + fixture.requests[0].url.should.eql('/sandboxes/sbx_net'); + JSON.parse(fixture.requests[0].body).should.eql({ + network: { + allowOut: ['0.0.0.0/0'] + } + }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns typed unsupported errors for E2B volume compatibility', function () { + const volume = new qiniu.sandbox.Volume(); + return volume.create().then(() => { + throw new Error('expected volume.create to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + err.message.should.containEql('Volume'); + return volume.delete(); + }).then(() => { + throw new Error('expected volume.delete to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + return volume.list(); + }).then(() => { + throw new Error('expected volume.list to fail'); + }, err => { + err.name.should.eql('NotImplementedError'); + }); + }); + + it('maps injection rule CRUD APIs with Qiniu signing', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/injection-rules') { + res.statusCode = 200; + res.end(JSON.stringify([{ id: 'rule_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/injection-rules/rule_1') { + res.statusCode = 200; + res.end(JSON.stringify({ id: 'rule_1' })); + return; + } + if (req.method === 'PUT' && req.url === '/injection-rules/rule_1') { + res.statusCode = 200; + res.end(JSON.stringify({ id: 'rule_1', name: 'updated' })); + return; + } + if (req.method === 'DELETE' && req.url === '/injection-rules/rule_1') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + mac, + endpoint: fixture.endpoint + }); + + return client.listInjectionRules() + .then(() => client.getInjectionRule('rule_1')) + .then(() => client.updateInjectionRule('rule_1', { name: 'updated' })) + .then(() => client.deleteInjectionRule('rule_1')) + .then(() => { + fixture.requests.length.should.eql(4); + fixture.requests.forEach(req => { + should(req.headers.authorization).startWith('Qiniu ak:'); + }); + JSON.parse(fixture.requests[2].body).should.eql({ name: 'updated' }); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('normalizes snake_case sandbox info and camelCase injection inputs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.end(JSON.stringify({ + sandbox_id: 'sbx_snake', + sandbox_domain: 'snake.example.com' + })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_snake') { + res.statusCode = 200; + res.end(JSON.stringify({ + sandbox_id: 'sbx_snake', + domain: 'snake.example.com', + envd_access_token: 'snake-token', + envd_version: '1.2.3' + })); + return; + } + if (req.method === 'POST' && req.url === '/injection-rules') { + res.statusCode = 201; + res.end(JSON.stringify({ ruleID: 'rule_1' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const mac = new qiniu.auth.digest.Mac('ak', 'sk', { + disableQiniuTimestampSignature: true + }); + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key', + mac + }); + + return qiniu.sandbox.Sandbox.create({ client, template: 'base' }) + .then(sandbox => { + sandbox.sandboxId.should.eql('sbx_snake'); + sandbox.envdAccessToken.should.eql('snake-token'); + sandbox.envdVersion.should.eql('1.2.3'); + return client.createInjectionRule({ + name: 'qiniu', + injection: { + type: 'qiniu', + baseUrl: 'https://api.qnaigc.com', + apiKey: 'secret' + } + }); + }) + .then(() => { + JSON.parse(fixture.requests[2].body).should.eql({ + name: 'qiniu', + injection: { + type: 'qiniu', + base_url: 'https://api.qnaigc.com', + api_key: 'secret' + } + }); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B style sandbox paginator, snapshots, and MCP helpers', function () { + let tokenReads = 0; + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=2&nextToken=n1&metadata%5Buser%5D=alice&state=running') { + res.statusCode = 200; + res.end(JSON.stringify({ + items: [{ sandboxID: 'sbx_page', domain: 'page.example.com' }], + nextToken: 'n2' + })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_page/snapshots') { + res.statusCode = 201; + res.end(JSON.stringify({ snapshotID: 'snap_1', snapshotId: 'snap_1' })); + return; + } + if (req.method === 'GET' && req.url === '/snapshots?limit=1&sandboxId=sbx_page') { + res.statusCode = 200; + res.end(JSON.stringify({ + items: [{ snapshotID: 'snap_1', snapshotId: 'snap_1' }], + nextToken: 'snap_next' + })); + return; + } + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files' && parsed.searchParams.get('path') === '/etc/mcp-gateway/.token') { + tokenReads += 1; + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('mcp-token\n'); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + const paginator = qiniu.sandbox.Sandbox.list({ + client, + limit: 2, + nextToken: 'n1', + query: { + metadata: { user: 'alice' }, + state: ['running'] + } + }); + + return paginator.nextItems().then(items => { + items[0].sandboxId.should.eql('sbx_page'); + paginator.hasNext.should.eql(true); + paginator.nextToken.should.eql('n2'); + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_page', + envdUrl: fixture.endpoint, + info: { + domain: 'page.example.com', + envdAccessToken: 'token' + }, + client + }); + sandbox.getMcpUrl().should.eql('https://50005-sbx_page.page.example.com/mcp'); + return Promise.all([ + sandbox.getMcpToken(), + sandbox.getMcpToken() + ]).then(tokens => { + tokens.should.eql(['mcp-token', 'mcp-token']); + tokenReads.should.eql(1); + return sandbox.getMcpToken(); + }).then(token => { + token.should.eql('mcp-token'); + tokenReads.should.eql(1); + return sandbox.createSnapshot({ name: 'snap' }); + }).then(snapshot => { + snapshot.snapshotId.should.eql('snap_1'); + return sandbox.listSnapshots({ limit: 1 }).nextItems(); + }); + }).then(snapshots => { + snapshots[0].snapshotId.should.eql('snap_1'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('exports sandbox constants and typed helpers aligned with common runtime names', function () { + qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(300000); + qiniu.sandbox.FileType.FILE.should.eql('file'); + qiniu.sandbox.FileType.DIR.should.eql('dir'); + qiniu.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS); + qiniu.FileType.should.equal(qiniu.sandbox.FileType); + new qiniu.sandbox.InvalidArgumentError('bad arg').name.should.eql('InvalidArgumentError'); + new qiniu.sandbox.FileNotFoundError('missing').name.should.eql('FileNotFoundError'); + }); + + it('supports instance connect and betaPause aliases', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ + sandboxID: 'sbx_alias', + domain: 'alias.example.com', + envdAccessToken: 'token2', + envdVersion: '0.5.7' + })); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/pause') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_alias', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.connect({ timeoutMs: 30000 }) + .then(connected => { + connected.should.equal(sandbox); + sandbox.envdAccessToken.should.eql('token2'); + sandbox.envdVersion.should.eql('0.5.7'); + sandbox.domain.should.eql('alias.example.com'); + return sandbox.betaPause(); + }) + .then(paused => { + should(paused).equal(null); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects missing sandbox ids and encodes nested template file paths', function () { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: 'http://sandbox.test', + apiKey: 'sandbox-key' + }); + const requests = []; + client.httpClient.sendRequest = req => { + requests.push(req); + return Promise.resolve({ + ok: () => true, + data: { ok: true } + }); + }; + + return client.getSandbox('').then(() => { + throw new Error('expected getSandbox to reject missing id'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + return client.deleteSandbox(''); + }).then(() => { + throw new Error('expected deleteSandbox to reject missing id'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.eql('sandboxID is required'); + return client.getSandboxLogs('sbx/with space', { cursor: 'next/page' }); + }).then(() => client.getTemplateFiles('tpl/with space', 'dir/file hash')) + .then(() => { + requests.map(req => req.url).should.eql([ + 'http://sandbox.test/sandboxes/sbx%2Fwith%20space/logs?cursor=next%2Fpage', + 'http://sandbox.test/templates/tpl%2Fwith%20space/files/dir%2Ffile%20hash' + ]); + }); + }); +}); diff --git a/test/00_sandbox_commands.test.js b/test/00_sandbox_commands.test.js new file mode 100644 index 0000000..25937be --- /dev/null +++ b/test/00_sandbox_commands.test.js @@ -0,0 +1,1049 @@ +const { + should, + http, + qiniu, + startServer, + closeServer, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader, + handleGitAndPty +} = require('./sandbox_helpers'); + +describe('test sandbox commands module', function () { + it('runs commands and git operations through process RPC', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.cmd.should.eql('/bin/bash'); + body.process.args.should.eql(['-l', '-c', 'git status --porcelain=v1 -b']); + body.process.cwd.should.eql('/repo'); + req.headers['content-type'].should.eql('application/connect+json'); + req.headers['keepalive-ping-interval'].should.eql('50'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 101 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('## main\n M a.txt\n?? b.txt\n').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.git.status('/repo').then(status => { + status.currentBranch.should.eql('main'); + status.changedFiles.should.eql(['a.txt']); + status.untrackedFiles.should.eql(['b.txt']); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('passes git config options to git commands', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.args.should.eql([ + '-l', + '-c', + 'git -c \'http.version=HTTP/1.1\' clone \'https://example.com/repo.git\' --depth \'1\' --branch \'main\' \'/repo\'' + ]); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 103 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6c', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.git.clone('https://example.com/repo.git', { + path: '/repo', + depth: 1, + branch: 'main', + config: { + 'http.version': 'HTTP/1.1' + } + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('decodes base64 process byte fields from Connect JSON', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 102 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('hello sandbox').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_6b', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('cat /tmp/hello.txt') + .then(result => { + result.stdout.should.eql('hello sandbox'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers command management, callbacks, background handles, pty, and git wrappers', function () { + const commandsSeen = []; + const fakeCommands = { + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ + exitCode: 0, + stdout: 'value\n', + stderr: '' + }); + }, + start: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ pid: 9, wait: function () {} }); + } + }; + const git = new qiniu.sandbox.Git(fakeCommands); + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 12 } } }), + encodeConnectEnvelope({ event: { data: { stdout: [111, 117, 116], stderr: [101, 114, 114] } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 2, error: 'boom' } } }) + ])); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/process.Process/List') { + res.end(JSON.stringify({ + processes: [ + { pid: 1, tag: 't', config: { cmd: 'bash', args: ['-l'], envs: { A: '1' }, cwd: '/w' } } + ] + })); + return; + } + res.end('{}'); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_8', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const seen = []; + + return sandbox.commands.run('echo hi', { + cwd: '/work', + envs: { A: '1' }, + tag: 'tag1', + stdin: true, + onStdout: data => seen.push('out:' + data), + onStderr: data => seen.push('err:' + data) + }).then(result => { + result.exitCode.should.eql(2); + result.error.should.eql('boom'); + seen.should.eql(['out:out', 'err:err']); + const firstStartBody = decodeConnectEnvelope(fixture.requests[0].rawBody); + firstStartBody.process.cwd.should.eql('/work'); + firstStartBody.process.envs.should.eql({ A: '1' }); + firstStartBody.tag.should.eql('tag1'); + firstStartBody.stdin.should.eql(true); + return sandbox.commands.run('sleep 1', { background: true }); + }).then(handle => { + handle.pid.should.eql(12); + return sandbox.commands.list(); + }).then(list => { + list[0].cwd.should.eql('/w'); + return sandbox.commands.sendStdin(12, Buffer.from('hello')); + }).then(() => { + const sendStdinBody = JSON.parse(fixture.requests[3].body); + sendStdinBody.input.stdin.should.eql(Buffer.from('hello').toString('base64')); + return sandbox.commands.closeStdin(12); + }) + .then(() => sandbox.commands.kill(12)) + .then(() => handleGitAndPty(git, sandbox.pty, commandsSeen, fixture)) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns command background handles before the process stream ends', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 88 } } })); + return; + } + if (req.url === '/process.Process/SendSignal') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_live_cmd', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const seen = []; + + return sandbox.commands.run('sleep 5', { + background: true, + user: 'root', + requestTimeoutMs: 1000, + onStdout: data => seen.push(data) + }).then(handle => { + handle.pid.should.eql(88); + should.not.exist(handle.result); + return handle.kill().then(() => handle); + }).then(handle => { + fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); + commandResponse.write(encodeConnectEnvelope({ + event: { + data: { + stdout: Buffer.from('ready').toString('base64') + } + } + })); + commandResponse.end(encodeConnectEnvelope({ event: { end: { exitCode: 0 } } })); + return handle.wait(); + }).then(result => { + result.stdout.should.eql('ready'); + seen.should.eql(['ready']); + }).then(() => closeServer(fixture.server), err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects malformed command stream and JSON fallback payloads', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + if (req.url === '/process.Process/Start' && calls === 1) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeRawConnectEnvelope('not-json')); + return; + } + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('not-json'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(() => { + throw new Error('expected command stream parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + return sandbox.commands.start('echo bad'); + }).then(() => { + throw new Error('expected command JSON fallback parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command wait when Connect end-stream carries an error', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 91 } } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'stream failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(91); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('stream failed'); + err.code.should.eql('internal'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects oversized command stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo huge').then(() => { + throw new Error('expected command stream to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command start when the process stream does not start before timeout', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.flushHeaders(); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_timeout', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 5', { + requestTimeoutMs: 5 + }).then(() => { + throw new Error('expected command start to time out'); + }, err => { + err.message.should.eql('Command stream start timed out'); + if (commandResponse) { + commandResponse.end(); + } + }).then(() => closeServer(fixture.server), err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats command timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 81 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 1', { + timeout: 1 + }).then(handle => { + handle.pid.should.eql(81); + return handle.wait(); + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not reject command wait after disconnecting the live stream', function () { + let commandResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + commandResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 83 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_disconnect', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('sleep 30').then(handle => { + return handle.disconnect() + .then(() => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + }); + }).then(() => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (commandResponse) { + commandResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command wait when the live Connect stream ends with a partial frame after start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 82 } } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(82); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects command wait when the live Connect stream ends before process end', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: { pid: 84 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd_missing_end', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.start('echo bad').then(handle => { + handle.pid.should.eql(84); + return handle.wait(); + }).then(() => { + throw new Error('expected command wait to reject'); + }, err => { + err.message.should.eql('Command stream ended before process end'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses configured HTTP agents for live envd streams', function () { + const agent = new http.Agent(); + const requestsThroughAgent = []; + const originalAddRequest = agent.addRequest; + agent.addRequest = function (req, options) { + requestsThroughAgent.push(options.path); + return originalAddRequest.call(this, req, options); + }; + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 83 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: {} } })); + return; + } + if (req.url === '/filesystem.Filesystem/Stat') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + result: { + entry: { name: 'agent.txt', path: '/agent.txt', type: 'FILE_TYPE_FILE', size: 5 } + } + })); + return; + } + if (req.method === 'GET' && req.url.indexOf('/files') === 0) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end('agent'); + return; + } + if (req.method === 'POST' && req.url.indexOf('/files') === 0) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'agent.txt', path: '/agent.txt', type: 'file' }])); + return; + } + if (req.url === '/health') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_agent', + envdUrl: fixture.endpoint, + httpAgent: agent, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.commands.start('echo ok') + .then(handle => handle.wait()) + .then(() => sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + })) + .then(handle => { + handle._stopped.should.eql(true); + return sandbox.files.getInfo('/agent.txt'); + }) + .then(info => { + info.path.should.eql('/agent.txt'); + return sandbox.files.readText('/agent.txt'); + }) + .then(text => { + text.should.eql('agent'); + return sandbox.files.write('/agent.txt', 'agent'); + }) + .then(info => { + info.path.should.eql('/agent.txt'); + return sandbox.isRunning(); + }) + .then(running => { + running.should.eql(true); + return sandbox.pty.create({ + cols: 80, + rows: 24 + }); + }) + .then(handle => handle.wait()) + .then(() => { + requestsThroughAgent[0].should.eql('/process.Process/Start'); + requestsThroughAgent[1].should.eql('/filesystem.Filesystem/WatchDir'); + requestsThroughAgent[2].should.eql('/filesystem.Filesystem/Stat'); + requestsThroughAgent[3].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); + requestsThroughAgent[4].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); + requestsThroughAgent[5].should.eql('/health'); + requestsThroughAgent[6].should.eql('/process.Process/Start'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }).then(result => { + agent.destroy(); + return result; + }, err => { + agent.destroy(); + throw err; + }); + }); + + it('supports JSON fallback for process stream responses and poll timeout errors', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.flushHeaders(); + setTimeout(() => { + res.end(JSON.stringify({ + events: [ + { event: { start: { pid: 22 } } }, + { event: { data: { stdout: Buffer.from('ok').toString('base64') } } }, + { event: { end: { exitCode: 0 } } } + ] + })); + }, 20); + return; + } + if (req.url === '/sandboxes/sbx_pending') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_pending', state: 'pending' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pending', + envdUrl: fixture.endpoint, + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.commands.run('echo ok', { timeoutMs: 5 }) + .then(result => { + result.stdout.should.eql('ok'); + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 5 }); + }) + .then(() => { + throw new Error('expected waitForReady timeout'); + }, err => { + err.name.should.eql('TimeoutError'); + err.message.should.eql('Sandbox poll timed out'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats envd RPC timeout alias as seconds', function () { + const envd = require('../qiniu/sandbox/envd'); + return startServer((req, res) => { + if (req.url === '/test.RPC/Call') { + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ result: { ok: true } })); + }, 20); + return; + } + if (req.url === '/test.RPC/Stream') { + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { ok: true } })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_envd_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return envd.connectRPC(sandbox, '/test.RPC/Call', {}, { + timeout: 1 + }).then(result => { + result.should.eql({ ok: true }); + return envd.connectStreamRPC(sandbox, '/test.RPC/Stream', {}, { + timeout: 1 + }); + }).then(events => { + events.should.eql([{ event: { ok: true } }]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects truncated buffered Connect stream responses', function () { + try { + require('../qiniu/sandbox/envd').decodeConnectEnvelopes(encodeTruncatedConnectHeader()); + throw new Error('expected buffered decoder to reject truncated stream'); + } catch (err) { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + } + + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeTruncatedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_truncated', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('echo bad').then(() => { + throw new Error('expected truncated stream error'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports process stream JSON array and single event fallback responses', function () { + let calls = 0; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + calls += 1; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (calls === 1) { + res.end(JSON.stringify([ + { event: { start: { pid: 31 } } }, + { event: { data: { stdout: Buffer.from('array').toString('base64') } } }, + { event: { end: { exitCode: 0 } } } + ])); + return; + } + res.end(JSON.stringify({ + event: { + end: { + exitCode: 0 + } + } + })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_json_fallback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.run('echo array') + .then(result => { + result.pid.should.eql(31); + result.stdout.should.eql('array'); + return sandbox.commands.run('true'); + }) + .then(result => { + result.exitCode.should.eql(0); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B command timeout aliases and optional exit throwing', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 77 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('out').toString('base64'), stderr: Buffer.from('err').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 7 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_cmd', + envdUrl: fixture.endpoint, + envdAccessToken: 'token', + info: {} + }); + + return sandbox.commands.run('false', { + requestTimeoutMs: 12000 + }).then(result => { + result.exitCode.should.eql(7); + result.stdout.should.eql('out'); + result.stderr.should.eql('err'); + return sandbox.commands.run('false', { + requestTimeoutMs: 12000, + throwOnError: true + }); + }).then(() => { + throw new Error('expected command to throw'); + }, err => { + err.name.should.eql('CommandExitError'); + err.exitCode.should.eql(7); + err.stdout.should.eql('out'); + err.stderr.should.eql('err'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports commands.connect with E2B style command handle semantics', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Connect') { + const body = decodeConnectEnvelope(req.rawBody); + body.process.selector.pid.should.eql(55); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 55 } } }), + encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('connected').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_connect_cmd', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.commands.connect(55, { + requestTimeoutMs: 9000 + }).then(handle => { + handle.pid.should.eql(55); + return handle.wait(); + }).then(result => { + result.stdout.should.eql('connected'); + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('parses envd stream fallback responses shaped as events, arrays, and single events', function () { + const envd = require('../qiniu/sandbox/envd'); + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/stream.EventsObject') { + res.end(JSON.stringify({ + events: [{ event: { start: { pid: 1 } } }] + })); + return; + } + if (req.url === '/stream.Array') { + res.end(JSON.stringify([{ event: { end: { exitCode: 0 } } }])); + return; + } + if (req.url === '/stream.Single') { + res.end(JSON.stringify({ event: { data: { stdout: 'aGVsbG8=' } } })); + return; + } + if (req.url === '/stream.Empty') { + res.end(JSON.stringify({ ok: true })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_envd_fallbacks', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return envd.connectStreamRPC(sandbox, '/stream.EventsObject', {}) + .then(events => { + events.should.eql([{ event: { start: { pid: 1 } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Array', {}); + }) + .then(events => { + events.should.eql([{ event: { end: { exitCode: 0 } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Single', {}); + }) + .then(events => { + events.should.eql([{ event: { data: { stdout: 'aGVsbG8=' } } }]); + return envd.connectStreamRPC(sandbox, '/stream.Empty', {}); + }) + .then(events => { + events.should.eql([]); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); +}); diff --git a/test/00_sandbox_facade.test.js b/test/00_sandbox_facade.test.js new file mode 100644 index 0000000..4b9d50d --- /dev/null +++ b/test/00_sandbox_facade.test.js @@ -0,0 +1,267 @@ +const { + qiniu, + startServer, + closeServer +} = require('./sandbox_helpers'); + +describe('test sandbox facade module', function () { + it('covers Sandbox.connect, Sandbox.list, wait polling, and stopped health checks', function () { + let infoCalls = 0; + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'POST' && req.url === '/sandboxes/sbx_9/connect') { + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' })); + return; + } + if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=1') { + res.statusCode = 200; + res.end(JSON.stringify([{ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' }])); + return; + } + if (req.method === 'POST' && req.url === '/sandboxes') { + res.statusCode = 201; + res.end(JSON.stringify({ sandboxID: 'sbx_10', envdAccessToken: 'token' })); + return; + } + if (req.method === 'GET' && req.url === '/sandboxes/sbx_10') { + infoCalls += 1; + res.statusCode = 200; + res.end(JSON.stringify({ sandboxID: 'sbx_10', state: infoCalls > 1 ? 'running' : 'pending' })); + return; + } + if (req.method === 'GET' && req.url === '/health') { + res.statusCode = 502; + res.end(JSON.stringify({ message: 'stopped' })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint + }); + + return qiniu.sandbox.Sandbox.connect('sbx_9', { + client, + timeout: 12 + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_9'); + sandbox.envdAccessToken.should.eql('token'); + return qiniu.sandbox.Sandbox.list({ client, limit: 1 }); + }).then(sandboxes => { + sandboxes[0].sandboxId.should.eql('sbx_9'); + return client.createAndWait({ template: 'base' }, { intervalMs: 1, timeoutMs: 100 }); + }).then(sandbox => { + sandbox.sandboxId.should.eql('sbx_10'); + sandbox.envdAccessToken.should.eql('token'); + infoCalls.should.eql(2); + const stopped = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_11', + envdUrl: fixture.endpoint, + info: {} + }); + return stopped.isRunning(); + }).then(running => { + running.should.eql(false); + return closeServer(fixture.server); + }, err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('retries transient waitForReady polling errors before timeout', function () { + let infoCalls = 0; + return startServer((req, res) => { + if (req.method === 'GET' && req.url === '/sandboxes/sbx_retry') { + infoCalls += 1; + if (infoCalls === 1) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'temporary' })); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_retry', state: 'running' })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_retry', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ + intervalMs: 1, + timeoutMs: 50 + }).then(info => { + info.state.should.eql('running'); + infoCalls.should.eql(2); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats waitForReady timeout alias as seconds while polling', function () { + let infoCalls = 0; + return startServer((req, res) => { + if (req.method === 'GET' && req.url === '/sandboxes/sbx_poll_seconds') { + infoCalls += 1; + if (infoCalls === 1) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'starting' })); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'running' })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_poll_seconds', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ + intervalMs: 1, + timeout: 1 + }).then(info => { + info.state.should.eql('running'); + infoCalls.should.eql(2); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not retry fatal client errors while polling', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'missing' })); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_missing', + client: new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }), + info: {} + }); + + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { + throw new Error('expected waitForReady to fail'); + }, err => { + err.response.statusCode.should.eql(404); + calls.should.eql(1); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not retry programming errors while polling', function () { + let calls = 0; + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_programming_error', + info: {} + }); + sandbox.getInfo = function () { + calls += 1; + return Promise.reject(new TypeError('bad poll logic')); + }; + + return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { + throw new Error('expected waitForReady to fail'); + }, err => { + err.message.should.eql('bad poll logic'); + calls.should.eql(1); + }); + }); + + it('returns false for transient envd gateway health errors and rethrows others', function () { + let calls = 0; + return startServer((req, res) => { + calls += 1; + if (calls === 1) { + res.statusCode = 503; + res.end('starting'); + return; + } + if (calls === 2) { + res.statusCode = 504; + res.end('timeout'); + return; + } + res.statusCode = 500; + res.end('broken'); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_health_error', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.isRunning().then(running => { + running.should.eql(false); + return sandbox.isRunning(); + }).then(running => { + running.should.eql(false); + return sandbox.isRunning(); + }).then(() => { + throw new Error('expected health error'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.containEql('500'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns false from isRunning on connection failures', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_down', + envdUrl: 'http://127.0.0.1:9', + info: {} + }); + + return sandbox.isRunning().then(running => { + running.should.eql(false); + }); + }); +}); diff --git a/test/00_sandbox_filesystem.test.js b/test/00_sandbox_filesystem.test.js new file mode 100644 index 0000000..f91119c --- /dev/null +++ b/test/00_sandbox_filesystem.test.js @@ -0,0 +1,980 @@ +const { + should, + stream, + zlib, + qiniu, + startServer, + closeServer, + parseUrl, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader +} = require('./sandbox_helpers'); + +describe('test sandbox filesystem module', function () { + it('builds envd hosts and signed file urls', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_3', + info: { + domain: 'sandbox.example.com', + envdAccessToken: 'token' + } + }); + + sandbox.getHost(8080).should.eql('8080-sbx_3.sandbox.example.com'); + + const parsed = parseUrl(sandbox.downloadUrl('/home/user/a.txt', { + user: 'admin', + signatureExpiration: 60 + })); + parsed.protocol.should.eql('https:'); + parsed.host.should.eql('49983-sbx_3.sandbox.example.com'); + parsed.pathname.should.eql('/files'); + parsed.searchParams.get('path').should.eql('/home/user/a.txt'); + parsed.searchParams.get('username').should.eql('admin'); + const expiration = Number(parsed.searchParams.get('signature_expiration')); + expiration.should.be.above(Math.floor(Date.now() / 1000)); + expiration.should.be.below(Math.floor(Date.now() / 1000) + 120); + should(parsed.searchParams.get('signature')).startWith('v1_'); + + const absolute = parseUrl(sandbox.uploadUrl('/home/user/a.txt', { + signatureExpiration: 2000000000 + })); + absolute.searchParams.get('signature_expiration').should.eql('2000000000'); + }); + + it('reads and writes files through envd HTTP API', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/hello.txt'); + parsed.searchParams.get('username').should.eql('user'); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('hello'); + return; + } + + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/hello.txt'); + should(req.headers['content-type']).startWith('multipart/form-data; boundary='); + req.body.should.containEql('filename="/hello.txt"'); + req.body.should.containEql('hello'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'hello.txt', path: '/hello.txt', type: 'file' }])); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_4', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.readText('/hello.txt').then(text => { + text.should.eql('hello'); + return sandbox.files.write('/hello.txt', 'hello'); + }).then(info => { + info.path.should.eql('/hello.txt'); + fixture.requests.map(req => `${req.method} ${parseUrl(req.url).pathname}`).should.eql([ + 'GET /files', + 'POST /files' + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('reads files as text, bytes, blob, and stream formats', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(Buffer.from('hello')); + return; + } + + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_files', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/tmp/a.txt') + .then(text => { + text.should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'bytes' }); + }) + .then(bytes => { + Buffer.isBuffer(bytes).should.eql(true); + bytes.toString().should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'stream' }); + }) + .then(stream => { + (typeof stream.pipe).should.eql('function'); + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString())); + stream.on('error', reject); + }); + }) + .then(streamText => { + streamText.should.eql('hello'); + return sandbox.files.read('/tmp/a.txt', { format: 'blob' }); + }) + .then(blob => { + if (typeof global.Blob !== 'undefined') { + blob.should.be.instanceOf(global.Blob); + } else { + Buffer.isBuffer(blob).should.eql(true); + } + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('escapes file paths in multipart filenames', function () { + const unsafePath = '/tmp/a"\r\nX-Injected: y.txt'; + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql(unsafePath); + req.body.should.containEql('filename="/tmp/a\\"%0D%0AX-Injected: y.txt"'); + req.body.should.not.containEql('\r\nX-Injected'); + req.body.should.not.containEql('a"\r\n'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'a.txt', path: unsafePath, type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_multipart_safe', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.5' + } + }); + + return sandbox.files.write(unsafePath, 'hello') + .then(info => { + info.path.should.eql(unsafePath); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects Readable streams passed directly to filesystem write', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_stream_write', + envdUrl: 'http://127.0.0.1:9', + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.write('/stream.txt', new stream.Readable({ + read: function () {} + })).then(() => { + throw new Error('expected stream write to reject'); + }, err => { + err.message.should.eql('Streams are not supported as data in filesystem.write'); + }); + }); + + it('preserves falsy multipart payload values', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path').should.eql('/zero.txt'); + req.body.should.containEql('\r\n0\r\n'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zero.txt', path: '/zero.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_multipart_falsy', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.5' + } + }); + + return sandbox.files.write('/zero.txt', 0) + .then(info => { + info.path.should.eql('/zero.txt'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses Connect RPC paths for filesystem metadata operations', function () { + return startServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/filesystem.Filesystem/Stat') { + res.end(JSON.stringify({ + entry: { name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 } + })); + return; + } + if (req.url === '/filesystem.Filesystem/ListDir') { + res.end(JSON.stringify({ + entries: [{ name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 }] + })); + return; + } + if (req.url === '/filesystem.Filesystem/MakeDir') { + res.end(JSON.stringify({ + entry: { name: 'tmp', path: '/tmp', type: 'FILE_TYPE_DIRECTORY' } + })); + return; + } + if (req.url === '/filesystem.Filesystem/Move') { + res.end(JSON.stringify({ + entry: { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } + })); + return; + } + if (req.url === '/filesystem.Filesystem/Remove') { + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_5', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.getInfo('/hello.txt') + .then(info => { + info.type.should.eql('file'); + return sandbox.files.list('/'); + }) + .then(entries => { + entries[0].name.should.eql('hello.txt'); + return sandbox.files.makeDir('/tmp'); + }) + .then(info => { + info.type.should.eql('dir'); + return sandbox.files.rename('/a.txt', '/b.txt'); + }) + .then(info => { + info.path.should.eql('/b.txt'); + return sandbox.files.remove('/b.txt'); + }) + .then(() => { + fixture.requests.map(req => req.url).should.eql([ + '/filesystem.Filesystem/Stat', + '/filesystem.Filesystem/ListDir', + '/filesystem.Filesystem/MakeDir', + '/filesystem.Filesystem/Move', + '/filesystem.Filesystem/Remove' + ]); + fixture.requests[0].headers.authorization.should.eql('Basic dXNlcjo='); + fixture.requests[0].headers['x-access-token'].should.eql('token'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('watches directory changes and returns a stoppable handle after start event', function () { + let watchResponse; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + const body = decodeConnectEnvelope(req.rawBody); + body.path.should.eql('/workspace'); + body.recursive.should.eql(true); + req.headers['content-type'].should.eql('application/connect+json'); + req.headers['keepalive-ping-interval'].should.eql('50'); + watchResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: {} } })); + setTimeout(() => { + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + })); + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'written.txt', + type: 2 + } + } + })); + res.write(encodeConnectEnvelope({ event: { keepalive: {} } })); + }, 10); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const events = []; + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', event => { + events.push(event); + }, { + recursive: true, + requestTimeoutMs: 1000 + }).then(handle => { + (typeof handle.stop).should.eql('function'); + return new Promise(resolve => setTimeout(resolve, 40)).then(() => { + events.should.eql([ + { name: 'created.txt', type: qiniu.sandbox.FilesystemEventType.CREATE }, + { name: 'written.txt', type: qiniu.sandbox.FilesystemEventType.WRITE } + ]); + return handle.stop(); + }); + }).then(() => { + if (watchResponse) { + watchResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (watchResponse) { + watchResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects recursive directory watching on envd versions without support', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_old_watch', + envdUrl: 'http://127.0.0.1:9', + info: { + envdVersion: '0.1.3' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject'); + }, err => { + err.message.should.match(/recursive watching/i); + }); + }); + + it('rejects recursive directory watching when envd version is unknown', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_unknown_watch', + envdUrl: 'http://127.0.0.1:9', + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject without envd version'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.match(/recursive watching/i); + }); + }); + + it('returns false from exists for err.resp 404 responses', function () { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_exists_resp', + envdUrl: 'http://127.0.0.1:9', + info: {} + }); + sandbox.files.getInfo = function () { + const err = new Error('missing'); + err.resp = { statusCode: 404 }; + return Promise.reject(err); + }; + return sandbox.files.exists('/missing').then(exists => { + exists.should.eql(false); + }); + }); + + it('rejects malformed filesystem watch stream payloads', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeRawConnectEnvelope('not-json') + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + let handle; + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + err.message.should.match(/Unexpected token/); + } + }).then(ret => { + handle = ret; + return new Promise(resolve => setTimeout(resolve, 20)); + }).then(() => { + handle._stopped.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects oversized filesystem watch stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true + }).then(() => { + throw new Error('expected watchDir to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers filesystem writeFiles, bytes, exists false, and envd health', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(Buffer.from([1, 2, 3])); + return; + } + if (req.method === 'POST' && parsed.pathname === '/files') { + parsed.searchParams.get('path') === null ? true.should.eql(true) : false.should.eql(true); + parsed.searchParams.get('username').should.eql('user'); + should(parsed.searchParams.get('signature')).startWith('v1_'); + Number(parsed.searchParams.get('signature_expiration')).should.be.above(Math.floor(Date.now() / 1000)); + req.body.should.containEql('/a.txt'); + req.body.should.containEql('/b.txt'); + req.rawBody.includes(Buffer.from([1, 2, 3])).should.eql(true); + req.rawBody.includes(Buffer.from([4, 5, 6])).should.eql(true); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([ + { name: 'a.txt', path: '/a.txt', type: 'FILE_TYPE_FILE' }, + { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } + ])); + return; + } + if (req.method === 'POST' && req.url === '/filesystem.Filesystem/Stat') { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ message: 'not found' })); + return; + } + if (req.method === 'GET' && req.url === '/health') { + res.statusCode = 204; + res.end(); + return; + } + res.statusCode = 500; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_7', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/bin', { format: 'bytes' }) + .then(data => { + Buffer.isBuffer(data).should.eql(true); + data.length.should.eql(3); + return sandbox.files.writeFiles([ + { path: '/a.txt', data: new Uint8Array([1, 2, 3]) }, + { path: '/b.txt', data: new Uint8Array([4, 5, 6]).buffer } + ], { user: 'user' }); + }) + .then(entries => { + entries.map(entry => entry.path).should.eql(['/a.txt', '/b.txt']); + return sandbox.files.writeFiles(null).then(() => { + throw new Error('expected writeFiles invalid files to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.eql('files must be an array'); + }); + }) + .then(() => { + return sandbox.files.writeFiles([null]).then(() => { + throw new Error('expected writeFiles invalid file item to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.eql('Each file must be an object'); + }); + }) + .then(() => { + return sandbox.files.writeFiles([ + { path: '/stream.txt', data: new stream.Readable() } + ]).then(() => { + throw new Error('expected writeFiles stream data to fail'); + }, err => { + err.name.should.eql('TypeError'); + err.message.should.match(/Streams are not supported/); + }); + }) + .then(() => { + return sandbox.files.exists('/missing.txt'); + }) + .then(exists => { + exists.should.eql(false); + return sandbox.isRunning(); + }) + .then(running => { + running.should.eql(true); + sandbox.getHost(1234).should.eql(''); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir on Connect end-stream errors after start', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'watch failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('watch failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the event callback throws', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: {} } })); + res.write(encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + })); + setTimeout(() => res.end(), 50); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_callback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => { + throw new Error('callback failed'); + }, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('callback failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the event callback rejects asynchronously', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeConnectEnvelope({ + event: { + filesystem: { + name: 'created.txt', + type: 'EVENT_TYPE_CREATE' + } + } + }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_async_callback', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => Promise.reject(new Error('async callback failed')), { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('async callback failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('handles rejected async watchDir onExit callbacks', function () { + let exitCalled = false; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: {} } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_async_exit', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: () => { + exitCalled = true; + return Promise.reject(new Error('async exit failed')); + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitCalled.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('fails watchDir when the live Connect stream ends with a partial frame after start', function () { + let exitError; + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: {} } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + onExit: err => { + exitError = err; + } + }).then(handle => { + return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); + }).then(handle => { + handle._stopped.should.eql(true); + exitError.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats watchDir timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/filesystem.Filesystem/WatchDir') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(encodeConnectEnvelope({ event: { start: {} } })); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_watch_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token', + envdVersion: '0.5.7' + } + }); + + return sandbox.files.watchDir('/workspace', () => {}, { + recursive: true, + timeout: 1 + }).then(handle => { + handle._stopped.should.eql(true); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports filesystem gzip and octet-stream write compatibility options', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'GET' && parsed.pathname === '/files') { + req.headers['accept-encoding'].should.eql('gzip'); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('zip'); + return; + } + if (req.method === 'POST' && parsed.pathname === '/files') { + req.headers['content-type'].should.eql('application/octet-stream'); + req.headers['content-encoding'].should.eql('gzip'); + parsed.searchParams.get('path').should.eql('/zip.txt'); + Array.from(zlib.gunzipSync(req.rawBody)).should.eql([1, 2, 3]); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_zip', + envdUrl: fixture.endpoint, + info: { + envdVersion: '0.5.7', + envdAccessToken: 'token' + } + }); + + return sandbox.files.read('/zip.txt', { gzip: true }) + .then(text => { + text.should.eql('zip'); + return sandbox.files.write('/zip.txt', new Uint8Array([1, 2, 3]), { + gzip: true, + useOctetStream: true + }); + }) + .then(info => { + info.path.should.eql('/zip.txt'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('falls back to multipart uploads when envd does not support octet-stream', function () { + return startServer((req, res) => { + const parsed = parseUrl(req.url); + if (req.method === 'POST' && parsed.pathname === '/files') { + should(req.headers['content-type']).startWith('multipart/form-data; boundary='); + should.not.exist(req.headers['content-encoding']); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_zip_old', + envdUrl: fixture.endpoint, + info: { + envdVersion: '0.5.5', + envdAccessToken: 'token' + } + }); + + return sandbox.files.write('/zip.txt', 'zip', { + gzip: true, + useOctetStream: true + }).then(info => { + info.path.should.eql('/zip.txt'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); +}); diff --git a/test/00_sandbox_git.test.js b/test/00_sandbox_git.test.js new file mode 100644 index 0000000..7c43926 --- /dev/null +++ b/test/00_sandbox_git.test.js @@ -0,0 +1,471 @@ +const { + should, + qiniu +} = require('./sandbox_helpers'); + +describe('test sandbox git module', function () { + it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('branch ') >= 0 && cmd.indexOf('%(refname:short)') >= 0) { + return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); + } + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ stdout: 'https://github.com/acme/repo.git\n', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://github.com/acme/repo.git', '/repo', { + username: 'u', + password: 'p', + depth: 1, + branch: 'main' + }).then(() => git.branches('/repo')) + .then(branches => { + branches.should.eql([ + { name: 'main', current: true }, + { name: 'feature', current: false } + ]); + return git.reset('/repo', { hard: true, ref: 'HEAD~1' }); + }) + .then(() => git.restore('/repo', { staged: true, paths: ['a.txt'] })) + .then(() => git.reset('/repo', { ref: 'HEAD', paths: 'single.txt' })) + .then(() => git.restore('/repo', { files: 'restore.txt' })) + .then(() => git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git', { overwrite: true, fetch: true })) + .then(() => git.commit('/repo', 'msg', { + authorName: 'Alice', + authorEmail: 'alice@example.com', + allowEmpty: true + })) + .then(() => git.setConfig('/repo', 'user.name', 'Alice', { scope: 'global' })) + .then(() => { + const commandText = commandsSeen.map(item => item.cmd).join('\n'); + commandText.should.containEql('credential.helper='); + commandText.should.containEql('clone \'https://github.com/acme/repo.git\''); + commandText.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + commandText.should.containEql('branch \'--format=%(HEAD) %(refname:short)\''); + commandText.should.containEql('reset --hard \'HEAD~1\''); + commandText.should.containEql('restore --staged -- \'a.txt\''); + commandText.should.containEql('reset \'HEAD\' -- \'single.txt\''); + commandText.should.containEql('restore --worktree -- \'restore.txt\''); + commandText.should.containEql('remote remove \'origin\''); + commandText.should.containEql('remote add \'origin\''); + commandText.should.containEql('fetch \'origin\''); + commandText.should.containEql('commit -m \'msg\' --author \'Alice \' --allow-empty'); + commandText.should.containEql('config --global \'user.name\' \'Alice\''); + }); + }); + + it('returns git remote add result when fetch is not requested', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ + stdout: 'added', + stderr: '', + exitCode: 0 + }); + } + }); + + return git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git').then(result => { + result.should.eql({ + stdout: 'added', + stderr: '', + exitCode: 0 + }); + }); + }); + + it('does not fetch after git remote add fails', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd) { + commandsSeen.push(cmd); + if (cmd.indexOf(' remote add ') >= 0) { + return Promise.resolve({ stdout: '', stderr: 'bad remote', exitCode: 1 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.remoteAdd('/repo', 'origin', 'bad-url', { fetch: true }).then(result => { + result.exitCode.should.eql(1); + commandsSeen.should.eql([ + 'git remote add \'origin\' \'bad-url\'' + ]); + }); + }); + + it('passes git push credentials through a helper when push fails', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf(' push ') >= 0) { + return Promise.reject(new Error('push failed')); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.push('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + throw new Error('expected git push to fail'); + }, err => { + err.message.should.eql('push failed'); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('printf "username=%s\\npassword=%s\\n" "$GIT_USERNAME" "$GIT_PASSWORD"'); + commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('passes git pull credentials through a helper without rewriting remotes', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.pull('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('pull \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('passes git clone credentials through a helper instead of the command line', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://github.com/acme/private.git', { + username: 'u', + password: 'p' + }).then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('clone \'https://github.com/acme/private.git\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('keeps embedded git clone credentials when no helper credentials are provided', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('https://u:p@github.com/acme/private.git').then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('clone \'https://u:p@github.com/acme/private.git\''); + should.not.exist(commandsSeen[0].opts.envs); + }); + }); + + it('passes http git clone credentials through a helper instead of the command line', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.clone('http://git.example.com/acme/private.git', { + username: 'u', + password: 'p' + }).then(() => { + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('clone \'http://git.example.com/acme/private.git\''); + commandsSeen[0].cmd.should.not.containEql('u:p'); + commandsSeen[0].opts.envs.should.eql({ + GIT_USERNAME: 'u', + GIT_PASSWORD: 'p' + }); + }); + }); + + it('rejects unsafe git reset modes', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + try { + git.reset('/repo', { + mode: 'hard; touch /tmp/pwned' + }); + throw new Error('expected git reset to reject unsafe mode'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/Invalid git reset mode/); + } + + try { + git.reset('/repo', { + mode: 'hard', + paths: ['a.txt'] + }); + throw new Error('expected git reset to reject mode with paths'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/mode cannot be used when paths are specified/); + } + + try { + git.restore('/repo'); + throw new Error('expected git restore to reject missing paths'); + } catch (err) { + err.name.should.eql('InvalidArgumentError'); + err.message.should.match(/At least one path/); + } + }); + + it('surfaces git auth and validation errors on auth helpers', function () { + const git = new qiniu.sandbox.Git({ + run: function () { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); + } + }); + + try { + git.push('/repo', { + username: 'u' + }); + throw new Error('expected missing password'); + } catch (err) { + err.name.should.eql('GitAuthError'); + } + + return git.dangerouslyAuthenticate('/repo', 'origin', 'u', 'p').then(() => { + throw new Error('expected missing upstream'); + }, err => { + err.name.should.eql('GitUpstreamError'); + return git.commit('/repo', 'msg', { + authorName: 'Alice' + }); + }).then(() => { + throw new Error('expected missing author email'); + }, err => { + err.name.should.eql('GitAuthError'); + }); + }); + + it('replaces existing git remote credentials when dangerously authenticating', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://old:secret@example.com/acme/repo.git\n', + stderr: '', + exitCode: 0 + }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { + commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); + commandsSeen[1].cmd.should.not.containEql('old:secret'); + }); + }); + + it('replaces token-only git remote credentials when dangerously authenticating', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf('remote get-url') >= 0) { + return Promise.resolve({ + stdout: 'https://old-token@example.com/acme/repo.git\n', + stderr: '', + exitCode: 0 + }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { + commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); + commandsSeen[1].cmd.should.not.containEql('old-token@'); + }); + }); + + it('keeps original git push error without credential cleanup', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (cmd.indexOf(' push ') >= 0) { + return Promise.reject(new Error('push failed')); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.push('/repo', { + username: 'u', + password: 'p', + remote: 'origin', + branch: 'main' + }).then(() => { + throw new Error('expected git push to fail'); + }, err => { + err.message.should.eql('push failed'); + commandsSeen.length.should.eql(1); + commandsSeen[0].cmd.should.containEql('credential.helper='); + commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); + commandsSeen[0].cmd.should.not.containEql('remote set-url'); + }); + }); + + it('normalizes git config helpers when options are omitted', function () { + const commandsSeen = []; + let missingConfig = false; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + if (missingConfig && cmd.indexOf(' config --get ') >= 0) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); + } + return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); + } + }); + + return git.setConfig('user.name', 'Alice') + .then(() => git.getConfig('user.name')) + .then(value => { + value.should.eql('Alice'); + return git.configureUser('Alice', 'alice@example.com'); + }) + .then(() => { + missingConfig = true; + return git.getConfig('missing.name'); + }) + .then(value => { + should.not.exist(value); + }) + .then(() => { + const shellQuote = require('../qiniu/sandbox/util').shellQuote; + commandsSeen.map(item => item.cmd).should.eql([ + 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), + 'git config --get ' + shellQuote('user.name'), + 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), + 'git config ' + shellQuote('user.email') + ' ' + shellQuote('alice@example.com'), + 'git config --get ' + shellQuote('missing.name') + ]); + commandsSeen.forEach(item => { + should.not.exist(item.opts.cwd); + }); + }); + }); + + it('supports E2B style git option signatures for config and restore helpers', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); + } + }); + + return git.setConfig('user.name', 'Alice', { + path: '/repo', + scope: 'local' + }).then(() => git.getConfig('user.name', { + path: '/repo', + scope: 'local' + })).then(value => { + value.should.eql('Alice'); + return git.configureUser('Alice', 'alice@example.com', { + path: '/repo', + scope: 'local' + }); + }).then(() => git.reset('/repo', { + mode: 'hard', + target: 'HEAD~1' + })).then(() => git.restore('/repo', { + paths: ['a.txt'] + })).then(() => { + commandsSeen.map(item => item.cmd).should.eql([ + 'git config --local \'user.name\' \'Alice\'', + 'git config --local --get \'user.name\'', + 'git config --local \'user.name\' \'Alice\'', + 'git config --local \'user.email\' \'alice@example.com\'', + 'git reset --hard \'HEAD~1\'', + 'git restore --worktree -- \'a.txt\'' + ]); + commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); + }); + }); + + it('keeps legacy git configureUser repo path when delegating to config helpers', function () { + const commandsSeen = []; + const git = new qiniu.sandbox.Git({ + run: function (cmd, opts) { + commandsSeen.push({ cmd, opts }); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + }); + + return git.configureUser('/repo', 'Alice', 'alice@example.com', { + config: { + 'http.version': 'HTTP/1.1' + } + }).then(result => { + result.exitCode.should.eql(0); + commandsSeen.map(item => item.cmd).should.eql([ + 'git -c \'http.version=HTTP/1.1\' config \'user.name\' \'Alice\'', + 'git -c \'http.version=HTTP/1.1\' config \'user.email\' \'alice@example.com\'' + ]); + commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); + }); + }); +}); diff --git a/test/00_sandbox_pty.test.js b/test/00_sandbox_pty.test.js new file mode 100644 index 0000000..b560467 --- /dev/null +++ b/test/00_sandbox_pty.test.js @@ -0,0 +1,494 @@ +const { + should, + qiniu, + startServer, + closeServer, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader +} = require('./sandbox_helpers'); + +describe('test sandbox pty module', function () { + it('supports E2B style PTY connect, input, resize, and kill operations', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start' || req.url === '/process.Process/Connect') { + const body = decodeConnectEnvelope(req.rawBody); + if (req.url === '/process.Process/Start') { + body.pty.size.should.eql({ cols: 80, rows: 24 }); + body.process.envs.TERM.should.eql('xterm-256color'); + } else { + body.process.selector.pid.should.eql(44); + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 44 } } }), + encodeConnectEnvelope({ event: { data: { pty: Buffer.from('ok').toString('base64') } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + if (req.url === '/process.Process/SendInput' || req.url === '/process.Process/Update' || req.url === '/process.Process/SendSignal') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('{}'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + const data = []; + + return sandbox.pty.create({ + cols: 80, + rows: 24, + user: 'root', + requestTimeoutMs: 1000, + onData: chunk => data.push(Buffer.from(chunk).toString()) + }).then(handle => { + handle.pid.should.eql(44); + return handle.kill().then(killed => { + killed.should.eql(true); + fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); + return handle.wait(); + }); + }).then(() => sandbox.pty.connect(44, { + onData: chunk => data.push(Buffer.from(chunk).toString()) + })).then(handle => handle.wait()) + .then(() => sandbox.pty.sendInput(44, 123)) + .then(() => sandbox.pty.resize(44, { cols: 100, rows: 30 })) + .then(() => sandbox.pty.kill(44)) + .then(killed => { + killed.should.eql(true); + data.should.eql(['ok', 'ok']); + const sendBody = JSON.parse(fixture.requests[3].body); + sendBody.input.pty.should.eql(Buffer.from('123').toString('base64')); + const resizeBody = JSON.parse(fixture.requests[4].body); + resizeBody.pty.size.should.eql({ cols: 100, rows: 30 }); + const killBody = JSON.parse(fixture.requests[5].body); + killBody.signal.should.eql('SIGNAL_SIGKILL'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects malformed live PTY stream payloads', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeRawConnectEnvelope('not-json')); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_bad_json', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(() => { + throw new Error('expected pty stream parse error'); + }, err => { + err.message.should.match(/Unexpected token/); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('uses live PTY creation for default and args-based create calls', function () { + let starts = 0; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + starts += 1; + const body = decodeConnectEnvelope(req.rawBody); + if (starts === 1) { + body.process.cmd.should.eql('/bin/bash'); + body.process.args.should.eql(['-i', '-l']); + } else { + body.process.cmd.should.eql('node'); + body.process.args.should.eql(['-i']); + } + should.exist(body.pty); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 48 + starts } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_args', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create() + .then(handle => handle.wait()) + .then(() => sandbox.pty.create({ cmd: 'node', args: ['-i'] })) + .then(handle => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + starts.should.eql(2); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects PTY wait when Connect end-stream carries an error', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 45 } } }), + encodeConnectEndEnvelope({ error: { code: 'internal', message: 'pty failed' } }) + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_trailer', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(45); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('pty failed'); + err.code.should.eql('internal'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects PTY wait when the live Connect stream ends with a partial frame after start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 46 } } }), + encodeTruncatedConnectHeader() + ])); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_truncated_tail', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(46); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('Sandbox envd stream truncated unexpectedly'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects PTY wait when the live Connect stream ends before process end', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeConnectEnvelope({ event: { start: { pid: 47 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_missing_end', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + handle.pid.should.eql(47); + return handle.wait(); + }).then(() => { + throw new Error('expected pty wait to reject'); + }, err => { + err.message.should.eql('PTY stream ended before process end'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('does not reject PTY wait after disconnecting the live stream', function () { + let ptyResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + ptyResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.write(encodeConnectEnvelope({ event: { start: { pid: 48 } } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_disconnect', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(handle => { + return handle.disconnect() + .then(() => handle.wait()) + .then(result => { + result.exitCode.should.eql(0); + }); + }).then(() => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server); + }, err => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects oversized PTY stream envelopes', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + res.end(encodeOversizedConnectHeader()); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_huge', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24 + }).then(() => { + throw new Error('expected pty stream to reject oversized frame'); + }, err => { + err.message.should.containEql('envelope too large'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('rejects live PTY start when the process stream does not start before timeout', function () { + let ptyResponse; + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + ptyResponse = res; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_timeout', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24, + requestTimeoutMs: 5 + }).then(() => { + throw new Error('expected pty start to time out'); + }, err => { + err.message.should.eql('PTY stream start timed out'); + if (ptyResponse) { + ptyResponse.end(); + } + }).then(() => closeServer(fixture.server), err => { + if (ptyResponse) { + ptyResponse.end(); + } + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats PTY timeout alias as seconds while waiting for stream start', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/Start') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/connect+json'); + setTimeout(() => { + res.end(Buffer.concat([ + encodeConnectEnvelope({ event: { start: { pid: 47 } } }), + encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) + ])); + }, 20); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_timeout_seconds', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.create({ + cols: 80, + rows: 24, + timeout: 1 + }).then(handle => { + handle.pid.should.eql(47); + return handle.wait(); + }).then(result => { + result.exitCode.should.eql(0); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('returns false for PTY kill 404 responses and rethrows other failures', function () { + return startServer((req, res) => { + if (req.url === '/process.Process/SendSignal') { + const body = JSON.parse(req.body); + if (body.process.selector.pid === 404) { + res.statusCode = 404; + res.end('missing'); + return; + } + res.statusCode = 500; + res.end('boom'); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const sandbox = new qiniu.sandbox.Sandbox({ + sandboxId: 'sbx_pty_kill', + envdUrl: fixture.endpoint, + info: { + envdAccessToken: 'token' + } + }); + + return sandbox.pty.kill(404) + .then(killed => { + killed.should.eql(false); + return sandbox.pty.kill(500); + }) + .then(() => { + throw new Error('expected non-404 PTY kill to reject'); + }, err => { + err.name.should.eql('SandboxError'); + err.message.should.containEql('status 500'); + }) + .then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); +}); diff --git a/test/00_sandbox_template.test.js b/test/00_sandbox_template.test.js new file mode 100644 index 0000000..2c5b156 --- /dev/null +++ b/test/00_sandbox_template.test.js @@ -0,0 +1,551 @@ +const { + should, + fs, + qiniu, + startServer, + closeServer +} = require('./sandbox_helpers'); + +describe('test sandbox template module', function () { + it('maps template, build, tag, and access-token APIs', function () { + return startServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET' && req.url === '/templates?teamID=team') { + res.statusCode = 200; + res.end(JSON.stringify([{ templateID: 'tpl_1' }])); + return; + } + if (req.method === 'GET' && req.url === '/default-templates') { + res.statusCode = 200; + res.end(JSON.stringify([{ templateID: 'base' }])); + return; + } + if (req.method === 'POST' && req.url === '/v3/templates') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b1' })); + return; + } + if (req.method === 'POST' && req.url === '/v2/templates') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_2', buildID: 'b2' })); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1?limit=5&nextToken=n2') { + res.statusCode = 200; + res.end(JSON.stringify({ templateID: 'tpl_1' })); + return; + } + if (req.method === 'PATCH' && req.url === '/templates/tpl_1') { + res.statusCode = 200; + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === 'DELETE' && req.url === '/templates/tpl_1') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/files/hash') { + res.statusCode = 201; + res.end(JSON.stringify({ url: 'https://upload' })); + return; + } + if (req.method === 'POST' && req.url === '/v2/templates/tpl_1/builds/b1') { + res.statusCode = 202; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/status?logsOffset=1&limit=2&level=info') { + res.statusCode = 200; + res.end(JSON.stringify({ status: 'ready' })); + return; + } + if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/logs?cursor=1&limit=2&direction=asc&level=info&source=builder') { + res.statusCode = 200; + res.end(JSON.stringify({ logs: [] })); + return; + } + if (req.method === 'POST' && req.url === '/templates/tags') { + res.statusCode = 201; + res.end(JSON.stringify({ tags: ['v1'] })); + return; + } + if (req.method === 'DELETE' && req.url === '/templates/tags') { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === 'GET' && req.url === '/templates/aliases/base') { + res.statusCode = 200; + res.end(JSON.stringify({ exists: true })); + return; + } + if (req.method === 'POST' && req.url === '/templates/tpl_1') { + res.statusCode = 202; + res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b3' })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ message: req.method + ' ' + req.url })); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + apiKey: 'sandbox-key', + accessToken: 'access-token', + endpoint: fixture.endpoint + }); + + return client.listTemplates({ teamID: 'team' }) + .then(() => client.listDefaultTemplates()) + .then(() => client.createTemplate({ name: 'node:v1' })) + .then(() => client.createTemplateV2({ alias: 'old' })) + .then(() => client.getTemplate('tpl_1', { limit: 5, nextToken: 'n2' })) + .then(() => client.updateTemplate('tpl_1', { public: true })) + .then(() => client.deleteTemplate('tpl_1')) + .then(() => client.getTemplateFiles('tpl_1', 'hash')) + .then(() => client.startTemplateBuild('tpl_1', 'b1', { cpuCount: 2 })) + .then(() => client.getTemplateBuildStatus('tpl_1', 'b1', { logsOffset: 1, limit: 2, level: 'info' })) + .then(() => client.getTemplateBuildLogs('tpl_1', 'b1', { + cursor: 1, + limit: 2, + direction: 'asc', + level: 'info', + source: 'builder' + })) + .then(() => client.assignTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) + .then(() => client.deleteTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) + .then(() => client.getTemplateByAlias('base')) + .then(() => client.rebuildTemplate('tpl_1', { name: 'again' })) + .then(() => { + JSON.parse(fixture.requests[2].body).should.eql({ name: 'node:v1' }); + JSON.parse(fixture.requests[5].body).should.eql({ public: true }); + fixture.requests[14].headers.authorization.should.eql('Bearer access-token'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('builds templates through an E2B style Template facade', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + templateID: 'tpl_1', + buildID: 'bld_1', + status: 'building' + })); + }).then(fixture => { + const template = qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .aptInstall(['git']) + .runCmd('node --version') + .copy('/src', '/app') + .setStartCmd('node server.js') + .setReadyCmd('curl -f http://localhost:3000/health'); + + return template.build({ + apiKey: 'sandbox-key', + accessKey: 'ak', + secretKey: 'sk', + macOptions: { + disableQiniuTimestampSignature: true + }, + endpoint: fixture.endpoint, + timeout: 1000, + requestTimeoutMs: 1000, + name: 'node-template:test' + }).then(result => { + result.templateID.should.eql('tpl_1'); + const body = JSON.parse(fixture.requests[0].body); + body.name.should.eql('node-template:test'); + should.not.exist(body.apiKey); + should.not.exist(body.accessKey); + should.not.exist(body.secretKey); + should.not.exist(body.macOptions); + should.not.exist(body.timeout); + should.not.exist(body.requestTimeoutMs); + body.buildConfig.fromImage.should.eql('ubuntu:22.04'); + body.buildConfig.steps.should.eql([ + { type: 'apt', packages: ['git'] }, + { type: 'run', cmd: 'node --version' }, + { type: 'copy', src: '/src', dest: '/app' } + ]); + body.buildConfig.startCmd.should.eql('node server.js'); + body.buildConfig.readyCmd.should.eql('curl -f http://localhost:3000/health'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports Template.fromTemplate in builder payloads', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_child', buildID: 'bld_child' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromTemplate('base-template') + .runCmd('echo child') + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'child-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromTemplate.should.eql('base-template'); + body.buildConfig.steps[0].cmd.should.eql('echo child'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports E2B style Template filesystem, env, package, and git helpers', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_helpers', buildID: 'bld_helpers' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromImage('ubuntu:22.04') + .copyItems([ + { src: 'app.js', dest: '/app/', user: 'root', mode: 0o755 }, + { src: ['package.json', 'package-lock.json'], dest: '/app/' } + ]) + .remove(['/tmp/cache dir', '/tmp/old'], { recursive: true, force: true, user: 'root' }) + .rename('/tmp/a file', '/tmp/b file', { force: true }) + .makeDir(['/app/data dir', '/app/logs'], { mode: 0o755 }) + .makeSymlink('/usr/bin/node', '/usr/local/bin/node link', { force: true, user: 'root' }) + .setWorkdir('/app') + .setUser('node') + .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) + .pipInstall(['numpy', 'pandas'], { g: false }) + .pipInstall({ g: false }) + .pipInstall([], { g: false }) + .npmInstall('typescript', { dev: true }) + .npmInstall('tsx', { g: true }) + .npmInstall({ dev: true }) + .bunInstall(['elysia'], { dev: true }) + .bunInstall({ dev: true }) + .bunInstall([]) + .bunInstall(undefined, { g: true }) + .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { + branch: 'sandbox', + depth: 1, + user: 'root' + }) + .gitClone('https://github.com/qiniu/nodejs-sdk.git', { + branch: 'sandbox', + depth: 1 + }) + .runCmd(['echo one', 'echo two'], { user: 'root' }) + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'helper-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['app.js', '/app/', 'root', '0755'] }, + { type: 'COPY', args: ['package.json', '/app/'] }, + { type: 'COPY', args: ['package-lock.json', '/app/'] }, + { type: 'RUN', args: ['rm -r -f \'/tmp/cache dir\' \'/tmp/old\'', 'root'] }, + { type: 'RUN', args: ['mv -f \'/tmp/a file\' \'/tmp/b file\''] }, + { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/data dir\' \'/app/logs\''] }, + { type: 'RUN', args: ['ln -s -f \'/usr/bin/node\' \'/usr/local/bin/node link\'', 'root'] }, + { type: 'WORKDIR', args: ['/app'] }, + { type: 'USER', args: ['node'] }, + { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '8080'] }, + { type: 'RUN', args: ['pip install --user \'numpy\' \'pandas\''] }, + { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, + { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, + { type: 'RUN', args: ['npm install --save-dev'] }, + { type: 'RUN', args: ['bun add --dev \'elysia\''] }, + { type: 'RUN', args: ['bun install'] }, + { type: 'RUN', args: ['bun install'] }, + { type: 'RUN', args: ['bun install', 'root'] }, + { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, + { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, + { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\''] }, + { type: 'RUN', args: ['echo one && echo two', 'root'] } + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('supports Template registry, Dockerfile, and skipCache helpers', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_dockerfile', buildID: 'bld_dockerfile' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .skipCache() + .fromImage('registry.example.com/private/app:latest', { + username: 'alice', + password: 'secret' + }) + .runCmd('echo forced') + .fromDockerfile('FROM node:22\nWORKDIR /app\nENV NODE_ENV=production PORT=3000\nRUN npm ci\nCOPY package.json /app/\nUSER node') + .fromAWSRegistry('123456789.dkr.ecr.us-west-2.amazonaws.com/app:latest', { + accessKeyId: 'ak', + secretAccessKey: 'sk', + region: 'us-west-2' + }) + .fromGCPRegistry('gcr.io/project/app:latest', { + serviceAccountJSON: { project_id: 'project' } + }) + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'dockerfile-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromImage.should.eql('gcr.io/project/app:latest'); + body.buildConfig.fromImageRegistry.should.eql({ + type: 'gcp', + serviceAccountJson: JSON.stringify({ project_id: 'project' }) + }); + body.buildConfig.force.should.eql(true); + body.buildConfig.steps.should.eql([ + { type: 'RUN', args: ['echo forced'], force: true }, + { type: 'WORKDIR', args: ['/app'], force: true }, + { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '3000'], force: true }, + { type: 'RUN', args: ['npm ci'], force: true }, + { type: 'COPY', args: ['package.json', '/app/'], force: true }, + { type: 'USER', args: ['node'], force: true } + ]); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('clears stale registry credentials when switching to a public image', function () { + return startServer((req, res) => { + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ templateID: 'tpl_public', buildID: 'bld_public' })); + }).then(fixture => { + return qiniu.sandbox.Template() + .fromImage('registry.example.com/private/app:latest', { + username: 'alice', + password: 'secret' + }) + .fromImage('node:22') + .build({ + apiKey: 'sandbox-key', + endpoint: fixture.endpoint, + name: 'public-template:test' + }).then(() => { + const body = JSON.parse(fixture.requests[0].body); + body.buildConfig.fromImage.should.eql('node:22'); + should.not.exist(body.buildConfig.fromImageRegistry); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('treats long Dockerfile text as content instead of probing it as a path', function () { + const content = 'FROM node:22\nRUN ' + new Array(1200).join('x'); + const template = qiniu.sandbox.Template().fromDockerfile(content); + template.buildConfig.fromImage.should.eql('node:22'); + template.buildConfig.steps[0].cmd.should.match(/^x+$/); + }); + + it('wraps Dockerfile path read errors with path context', function () { + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = function (path) { + if (path === __filename) { + throw new Error('permission denied'); + } + return originalReadFileSync.apply(this, arguments); + }; + try { + (() => qiniu.sandbox.Template().fromDockerfile(__filename)).should.throw(/Failed to read Dockerfile/); + } finally { + fs.readFileSync = originalReadFileSync; + } + }); + + it('throws when Dockerfile path does not exist', function () { + (() => qiniu.sandbox.Template().fromDockerfile('/tmp/missing-qiniu-sdk-Dockerfile')).should.throw(/Dockerfile file not found/); + }); + + it('reads existing custom Dockerfile paths', function () { + const file = `/tmp/qiniu-sdk-Containerfile-${Date.now()}`; + fs.writeFileSync(file, 'FROM node:22\nRUN echo ok'); + try { + const template = qiniu.sandbox.Template().fromDockerfile(file); + template.buildConfig.fromImage.should.eql('node:22'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo ok' } + ]); + } finally { + fs.unlinkSync(file); + } + }); + + it('allows single-line Dockerfile comments as content', function () { + const template = qiniu.sandbox.Template().fromDockerfile('# syntax=docker/dockerfile:1'); + template.buildConfig.steps.should.eql([]); + }); + + it('preserves octal permission strings in Template helpers', function () { + const template = qiniu.sandbox.Template() + .copy('app.js', '/app/', { mode: '755' }) + .copy('bin.js', '/app/', { mode: '0o755' }) + .makeDir('/app/cache', { mode: '0755' }); + + template.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['app.js', '/app/', '', '0755'] }, + { type: 'COPY', args: ['bin.js', '/app/', '', '0755'] }, + { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/cache\''] } + ]); + }); + + it('parses escaped quotes in Dockerfile ENV values', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nENV FOO="bar\\"baz" QUOTED=\'it\\\'s ok\'\nENV MY_VAR some=value'); + template.buildConfig.steps.should.eql([ + { type: 'ENV', args: ['FOO', 'bar"baz', 'QUOTED', 'it\'s ok'] }, + { type: 'ENV', args: ['MY_VAR', 'some=value'] } + ]); + }); + + it('joins Dockerfile lines continued with backslash before parsing', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM --platform=linux/amd64 ubuntu:22.04 AS build\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000\nCOPY "file name.txt" "/app/data dir/"'); + template.buildConfig.fromImage.should.eql('ubuntu:22.04'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, + { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] }, + { type: 'ENV', args: ['PORT', '3000'] }, + { type: 'COPY', args: ['file name.txt', '/app/data dir/'] } + ]); + }); + + it('does not continue Dockerfile lines with escaped trailing backslashes', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nRUN echo \\\\\nRUN echo next'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo \\\\' }, + { type: 'run', cmd: 'echo next' } + ]); + }); + + it('does not merge Dockerfile comments or blank lines that end with backslash', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\n# ignored comment \\\nRUN echo ok\n\nRUN echo next'); + template.buildConfig.steps.should.eql([ + { type: 'run', cmd: 'echo ok' }, + { type: 'run', cmd: 'echo next' } + ]); + }); + + it('parses JSON Dockerfile COPY args and skips unsupported COPY --from flags', function () { + const template = qiniu.sandbox.Template() + .fromDockerfile('FROM node:22\nCOPY ["file name.txt", "/app/data dir/"]\nCOPY --chown=node package.json /app/\nCOPY --link linked.txt /linked/\nCOPY --from=builder /app/dist /app/dist'); + template.buildConfig.steps.should.eql([ + { type: 'COPY', args: ['file name.txt', '/app/data dir/'] }, + { type: 'COPY', args: ['package.json', '/app/'] }, + { type: 'COPY', args: ['linked.txt', '/linked/'] } + ]); + }); + + it('throws TemplateBuildError when a template build finishes with error status', function () { + return startServer((req, res) => { + if (req.url === '/templates/tpl_1/builds/bld_1/status') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ status: 'error', error: { message: 'compile failed' } })); + return; + } + res.statusCode = 404; + res.end(); + }).then(fixture => { + const client = new qiniu.sandbox.SandboxClient({ + endpoint: fixture.endpoint, + apiKey: 'sandbox-key' + }); + + return client.waitForBuild('tpl_1', 'bld_1', { intervalMs: 1, timeoutMs: 20 }).then(() => { + throw new Error('expected template build error'); + }, err => { + err.name.should.eql('TemplateBuildError'); + err.message.should.eql('compile failed'); + }).then(() => closeServer(fixture.server), err => { + return closeServer(fixture.server).then(() => { + throw err; + }); + }); + }); + }); + + it('covers Template package helper option branches and build option cleanup', function () { + const bodySeen = []; + const client = { + createTemplateV3: body => { + bodySeen.push(body); + return Promise.resolve({ templateID: 'tpl_1' }); + } + }; + const template = qiniu.sandbox.Template() + .pipInstall({ g: false }) + .npmInstall({ g: true, dev: true }) + .bunInstall({ g: true }) + .setEnvs({}) + .setStartCmd('npm start') + .setReadyCmd('curl -f http://localhost:3000'); + + return template.build({ + client, + endpoint: 'https://sandbox.example.com', + apiKey: 'api-key', + accessToken: 'access-token', + accessKey: 'ak', + secretKey: 'sk', + timeout: 1, + requestTimeoutMs: 2000, + alias: 'tpl-alias' + }).then(ret => { + ret.templateID.should.eql('tpl_1'); + bodySeen.length.should.eql(1); + bodySeen[0].alias.should.eql('tpl-alias'); + should.not.exist(bodySeen[0].client); + should.not.exist(bodySeen[0].endpoint); + should.not.exist(bodySeen[0].apiKey); + should.not.exist(bodySeen[0].accessToken); + should.not.exist(bodySeen[0].accessKey); + should.not.exist(bodySeen[0].secretKey); + should.not.exist(bodySeen[0].timeout); + should.not.exist(bodySeen[0].requestTimeoutMs); + bodySeen[0].buildConfig.startCmd.should.eql('npm start'); + bodySeen[0].buildConfig.readyCmd.should.eql('curl -f http://localhost:3000'); + bodySeen[0].buildConfig.steps.should.eql([ + { type: 'RUN', args: ['pip install --user .'] }, + { type: 'RUN', args: ['npm install -g --save-dev', 'root'] }, + { type: 'RUN', args: ['bun install', 'root'] } + ]); + }); + }); +}); diff --git a/test/sandbox.test.js b/test/sandbox.test.js deleted file mode 100644 index ecb153f..0000000 --- a/test/sandbox.test.js +++ /dev/null @@ -1,4758 +0,0 @@ -const should = require('should'); -const http = require('http'); -const fs = require('fs'); -const stream = require('stream'); -const zlib = require('zlib'); - -const qiniu = require('../index'); - -function startServer (handler) { - const requests = []; - const server = http.createServer((req, res) => { - const chunks = []; - req.on('data', chunk => { - chunks.push(chunk); - }); - req.on('end', () => { - const rawBody = Buffer.concat(chunks); - const record = { - method: req.method, - url: req.url, - headers: req.headers, - body: rawBody.toString(), - rawBody - }; - requests.push(record); - handler(record, res); - }); - }); - - return new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - resolve({ - server, - requests, - endpoint: `http://127.0.0.1:${server.address().port}` - }); - }); - }); -} - -function closeServer (server) { - return new Promise(resolve => server.close(resolve)); -} - -function parseUrl (value) { - return new URL(value, 'http://127.0.0.1'); -} - -function decodeConnectEnvelope (body) { - body[0].should.eql(0); - const length = body.readUInt32BE(1); - return JSON.parse(body.slice(5, 5 + length).toString()); -} - -function encodeConnectEnvelope (message) { - const payload = Buffer.from(JSON.stringify(message)); - const header = Buffer.alloc(5); - header[0] = 0; - header.writeUInt32BE(payload.length, 1); - return Buffer.concat([header, payload]); -} - -function encodeRawConnectEnvelope (payload, flags) { - payload = Buffer.from(payload); - const header = Buffer.alloc(5); - header[0] = flags || 0; - header.writeUInt32BE(payload.length, 1); - return Buffer.concat([header, payload]); -} - -function encodeConnectEndEnvelope (message) { - return encodeRawConnectEnvelope(JSON.stringify(message || {}), 2); -} - -function encodeOversizedConnectHeader () { - const header = Buffer.alloc(5); - header[0] = 0; - header.writeUInt32BE(10 * 1024 * 1024 + 1, 1); - return header; -} - -function encodeTruncatedConnectHeader () { - const header = Buffer.alloc(5); - header[0] = 0; - header.writeUInt32BE(20, 1); - return header; -} - -describe('test sandbox module', function () { - it('creates sandbox with E2B compatible options and API key auth', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - sandboxID: 'sbx_1', - domain: 'sbx.local', - envdVersion: '0.0.1', - envdAccessToken: 'token' - })); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint - }); - - return client.createSandbox({ - template: 'base', - timeoutMs: 15000, - metadata: { - user: 'alice' - }, - envs: { - FOO: 'bar' - }, - injections: [ - { - type: 'qiniu', - apiKey: 'ak', - baseUrl: 'https://example.com', - ruleId: 'rule_1' - } - ] - }).then(ret => { - should.equal(ret.sandboxID, 'sbx_1'); - fixture.requests.length.should.eql(1); - fixture.requests[0].method.should.eql('POST'); - fixture.requests[0].url.should.eql('/sandboxes'); - fixture.requests[0].headers['x-api-key'].should.eql('sandbox-key'); - fixture.requests[0].headers.authorization.should.eql('Bearer sandbox-key'); - fixture.requests[0].headers['content-type'].should.eql('application/json'); - JSON.parse(fixture.requests[0].body).should.eql({ - templateID: 'base', - timeout: 15, - metadata: { - user: 'alice' - }, - envVars: { - FOO: 'bar' - }, - injections: [ - { - type: 'qiniu', - api_key: 'ak', - base_url: 'https://example.com', - ruleID: 'rule_1' - } - ] - }); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('passes client timeout to urllib requests and validates metrics ids', function () { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: 'http://sandbox.test', - apiKey: 'sandbox-key', - timeout: 1234 - }); - const urls = []; - client.httpClient.sendRequest = req => { - urls.push(req.url); - req.urllibOptions.timeout.should.eql(1234); - should.not.exist(req.urllibOptions.headers['Content-Length']); - should.not.exist(req.urllibOptions.contentType); - return Promise.resolve({ - ok: () => true, - data: { ok: true } - }); - }; - - return client.listSandboxes() - .then(() => client.getSandboxesMetrics().then(() => { - throw new Error('expected empty metrics ids to fail'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.match(/At least one sandbox ID/); - })) - .then(() => client.getSandboxesMetrics('sbx_one')) - .then(() => client.getSandboxesMetrics({ sandbox_ids: 'sbx_field' })) - .then(() => client.getSandboxesMetrics({ sandboxId: 'sbx_object' })) - .then(() => client.getSandboxesMetrics([{ sandboxID: 'sbx_array_object' }, 'sbx_array_string'])) - .then(() => { - urls.should.eql([ - 'http://sandbox.test/sandboxes', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_one', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_field', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_object', - 'http://sandbox.test/sandboxes/metrics?sandbox_ids=sbx_array_object%2Csbx_array_string' - ]); - }); - }); - - it('throws a clear error when Qiniu-auth APIs are called without AK/SK credentials', function () { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: 'http://sandbox.test', - apiKey: 'sandbox-key' - }); - - (() => new qiniu.sandbox.SandboxClient({ - endpoint: 'http://sandbox.test', - apiKey: 'sandbox-key', - accessKey: 'ak' - })).should.throw(/Both accessKey and secretKey/); - const mac = new qiniu.auth.digest.Mac('ak', 'sk'); - const clientWithMac = new qiniu.sandbox.SandboxClient({ - endpoint: 'http://sandbox.test', - apiKey: 'sandbox-key', - accessKey: 'ak', - mac - }); - clientWithMac.mac.should.equal(mac); - - return client.listInjectionRules().then(() => { - throw new Error('expected missing Qiniu credentials rejection'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); - }); - }); - - it('requires Qiniu AK/SK before creating sandboxes with Kodo resources', function () { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: 'http://sandbox.test', - apiKey: 'sandbox-key' - }); - - return client.createSandbox({ - template: 'base', - resources: [{ - type: 'kodo', - bucket: 'bucket', - mountPath: '/workspace/kodo' - }] - }).then(() => { - throw new Error('expected missing Qiniu credentials error'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.eql('Qiniu Mac credentials (accessKey/secretKey) are required for this operation'); - }); - }); - - it('keeps Qiniu sandbox extensions in create body', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - sandboxID: 'sbx_qiniu', - domain: 'sbx.local', - envdAccessToken: 'token' - })); - }).then(fixture => { - return qiniu.sandbox.Sandbox.create({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - mcp: { enabled: true }, - injections: [{ injectionRuleID: 'rule_1' }], - resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] - }).then(() => { - JSON.parse(fixture.requests[0].body).should.eql({ - templateID: 'base', - mcp: { enabled: true }, - injections: [{ injectionRuleID: 'rule_1' }], - resources: [{ type: 'github_repository', url: 'https://github.com/acme/repo' }] - }); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('uses Qiniu AK/SK signing when creating sandbox with Kodo resources', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - sandboxID: 'sbx_kodo', - domain: 'sbx.local', - envdAccessToken: 'token' - })); - }).then(fixture => { - const mac = new qiniu.auth.digest.Mac('ak', 'sk', { - disableQiniuTimestampSignature: true - }); - const client = new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key', - mac - }); - - return client.createSandbox({ - template: 'base', - resources: [ - { - type: 'kodo', - bucket: 'bucket', - mount_path: '/workspace/kodo', - read_only: true - } - ] - }).then(() => { - should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); - should.not.exist(fixture.requests[0].headers['x-api-key']); - JSON.parse(fixture.requests[0].body).resources[0].type.should.eql('kodo'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('exposes E2B style Sandbox.create and kill helpers', function () { - return startServer((req, res) => { - if (req.method === 'POST' && req.url === '/sandboxes') { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - sandboxID: 'sbx_2', - domain: 'sbx.local', - envdAccessToken: 'token' - })); - return; - } - - if (req.method === 'DELETE' && req.url === '/sandboxes/sbx_2') { - res.statusCode = 204; - res.end(); - return; - } - - res.statusCode = 404; - res.end(); - }).then(fixture => { - return qiniu.sandbox.Sandbox.create({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - template: 'base' - }).then(sandbox => { - sandbox.sandboxId.should.eql('sbx_2'); - return sandbox.kill(); - }).then(() => { - fixture.requests.map(req => `${req.method} ${req.url}`).should.eql([ - 'POST /sandboxes', - 'DELETE /sandboxes/sbx_2' - ]); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('exports E2B style top-level Sandbox and client classes', function () { - qiniu.Sandbox.should.equal(qiniu.sandbox.Sandbox); - qiniu.SandboxClient.should.equal(qiniu.sandbox.SandboxClient); - qiniu.CommandExitError.should.equal(qiniu.sandbox.CommandExitError); - }); - - it('supports Sandbox.create(template, opts) overload', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - sandboxID: 'sbx_template', - domain: 'sbx.local', - envdAccessToken: 'token' - })); - }).then(fixture => { - return qiniu.sandbox.Sandbox.create('nodejs', { - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - metadata: { - source: 'e2b-overload' - } - }).then(sandbox => { - sandbox.sandboxId.should.eql('sbx_template'); - JSON.parse(fixture.requests[0].body).should.eql({ - templateID: 'nodejs', - metadata: { - source: 'e2b-overload' - } - }); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('keeps sandbox lifetime timeout separate from HTTP request timeout in static helpers', function () { - return startServer((req, res) => { - setTimeout(() => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - sandboxID: 'sbx_timeout', - domain: 'sbx.local', - envdAccessToken: 'token' - })); - }, 40); - }).then(fixture => { - return qiniu.sandbox.Sandbox.create({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - template: 'base', - timeout: 30 - }).then(sandbox => { - sandbox.sandboxId.should.eql('sbx_timeout'); - JSON.parse(fixture.requests[0].body).timeout.should.eql(30); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('exposes typed sandbox compatibility errors', function () { - const err = new qiniu.sandbox.CommandExitError({ - command: 'false', - exitCode: 1, - stdout: 'out', - stderr: 'err' - }); - - err.should.be.instanceOf(Error); - err.name.should.eql('CommandExitError'); - err.exitCode.should.eql(1); - err.stdout.should.eql('out'); - err.stderr.should.eql('err'); - new qiniu.sandbox.GitAuthError('bad credentials').name.should.eql('GitAuthError'); - new qiniu.sandbox.GitUpstreamError('missing upstream').name.should.eql('GitUpstreamError'); - new qiniu.sandbox.NotImplementedError('volume').name.should.eql('NotImplementedError'); - }); - - it('uses Qiniu AK/SK signing for injection rule APIs', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - id: 'rule_1', - name: 'openai', - injection: { - type: 'openai', - apiKey: 'secret' - } - })); - }).then(fixture => { - const mac = new qiniu.auth.digest.Mac('ak', 'sk', { - disableQiniuTimestampSignature: true - }); - const client = new qiniu.sandbox.SandboxClient({ - mac, - endpoint: fixture.endpoint - }); - - return client.createInjectionRule({ - name: 'openai', - injection: { - type: 'openai', - apiKey: 'secret' - } - }).then(() => { - fixture.requests[0].method.should.eql('POST'); - fixture.requests[0].url.should.eql('/injection-rules'); - should(fixture.requests[0].headers.authorization).startWith('Qiniu ak:'); - should.not.exist(fixture.requests[0].headers['x-api-key']); - JSON.parse(fixture.requests[0].body).should.eql({ - name: 'openai', - injection: { - type: 'openai', - api_key: 'secret' - } - }); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('builds envd hosts and signed file urls', function () { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_3', - info: { - domain: 'sandbox.example.com', - envdAccessToken: 'token' - } - }); - - sandbox.getHost(8080).should.eql('8080-sbx_3.sandbox.example.com'); - - const parsed = parseUrl(sandbox.downloadUrl('/home/user/a.txt', { - user: 'admin', - signatureExpiration: 60 - })); - parsed.protocol.should.eql('https:'); - parsed.host.should.eql('49983-sbx_3.sandbox.example.com'); - parsed.pathname.should.eql('/files'); - parsed.searchParams.get('path').should.eql('/home/user/a.txt'); - parsed.searchParams.get('username').should.eql('admin'); - const expiration = Number(parsed.searchParams.get('signature_expiration')); - expiration.should.be.above(Math.floor(Date.now() / 1000)); - expiration.should.be.below(Math.floor(Date.now() / 1000) + 120); - should(parsed.searchParams.get('signature')).startWith('v1_'); - - const absolute = parseUrl(sandbox.uploadUrl('/home/user/a.txt', { - signatureExpiration: 2000000000 - })); - absolute.searchParams.get('signature_expiration').should.eql('2000000000'); - }); - - it('reads and writes files through envd HTTP API', function () { - return startServer((req, res) => { - const parsed = parseUrl(req.url); - if (req.method === 'GET' && parsed.pathname === '/files') { - parsed.searchParams.get('path').should.eql('/hello.txt'); - parsed.searchParams.get('username').should.eql('user'); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('hello'); - return; - } - - if (req.method === 'POST' && parsed.pathname === '/files') { - parsed.searchParams.get('path').should.eql('/hello.txt'); - should(req.headers['content-type']).startWith('multipart/form-data; boundary='); - req.body.should.containEql('filename="/hello.txt"'); - req.body.should.containEql('hello'); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify([{ name: 'hello.txt', path: '/hello.txt', type: 'file' }])); - return; - } - - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_4', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.files.readText('/hello.txt').then(text => { - text.should.eql('hello'); - return sandbox.files.write('/hello.txt', 'hello'); - }).then(info => { - info.path.should.eql('/hello.txt'); - fixture.requests.map(req => `${req.method} ${parseUrl(req.url).pathname}`).should.eql([ - 'GET /files', - 'POST /files' - ]); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('reads files as text, bytes, blob, and stream formats', function () { - return startServer((req, res) => { - const parsed = parseUrl(req.url); - if (req.method === 'GET' && parsed.pathname === '/files') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/octet-stream'); - res.end(Buffer.from('hello')); - return; - } - - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_files', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.files.read('/tmp/a.txt') - .then(text => { - text.should.eql('hello'); - return sandbox.files.read('/tmp/a.txt', { format: 'bytes' }); - }) - .then(bytes => { - Buffer.isBuffer(bytes).should.eql(true); - bytes.toString().should.eql('hello'); - return sandbox.files.read('/tmp/a.txt', { format: 'stream' }); - }) - .then(stream => { - (typeof stream.pipe).should.eql('function'); - return new Promise((resolve, reject) => { - const chunks = []; - stream.on('data', chunk => chunks.push(chunk)); - stream.on('end', () => resolve(Buffer.concat(chunks).toString())); - stream.on('error', reject); - }); - }) - .then(streamText => { - streamText.should.eql('hello'); - return sandbox.files.read('/tmp/a.txt', { format: 'blob' }); - }) - .then(blob => { - if (typeof global.Blob !== 'undefined') { - blob.should.be.instanceOf(global.Blob); - } else { - Buffer.isBuffer(blob).should.eql(true); - } - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('escapes file paths in multipart filenames', function () { - const unsafePath = '/tmp/a"\r\nX-Injected: y.txt'; - return startServer((req, res) => { - const parsed = parseUrl(req.url); - if (req.method === 'POST' && parsed.pathname === '/files') { - parsed.searchParams.get('path').should.eql(unsafePath); - req.body.should.containEql('filename="/tmp/a\\"%0D%0AX-Injected: y.txt"'); - req.body.should.not.containEql('\r\nX-Injected'); - req.body.should.not.containEql('a"\r\n'); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify([{ name: 'a.txt', path: unsafePath, type: 'file' }])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_multipart_safe', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.5' - } - }); - - return sandbox.files.write(unsafePath, 'hello') - .then(info => { - info.path.should.eql(unsafePath); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects Readable streams passed directly to filesystem write', function () { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_stream_write', - envdUrl: 'http://127.0.0.1:9', - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.files.write('/stream.txt', new stream.Readable({ - read: function () {} - })).then(() => { - throw new Error('expected stream write to reject'); - }, err => { - err.message.should.eql('Streams are not supported as data in filesystem.write'); - }); - }); - - it('preserves falsy multipart payload values', function () { - return startServer((req, res) => { - const parsed = parseUrl(req.url); - if (req.method === 'POST' && parsed.pathname === '/files') { - parsed.searchParams.get('path').should.eql('/zero.txt'); - req.body.should.containEql('\r\n0\r\n'); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify([{ name: 'zero.txt', path: '/zero.txt', type: 'file' }])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_multipart_falsy', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.5' - } - }); - - return sandbox.files.write('/zero.txt', 0) - .then(info => { - info.path.should.eql('/zero.txt'); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('uses Connect RPC paths for filesystem metadata operations', function () { - return startServer((req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - if (req.url === '/filesystem.Filesystem/Stat') { - res.end(JSON.stringify({ - entry: { name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 } - })); - return; - } - if (req.url === '/filesystem.Filesystem/ListDir') { - res.end(JSON.stringify({ - entries: [{ name: 'hello.txt', path: '/hello.txt', type: 'FILE_TYPE_FILE', size: 5 }] - })); - return; - } - if (req.url === '/filesystem.Filesystem/MakeDir') { - res.end(JSON.stringify({ - entry: { name: 'tmp', path: '/tmp', type: 'FILE_TYPE_DIRECTORY' } - })); - return; - } - if (req.url === '/filesystem.Filesystem/Move') { - res.end(JSON.stringify({ - entry: { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } - })); - return; - } - if (req.url === '/filesystem.Filesystem/Remove') { - res.end('{}'); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_5', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.files.getInfo('/hello.txt') - .then(info => { - info.type.should.eql('file'); - return sandbox.files.list('/'); - }) - .then(entries => { - entries[0].name.should.eql('hello.txt'); - return sandbox.files.makeDir('/tmp'); - }) - .then(info => { - info.type.should.eql('dir'); - return sandbox.files.rename('/a.txt', '/b.txt'); - }) - .then(info => { - info.path.should.eql('/b.txt'); - return sandbox.files.remove('/b.txt'); - }) - .then(() => { - fixture.requests.map(req => req.url).should.eql([ - '/filesystem.Filesystem/Stat', - '/filesystem.Filesystem/ListDir', - '/filesystem.Filesystem/MakeDir', - '/filesystem.Filesystem/Move', - '/filesystem.Filesystem/Remove' - ]); - fixture.requests[0].headers.authorization.should.eql('Basic dXNlcjo='); - fixture.requests[0].headers['x-access-token'].should.eql('token'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('watches directory changes and returns a stoppable handle after start event', function () { - let watchResponse; - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - const body = decodeConnectEnvelope(req.rawBody); - body.path.should.eql('/workspace'); - body.recursive.should.eql(true); - req.headers['content-type'].should.eql('application/connect+json'); - req.headers['keepalive-ping-interval'].should.eql('50'); - watchResponse = res; - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.write(encodeConnectEnvelope({ event: { start: {} } })); - setTimeout(() => { - res.write(encodeConnectEnvelope({ - event: { - filesystem: { - name: 'created.txt', - type: 'EVENT_TYPE_CREATE' - } - } - })); - res.write(encodeConnectEnvelope({ - event: { - filesystem: { - name: 'written.txt', - type: 2 - } - } - })); - res.write(encodeConnectEnvelope({ event: { keepalive: {} } })); - }, 10); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const events = []; - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', event => { - events.push(event); - }, { - recursive: true, - requestTimeoutMs: 1000 - }).then(handle => { - (typeof handle.stop).should.eql('function'); - return new Promise(resolve => setTimeout(resolve, 40)).then(() => { - events.should.eql([ - { name: 'created.txt', type: qiniu.sandbox.FilesystemEventType.CREATE }, - { name: 'written.txt', type: qiniu.sandbox.FilesystemEventType.WRITE } - ]); - return handle.stop(); - }); - }).then(() => { - if (watchResponse) { - watchResponse.end(); - } - return closeServer(fixture.server); - }, err => { - if (watchResponse) { - watchResponse.end(); - } - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects recursive directory watching on envd versions without support', function () { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_old_watch', - envdUrl: 'http://127.0.0.1:9', - info: { - envdVersion: '0.1.3' - } - }); - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true - }).then(() => { - throw new Error('expected watchDir to reject'); - }, err => { - err.message.should.match(/recursive watching/i); - }); - }); - - it('rejects recursive directory watching when envd version is unknown', function () { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_unknown_watch', - envdUrl: 'http://127.0.0.1:9', - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true - }).then(() => { - throw new Error('expected watchDir to reject without envd version'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.match(/recursive watching/i); - }); - }); - - it('returns false from exists for err.resp 404 responses', function () { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_exists_resp', - envdUrl: 'http://127.0.0.1:9', - info: {} - }); - sandbox.files.getInfo = function () { - const err = new Error('missing'); - err.resp = { statusCode: 404 }; - return Promise.reject(err); - }; - return sandbox.files.exists('/missing').then(exists => { - exists.should.eql(false); - }); - }); - - it('rejects malformed filesystem watch stream payloads', function () { - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: {} } }), - encodeRawConnectEnvelope('not-json') - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_bad_json', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - let handle; - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true, - onExit: err => { - err.message.should.match(/Unexpected token/); - } - }).then(ret => { - handle = ret; - return new Promise(resolve => setTimeout(resolve, 20)); - }).then(() => { - handle._stopped.should.eql(true); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects oversized filesystem watch stream envelopes', function () { - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeOversizedConnectHeader()); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_huge', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true - }).then(() => { - throw new Error('expected watchDir to reject oversized frame'); - }, err => { - err.message.should.containEql('envelope too large'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('runs commands and git operations through process RPC', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - const body = decodeConnectEnvelope(req.rawBody); - body.process.cmd.should.eql('/bin/bash'); - body.process.args.should.eql(['-l', '-c', 'git status --porcelain=v1 -b']); - body.process.cwd.should.eql('/repo'); - req.headers['content-type'].should.eql('application/connect+json'); - req.headers['keepalive-ping-interval'].should.eql('50'); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 101 } } }), - encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('## main\n M a.txt\n?? b.txt\n').toString('base64') } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_6', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.git.status('/repo').then(status => { - status.currentBranch.should.eql('main'); - status.changedFiles.should.eql(['a.txt']); - status.untrackedFiles.should.eql(['b.txt']); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('passes git config options to git commands', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - const body = decodeConnectEnvelope(req.rawBody); - body.process.args.should.eql([ - '-l', - '-c', - 'git -c \'http.version=HTTP/1.1\' clone \'https://example.com/repo.git\' --depth \'1\' --branch \'main\' \'/repo\'' - ]); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 103 } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_6c', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.git.clone('https://example.com/repo.git', { - path: '/repo', - depth: 1, - branch: 'main', - config: { - 'http.version': 'HTTP/1.1' - } - }).then(result => { - result.exitCode.should.eql(0); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('decodes base64 process byte fields from Connect JSON', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 102 } } }), - encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('hello sandbox').toString('base64') } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_6b', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.run('cat /tmp/hello.txt') - .then(result => { - result.stdout.should.eql('hello sandbox'); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('maps sandbox lifecycle and metrics APIs', function () { - return startServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - if (req.method === 'GET' && req.url === '/v2/sandboxes?metadata=user%3Dalice&state=running%2Cpaused&limit=10&nextToken=n1') { - res.statusCode = 200; - res.end(JSON.stringify([{ sandboxID: 'sbx_1' }])); - return; - } - if (req.method === 'GET' && req.url === '/sandboxes/sbx_1') { - res.statusCode = 200; - res.end(JSON.stringify({ sandboxID: 'sbx_1', state: 'running' })); - return; - } - if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/connect') { - res.statusCode = 200; - res.end(JSON.stringify({ sandboxID: 'sbx_1', envdAccessToken: 'token' })); - return; - } - if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/timeout') { - res.statusCode = 204; - res.end(); - return; - } - if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/refreshes') { - res.statusCode = 204; - res.end(); - return; - } - if (req.method === 'POST' && req.url === '/sandboxes/sbx_1/pause') { - res.statusCode = 204; - res.end(); - return; - } - if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/metrics?start=1&end=2') { - res.statusCode = 200; - res.end(JSON.stringify([{ cpuCount: 1 }])); - return; - } - if (req.method === 'GET' && req.url === '/sandboxes/sbx_1/logs?start=10&limit=20') { - res.statusCode = 200; - res.end(JSON.stringify({ logs: [] })); - return; - } - if (req.method === 'GET' && req.url === '/sandboxes/metrics?sandbox_ids=sbx_1%2Csbx_2') { - res.statusCode = 200; - res.end(JSON.stringify({ sandboxes: [] })); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ message: req.method + ' ' + req.url })); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint - }); - - return client.deleteSandbox().then(() => { - throw new Error('expected missing sandboxID to fail'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.match(/sandboxID is required/); - }).then(() => client.list({ - metadata: 'user=alice', - state: ['running', 'paused'], - limit: 10, - nextToken: 'n1' - })).then(() => client.getInfo('sbx_1')) - .then(() => client.connect('sbx_1', { timeoutMs: 20000 })) - .then(() => client.setTimeout('sbx_1', { timeoutMs: 30000 })) - .then(() => client.refreshSandbox('sbx_1', { duration: 60 })) - .then(() => client.pauseSandbox('sbx_1')) - .then(() => client.getMetrics('sbx_1', { start: 1, end: 2 })) - .then(() => client.getLogs('sbx_1', { start: 10, limit: 20 })) - .then(() => client.getSandboxesMetrics(['sbx_1', 'sbx_2'])) - .then(() => { - JSON.parse(fixture.requests[3].body).should.eql({ timeout: 30 }); - JSON.parse(fixture.requests[4].body).should.eql({ duration: 60 }); - fixture.requests.every(req => req.headers['x-api-key'] === 'sandbox-key').should.eql(true); - fixture.requests.every(req => req.headers.authorization === 'Bearer sandbox-key').should.eql(true); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('surfaces sandbox API string errors and default connect timeout', function () { - return startServer((req, res) => { - if (req.method === 'POST' && req.url === '/sandboxes/sbx_default/connect') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ sandboxID: 'sbx_default' })); - return; - } - res.statusCode = 418; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify('teapot')); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }); - - return client.connectSandbox('sbx_default') - .then(() => client.getSandbox('sbx_error')) - .then(() => { - throw new Error('expected string error'); - }, err => { - JSON.parse(fixture.requests[0].body).should.eql({ timeout: 15 }); - err.name.should.eql('SandboxError'); - err.message.should.containEql('teapot'); - return client.getSandbox(''); - }) - .then(() => { - throw new Error('expected missing sandboxID to fail'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.eql('sandboxID is required'); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('maps template, build, tag, and access-token APIs', function () { - return startServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - if (req.method === 'GET' && req.url === '/templates?teamID=team') { - res.statusCode = 200; - res.end(JSON.stringify([{ templateID: 'tpl_1' }])); - return; - } - if (req.method === 'GET' && req.url === '/default-templates') { - res.statusCode = 200; - res.end(JSON.stringify([{ templateID: 'base' }])); - return; - } - if (req.method === 'POST' && req.url === '/v3/templates') { - res.statusCode = 202; - res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b1' })); - return; - } - if (req.method === 'POST' && req.url === '/v2/templates') { - res.statusCode = 202; - res.end(JSON.stringify({ templateID: 'tpl_2', buildID: 'b2' })); - return; - } - if (req.method === 'GET' && req.url === '/templates/tpl_1?limit=5&nextToken=n2') { - res.statusCode = 200; - res.end(JSON.stringify({ templateID: 'tpl_1' })); - return; - } - if (req.method === 'PATCH' && req.url === '/templates/tpl_1') { - res.statusCode = 200; - res.end(JSON.stringify({ ok: true })); - return; - } - if (req.method === 'DELETE' && req.url === '/templates/tpl_1') { - res.statusCode = 204; - res.end(); - return; - } - if (req.method === 'GET' && req.url === '/templates/tpl_1/files/hash') { - res.statusCode = 201; - res.end(JSON.stringify({ url: 'https://upload' })); - return; - } - if (req.method === 'POST' && req.url === '/v2/templates/tpl_1/builds/b1') { - res.statusCode = 202; - res.end(); - return; - } - if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/status?logsOffset=1&limit=2&level=info') { - res.statusCode = 200; - res.end(JSON.stringify({ status: 'ready' })); - return; - } - if (req.method === 'GET' && req.url === '/templates/tpl_1/builds/b1/logs?cursor=1&limit=2&direction=asc&level=info&source=builder') { - res.statusCode = 200; - res.end(JSON.stringify({ logs: [] })); - return; - } - if (req.method === 'POST' && req.url === '/templates/tags') { - res.statusCode = 201; - res.end(JSON.stringify({ tags: ['v1'] })); - return; - } - if (req.method === 'DELETE' && req.url === '/templates/tags') { - res.statusCode = 204; - res.end(); - return; - } - if (req.method === 'GET' && req.url === '/templates/aliases/base') { - res.statusCode = 200; - res.end(JSON.stringify({ exists: true })); - return; - } - if (req.method === 'POST' && req.url === '/templates/tpl_1') { - res.statusCode = 202; - res.end(JSON.stringify({ templateID: 'tpl_1', buildID: 'b3' })); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ message: req.method + ' ' + req.url })); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - apiKey: 'sandbox-key', - accessToken: 'access-token', - endpoint: fixture.endpoint - }); - - return client.listTemplates({ teamID: 'team' }) - .then(() => client.listDefaultTemplates()) - .then(() => client.createTemplate({ name: 'node:v1' })) - .then(() => client.createTemplateV2({ alias: 'old' })) - .then(() => client.getTemplate('tpl_1', { limit: 5, nextToken: 'n2' })) - .then(() => client.updateTemplate('tpl_1', { public: true })) - .then(() => client.deleteTemplate('tpl_1')) - .then(() => client.getTemplateFiles('tpl_1', 'hash')) - .then(() => client.startTemplateBuild('tpl_1', 'b1', { cpuCount: 2 })) - .then(() => client.getTemplateBuildStatus('tpl_1', 'b1', { logsOffset: 1, limit: 2, level: 'info' })) - .then(() => client.getTemplateBuildLogs('tpl_1', 'b1', { - cursor: 1, - limit: 2, - direction: 'asc', - level: 'info', - source: 'builder' - })) - .then(() => client.assignTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) - .then(() => client.deleteTemplateTags({ templateID: 'tpl_1', tags: ['v1'] })) - .then(() => client.getTemplateByAlias('base')) - .then(() => client.rebuildTemplate('tpl_1', { name: 'again' })) - .then(() => { - JSON.parse(fixture.requests[2].body).should.eql({ name: 'node:v1' }); - JSON.parse(fixture.requests[5].body).should.eql({ public: true }); - fixture.requests[14].headers.authorization.should.eql('Bearer access-token'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('builds templates through an E2B style Template facade', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - templateID: 'tpl_1', - buildID: 'bld_1', - status: 'building' - })); - }).then(fixture => { - const template = qiniu.sandbox.Template() - .fromImage('ubuntu:22.04') - .aptInstall(['git']) - .runCmd('node --version') - .copy('/src', '/app') - .setStartCmd('node server.js') - .setReadyCmd('curl -f http://localhost:3000/health'); - - return template.build({ - apiKey: 'sandbox-key', - accessKey: 'ak', - secretKey: 'sk', - macOptions: { - disableQiniuTimestampSignature: true - }, - endpoint: fixture.endpoint, - timeout: 1000, - requestTimeoutMs: 1000, - name: 'node-template:test' - }).then(result => { - result.templateID.should.eql('tpl_1'); - const body = JSON.parse(fixture.requests[0].body); - body.name.should.eql('node-template:test'); - should.not.exist(body.apiKey); - should.not.exist(body.accessKey); - should.not.exist(body.secretKey); - should.not.exist(body.macOptions); - should.not.exist(body.timeout); - should.not.exist(body.requestTimeoutMs); - body.buildConfig.fromImage.should.eql('ubuntu:22.04'); - body.buildConfig.steps.should.eql([ - { type: 'apt', packages: ['git'] }, - { type: 'run', cmd: 'node --version' }, - { type: 'copy', src: '/src', dest: '/app' } - ]); - body.buildConfig.startCmd.should.eql('node server.js'); - body.buildConfig.readyCmd.should.eql('curl -f http://localhost:3000/health'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports Template.fromTemplate in builder payloads', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ templateID: 'tpl_child', buildID: 'bld_child' })); - }).then(fixture => { - return qiniu.sandbox.Template() - .fromTemplate('base-template') - .runCmd('echo child') - .build({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - name: 'child-template:test' - }).then(() => { - const body = JSON.parse(fixture.requests[0].body); - body.buildConfig.fromTemplate.should.eql('base-template'); - body.buildConfig.steps[0].cmd.should.eql('echo child'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports E2B style Template filesystem, env, package, and git helpers', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ templateID: 'tpl_helpers', buildID: 'bld_helpers' })); - }).then(fixture => { - return qiniu.sandbox.Template() - .fromImage('ubuntu:22.04') - .copyItems([ - { src: 'app.js', dest: '/app/', user: 'root', mode: 0o755 }, - { src: ['package.json', 'package-lock.json'], dest: '/app/' } - ]) - .remove(['/tmp/cache dir', '/tmp/old'], { recursive: true, force: true, user: 'root' }) - .rename('/tmp/a file', '/tmp/b file', { force: true }) - .makeDir(['/app/data dir', '/app/logs'], { mode: 0o755 }) - .makeSymlink('/usr/bin/node', '/usr/local/bin/node link', { force: true, user: 'root' }) - .setWorkdir('/app') - .setUser('node') - .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) - .pipInstall(['numpy', 'pandas'], { g: false }) - .pipInstall({ g: false }) - .pipInstall([], { g: false }) - .npmInstall('typescript', { dev: true }) - .npmInstall('tsx', { g: true }) - .npmInstall({ dev: true }) - .bunInstall(['elysia'], { dev: true }) - .bunInstall({ dev: true }) - .bunInstall([]) - .bunInstall(undefined, { g: true }) - .aptInstall(['curl'], { noInstallRecommends: true, fixMissing: true }) - .gitClone('https://github.com/qiniu/nodejs-sdk.git', '/src/sdk dir', { - branch: 'sandbox', - depth: 1, - user: 'root' - }) - .gitClone('https://github.com/qiniu/nodejs-sdk.git', { - branch: 'sandbox', - depth: 1 - }) - .runCmd(['echo one', 'echo two'], { user: 'root' }) - .build({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - name: 'helper-template:test' - }).then(() => { - const body = JSON.parse(fixture.requests[0].body); - body.buildConfig.steps.should.eql([ - { type: 'COPY', args: ['app.js', '/app/', 'root', '0755'] }, - { type: 'COPY', args: ['package.json', '/app/'] }, - { type: 'COPY', args: ['package-lock.json', '/app/'] }, - { type: 'RUN', args: ['rm -r -f \'/tmp/cache dir\' \'/tmp/old\'', 'root'] }, - { type: 'RUN', args: ['mv -f \'/tmp/a file\' \'/tmp/b file\''] }, - { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/data dir\' \'/app/logs\''] }, - { type: 'RUN', args: ['ln -s -f \'/usr/bin/node\' \'/usr/local/bin/node link\'', 'root'] }, - { type: 'WORKDIR', args: ['/app'] }, - { type: 'USER', args: ['node'] }, - { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '8080'] }, - { type: 'RUN', args: ['pip install --user \'numpy\' \'pandas\''] }, - { type: 'RUN', args: ['pip install --user .'] }, - { type: 'RUN', args: ['pip install --user .'] }, - { type: 'RUN', args: ['npm install --save-dev \'typescript\''] }, - { type: 'RUN', args: ['npm install -g \'tsx\'', 'root'] }, - { type: 'RUN', args: ['npm install --save-dev'] }, - { type: 'RUN', args: ['bun add --dev \'elysia\''] }, - { type: 'RUN', args: ['bun install'] }, - { type: 'RUN', args: ['bun install'] }, - { type: 'RUN', args: ['bun install', 'root'] }, - { type: 'RUN', args: ['apt-get update && DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends --fix-missing \'curl\'', 'root'] }, - { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\' \'/src/sdk dir\'', 'root'] }, - { type: 'RUN', args: ['git clone \'https://github.com/qiniu/nodejs-sdk.git\' --branch \'sandbox\' --single-branch --depth \'1\''] }, - { type: 'RUN', args: ['echo one && echo two', 'root'] } - ]); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports Template registry, Dockerfile, and skipCache helpers', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ templateID: 'tpl_dockerfile', buildID: 'bld_dockerfile' })); - }).then(fixture => { - return qiniu.sandbox.Template() - .skipCache() - .fromImage('registry.example.com/private/app:latest', { - username: 'alice', - password: 'secret' - }) - .runCmd('echo forced') - .fromDockerfile('FROM node:22\nWORKDIR /app\nENV NODE_ENV=production PORT=3000\nRUN npm ci\nCOPY package.json /app/\nUSER node') - .fromAWSRegistry('123456789.dkr.ecr.us-west-2.amazonaws.com/app:latest', { - accessKeyId: 'ak', - secretAccessKey: 'sk', - region: 'us-west-2' - }) - .fromGCPRegistry('gcr.io/project/app:latest', { - serviceAccountJSON: { project_id: 'project' } - }) - .build({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - name: 'dockerfile-template:test' - }).then(() => { - const body = JSON.parse(fixture.requests[0].body); - body.buildConfig.fromImage.should.eql('gcr.io/project/app:latest'); - body.buildConfig.fromImageRegistry.should.eql({ - type: 'gcp', - serviceAccountJson: JSON.stringify({ project_id: 'project' }) - }); - body.buildConfig.force.should.eql(true); - body.buildConfig.steps.should.eql([ - { type: 'RUN', args: ['echo forced'], force: true }, - { type: 'WORKDIR', args: ['/app'], force: true }, - { type: 'ENV', args: ['NODE_ENV', 'production', 'PORT', '3000'], force: true }, - { type: 'RUN', args: ['npm ci'], force: true }, - { type: 'COPY', args: ['package.json', '/app/'], force: true }, - { type: 'USER', args: ['node'], force: true } - ]); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('clears stale registry credentials when switching to a public image', function () { - return startServer((req, res) => { - res.statusCode = 201; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ templateID: 'tpl_public', buildID: 'bld_public' })); - }).then(fixture => { - return qiniu.sandbox.Template() - .fromImage('registry.example.com/private/app:latest', { - username: 'alice', - password: 'secret' - }) - .fromImage('node:22') - .build({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint, - name: 'public-template:test' - }).then(() => { - const body = JSON.parse(fixture.requests[0].body); - body.buildConfig.fromImage.should.eql('node:22'); - should.not.exist(body.buildConfig.fromImageRegistry); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('treats long Dockerfile text as content instead of probing it as a path', function () { - const content = 'FROM node:22\nRUN ' + new Array(1200).join('x'); - const template = qiniu.sandbox.Template().fromDockerfile(content); - template.buildConfig.fromImage.should.eql('node:22'); - template.buildConfig.steps[0].cmd.should.match(/^x+$/); - }); - - it('wraps Dockerfile path read errors with path context', function () { - const originalReadFileSync = fs.readFileSync; - fs.readFileSync = function (path) { - if (path === __filename) { - throw new Error('permission denied'); - } - return originalReadFileSync.apply(this, arguments); - }; - try { - (() => qiniu.sandbox.Template().fromDockerfile(__filename)).should.throw(/Failed to read Dockerfile/); - } finally { - fs.readFileSync = originalReadFileSync; - } - }); - - it('throws when Dockerfile path does not exist', function () { - (() => qiniu.sandbox.Template().fromDockerfile('/tmp/missing-qiniu-sdk-Dockerfile')).should.throw(/Dockerfile file not found/); - }); - - it('reads existing custom Dockerfile paths', function () { - const file = `/tmp/qiniu-sdk-Containerfile-${Date.now()}`; - fs.writeFileSync(file, 'FROM node:22\nRUN echo ok'); - try { - const template = qiniu.sandbox.Template().fromDockerfile(file); - template.buildConfig.fromImage.should.eql('node:22'); - template.buildConfig.steps.should.eql([ - { type: 'run', cmd: 'echo ok' } - ]); - } finally { - fs.unlinkSync(file); - } - }); - - it('allows single-line Dockerfile comments as content', function () { - const template = qiniu.sandbox.Template().fromDockerfile('# syntax=docker/dockerfile:1'); - template.buildConfig.steps.should.eql([]); - }); - - it('preserves octal permission strings in Template helpers', function () { - const template = qiniu.sandbox.Template() - .copy('app.js', '/app/', { mode: '755' }) - .copy('bin.js', '/app/', { mode: '0o755' }) - .makeDir('/app/cache', { mode: '0755' }); - - template.buildConfig.steps.should.eql([ - { type: 'COPY', args: ['app.js', '/app/', '', '0755'] }, - { type: 'COPY', args: ['bin.js', '/app/', '', '0755'] }, - { type: 'RUN', args: ['mkdir -p -m 0755 \'/app/cache\''] } - ]); - }); - - it('parses escaped quotes in Dockerfile ENV values', function () { - const template = qiniu.sandbox.Template() - .fromDockerfile('FROM node:22\nENV FOO="bar\\"baz" QUOTED=\'it\\\'s ok\'\nENV MY_VAR some=value'); - template.buildConfig.steps.should.eql([ - { type: 'ENV', args: ['FOO', 'bar"baz', 'QUOTED', 'it\'s ok'] }, - { type: 'ENV', args: ['MY_VAR', 'some=value'] } - ]); - }); - - it('joins Dockerfile lines continued with backslash before parsing', function () { - const template = qiniu.sandbox.Template() - .fromDockerfile('FROM --platform=linux/amd64 ubuntu:22.04 AS build\nRUN apt-get update && \\\n apt-get install -y curl\nENV FOO=bar \\\n BAZ=qux\nENV PORT 3000\nCOPY "file name.txt" "/app/data dir/"'); - template.buildConfig.fromImage.should.eql('ubuntu:22.04'); - template.buildConfig.steps.should.eql([ - { type: 'run', cmd: 'apt-get update && apt-get install -y curl' }, - { type: 'ENV', args: ['FOO', 'bar', 'BAZ', 'qux'] }, - { type: 'ENV', args: ['PORT', '3000'] }, - { type: 'COPY', args: ['file name.txt', '/app/data dir/'] } - ]); - }); - - it('does not continue Dockerfile lines with escaped trailing backslashes', function () { - const template = qiniu.sandbox.Template() - .fromDockerfile('FROM node:22\nRUN echo \\\\\nRUN echo next'); - template.buildConfig.steps.should.eql([ - { type: 'run', cmd: 'echo \\\\' }, - { type: 'run', cmd: 'echo next' } - ]); - }); - - it('does not merge Dockerfile comments or blank lines that end with backslash', function () { - const template = qiniu.sandbox.Template() - .fromDockerfile('FROM node:22\n# ignored comment \\\nRUN echo ok\n\nRUN echo next'); - template.buildConfig.steps.should.eql([ - { type: 'run', cmd: 'echo ok' }, - { type: 'run', cmd: 'echo next' } - ]); - }); - - it('parses JSON Dockerfile COPY args and skips unsupported COPY --from flags', function () { - const template = qiniu.sandbox.Template() - .fromDockerfile('FROM node:22\nCOPY ["file name.txt", "/app/data dir/"]\nCOPY --chown=node package.json /app/\nCOPY --link linked.txt /linked/\nCOPY --from=builder /app/dist /app/dist'); - template.buildConfig.steps.should.eql([ - { type: 'COPY', args: ['file name.txt', '/app/data dir/'] }, - { type: 'COPY', args: ['package.json', '/app/'] }, - { type: 'COPY', args: ['linked.txt', '/linked/'] } - ]); - }); - - it('exposes network constants and maps updateNetwork to Qiniu API', function () { - return startServer((req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - sandboxID: 'sbx_net', - network: JSON.parse(req.body).network - })); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }); - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_net', - client, - info: {} - }); - qiniu.sandbox.ALL_TRAFFIC.should.eql('0.0.0.0/0'); - return sandbox.updateNetwork({ allowOut: [qiniu.sandbox.ALL_TRAFFIC] }) - .then(info => { - info.network.allowOut[0].should.eql('0.0.0.0/0'); - fixture.requests[0].method.should.eql('PATCH'); - fixture.requests[0].url.should.eql('/sandboxes/sbx_net'); - JSON.parse(fixture.requests[0].body).should.eql({ - network: { - allowOut: ['0.0.0.0/0'] - } - }); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('returns typed unsupported errors for E2B volume compatibility', function () { - const volume = new qiniu.sandbox.Volume(); - return volume.create().then(() => { - throw new Error('expected volume.create to fail'); - }, err => { - err.name.should.eql('NotImplementedError'); - err.message.should.containEql('Volume'); - return volume.delete(); - }).then(() => { - throw new Error('expected volume.delete to fail'); - }, err => { - err.name.should.eql('NotImplementedError'); - return volume.list(); - }).then(() => { - throw new Error('expected volume.list to fail'); - }, err => { - err.name.should.eql('NotImplementedError'); - }); - }); - - it('maps injection rule CRUD APIs with Qiniu signing', function () { - return startServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - if (req.method === 'GET' && req.url === '/injection-rules') { - res.statusCode = 200; - res.end(JSON.stringify([{ id: 'rule_1' }])); - return; - } - if (req.method === 'GET' && req.url === '/injection-rules/rule_1') { - res.statusCode = 200; - res.end(JSON.stringify({ id: 'rule_1' })); - return; - } - if (req.method === 'PUT' && req.url === '/injection-rules/rule_1') { - res.statusCode = 200; - res.end(JSON.stringify({ id: 'rule_1', name: 'updated' })); - return; - } - if (req.method === 'DELETE' && req.url === '/injection-rules/rule_1') { - res.statusCode = 204; - res.end(); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ message: req.method + ' ' + req.url })); - }).then(fixture => { - const mac = new qiniu.auth.digest.Mac('ak', 'sk', { - disableQiniuTimestampSignature: true - }); - const client = new qiniu.sandbox.SandboxClient({ - mac, - endpoint: fixture.endpoint - }); - - return client.listInjectionRules() - .then(() => client.getInjectionRule('rule_1')) - .then(() => client.updateInjectionRule('rule_1', { name: 'updated' })) - .then(() => client.deleteInjectionRule('rule_1')) - .then(() => { - fixture.requests.length.should.eql(4); - fixture.requests.forEach(req => { - should(req.headers.authorization).startWith('Qiniu ak:'); - }); - JSON.parse(fixture.requests[2].body).should.eql({ name: 'updated' }); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('covers filesystem writeFiles, bytes, exists false, and envd health', function () { - return startServer((req, res) => { - const parsed = parseUrl(req.url); - if (req.method === 'GET' && parsed.pathname === '/files') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/octet-stream'); - res.end(Buffer.from([1, 2, 3])); - return; - } - if (req.method === 'POST' && parsed.pathname === '/files') { - parsed.searchParams.get('path') === null ? true.should.eql(true) : false.should.eql(true); - parsed.searchParams.get('username').should.eql('user'); - should(parsed.searchParams.get('signature')).startWith('v1_'); - Number(parsed.searchParams.get('signature_expiration')).should.be.above(Math.floor(Date.now() / 1000)); - req.body.should.containEql('/a.txt'); - req.body.should.containEql('/b.txt'); - req.rawBody.includes(Buffer.from([1, 2, 3])).should.eql(true); - req.rawBody.includes(Buffer.from([4, 5, 6])).should.eql(true); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify([ - { name: 'a.txt', path: '/a.txt', type: 'FILE_TYPE_FILE' }, - { name: 'b.txt', path: '/b.txt', type: 'FILE_TYPE_FILE' } - ])); - return; - } - if (req.method === 'POST' && req.url === '/filesystem.Filesystem/Stat') { - res.statusCode = 404; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ message: 'not found' })); - return; - } - if (req.method === 'GET' && req.url === '/health') { - res.statusCode = 204; - res.end(); - return; - } - res.statusCode = 500; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_7', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.files.read('/bin', { format: 'bytes' }) - .then(data => { - Buffer.isBuffer(data).should.eql(true); - data.length.should.eql(3); - return sandbox.files.writeFiles([ - { path: '/a.txt', data: new Uint8Array([1, 2, 3]) }, - { path: '/b.txt', data: new Uint8Array([4, 5, 6]).buffer } - ], { user: 'user' }); - }) - .then(entries => { - entries.map(entry => entry.path).should.eql(['/a.txt', '/b.txt']); - return sandbox.files.writeFiles(null).then(() => { - throw new Error('expected writeFiles invalid files to fail'); - }, err => { - err.name.should.eql('TypeError'); - err.message.should.eql('files must be an array'); - }); - }) - .then(() => { - return sandbox.files.writeFiles([null]).then(() => { - throw new Error('expected writeFiles invalid file item to fail'); - }, err => { - err.name.should.eql('TypeError'); - err.message.should.eql('Each file must be an object'); - }); - }) - .then(() => { - return sandbox.files.writeFiles([ - { path: '/stream.txt', data: new stream.Readable() } - ]).then(() => { - throw new Error('expected writeFiles stream data to fail'); - }, err => { - err.name.should.eql('TypeError'); - err.message.should.match(/Streams are not supported/); - }); - }) - .then(() => { - return sandbox.files.exists('/missing.txt'); - }) - .then(exists => { - exists.should.eql(false); - return sandbox.isRunning(); - }) - .then(running => { - running.should.eql(true); - sandbox.getHost(1234).should.eql(''); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('covers command management, callbacks, background handles, pty, and git wrappers', function () { - const commandsSeen = []; - const fakeCommands = { - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ - exitCode: 0, - stdout: 'value\n', - stderr: '' - }); - }, - start: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ pid: 9, wait: function () {} }); - } - }; - const git = new qiniu.sandbox.Git(fakeCommands); - - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 12 } } }), - encodeConnectEnvelope({ event: { data: { stdout: [111, 117, 116], stderr: [101, 114, 114] } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 2, error: 'boom' } } }) - ])); - return; - } - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - if (req.url === '/process.Process/List') { - res.end(JSON.stringify({ - processes: [ - { pid: 1, tag: 't', config: { cmd: 'bash', args: ['-l'], envs: { A: '1' }, cwd: '/w' } } - ] - })); - return; - } - res.end('{}'); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_8', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - const seen = []; - - return sandbox.commands.run('echo hi', { - cwd: '/work', - envs: { A: '1' }, - tag: 'tag1', - stdin: true, - onStdout: data => seen.push('out:' + data), - onStderr: data => seen.push('err:' + data) - }).then(result => { - result.exitCode.should.eql(2); - result.error.should.eql('boom'); - seen.should.eql(['out:out', 'err:err']); - const firstStartBody = decodeConnectEnvelope(fixture.requests[0].rawBody); - firstStartBody.process.cwd.should.eql('/work'); - firstStartBody.process.envs.should.eql({ A: '1' }); - firstStartBody.tag.should.eql('tag1'); - firstStartBody.stdin.should.eql(true); - return sandbox.commands.run('sleep 1', { background: true }); - }).then(handle => { - handle.pid.should.eql(12); - return sandbox.commands.list(); - }).then(list => { - list[0].cwd.should.eql('/w'); - return sandbox.commands.sendStdin(12, Buffer.from('hello')); - }).then(() => { - const sendStdinBody = JSON.parse(fixture.requests[3].body); - sendStdinBody.input.stdin.should.eql(Buffer.from('hello').toString('base64')); - return sandbox.commands.closeStdin(12); - }) - .then(() => sandbox.commands.kill(12)) - .then(() => handleGitAndPty(git, sandbox.pty, commandsSeen, fixture)) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('returns command background handles before the process stream ends', function () { - let commandResponse; - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - commandResponse = res; - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.write(encodeConnectEnvelope({ event: { start: { pid: 88 } } })); - return; - } - if (req.url === '/process.Process/SendSignal') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end('{}'); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_live_cmd', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - const seen = []; - - return sandbox.commands.run('sleep 5', { - background: true, - user: 'root', - requestTimeoutMs: 1000, - onStdout: data => seen.push(data) - }).then(handle => { - handle.pid.should.eql(88); - should.not.exist(handle.result); - return handle.kill().then(() => handle); - }).then(handle => { - fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); - commandResponse.write(encodeConnectEnvelope({ - event: { - data: { - stdout: Buffer.from('ready').toString('base64') - } - } - })); - commandResponse.end(encodeConnectEnvelope({ event: { end: { exitCode: 0 } } })); - return handle.wait(); - }).then(result => { - result.stdout.should.eql('ready'); - seen.should.eql(['ready']); - }).then(() => closeServer(fixture.server), err => { - if (commandResponse) { - commandResponse.end(); - } - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects malformed command stream and JSON fallback payloads', function () { - let calls = 0; - return startServer((req, res) => { - calls += 1; - if (req.url === '/process.Process/Start' && calls === 1) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeRawConnectEnvelope('not-json')); - return; - } - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end('not-json'); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_bad_json', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('echo bad').then(() => { - throw new Error('expected command stream parse error'); - }, err => { - err.message.should.match(/Unexpected token/); - return sandbox.commands.start('echo bad'); - }).then(() => { - throw new Error('expected command JSON fallback parse error'); - }, err => { - err.message.should.match(/Unexpected token/); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects command wait when Connect end-stream carries an error', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 91 } } }), - encodeConnectEndEnvelope({ error: { code: 'internal', message: 'stream failed' } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_trailer', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('echo bad').then(handle => { - handle.pid.should.eql(91); - return handle.wait(); - }).then(() => { - throw new Error('expected command wait to reject'); - }, err => { - err.message.should.eql('stream failed'); - err.code.should.eql('internal'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects oversized command stream envelopes', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeOversizedConnectHeader()); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_huge', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('echo huge').then(() => { - throw new Error('expected command stream to reject oversized frame'); - }, err => { - err.message.should.containEql('envelope too large'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('fails watchDir on Connect end-stream errors after start', function () { - let exitError; - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: {} } }), - encodeConnectEndEnvelope({ error: { code: 'internal', message: 'watch failed' } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_trailer', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true, - onExit: err => { - exitError = err; - } - }).then(handle => { - return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); - }).then(handle => { - handle._stopped.should.eql(true); - exitError.message.should.eql('watch failed'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('fails watchDir when the event callback throws', function () { - let exitError; - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.write(encodeConnectEnvelope({ event: { start: {} } })); - res.write(encodeConnectEnvelope({ - event: { - filesystem: { - name: 'created.txt', - type: 'EVENT_TYPE_CREATE' - } - } - })); - setTimeout(() => res.end(), 50); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_callback', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', () => { - throw new Error('callback failed'); - }, { - recursive: true, - onExit: err => { - exitError = err; - } - }).then(handle => { - return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); - }).then(handle => { - handle._stopped.should.eql(true); - exitError.message.should.eql('callback failed'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('fails watchDir when the event callback rejects asynchronously', function () { - let exitError; - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: {} } }), - encodeConnectEnvelope({ - event: { - filesystem: { - name: 'created.txt', - type: 'EVENT_TYPE_CREATE' - } - } - }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_async_callback', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', () => Promise.reject(new Error('async callback failed')), { - recursive: true, - onExit: err => { - exitError = err; - } - }).then(handle => { - return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); - }).then(handle => { - handle._stopped.should.eql(true); - exitError.message.should.eql('async callback failed'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('handles rejected async watchDir onExit callbacks', function () { - let exitCalled = false; - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeConnectEnvelope({ event: { start: {} } })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_async_exit', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true, - onExit: () => { - exitCalled = true; - return Promise.reject(new Error('async exit failed')); - } - }).then(handle => { - return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); - }).then(handle => { - handle._stopped.should.eql(true); - exitCalled.should.eql(true); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('fails watchDir when the live Connect stream ends with a partial frame after start', function () { - let exitError; - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: {} } }), - encodeTruncatedConnectHeader() - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_truncated_tail', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true, - onExit: err => { - exitError = err; - } - }).then(handle => { - return new Promise(resolve => setTimeout(resolve, 20)).then(() => handle); - }).then(handle => { - handle._stopped.should.eql(true); - exitError.message.should.eql('Sandbox envd stream truncated unexpectedly'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('treats watchDir timeout alias as seconds while waiting for stream start', function () { - return startServer((req, res) => { - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - setTimeout(() => { - res.end(encodeConnectEnvelope({ event: { start: {} } })); - }, 20); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_watch_timeout_seconds', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.files.watchDir('/workspace', () => {}, { - recursive: true, - timeout: 1 - }).then(handle => { - handle._stopped.should.eql(true); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects command start when the process stream does not start before timeout', function () { - let commandResponse; - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - commandResponse = res; - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.flushHeaders(); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_timeout', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('sleep 5', { - requestTimeoutMs: 5 - }).then(() => { - throw new Error('expected command start to time out'); - }, err => { - err.message.should.eql('Command stream start timed out'); - if (commandResponse) { - commandResponse.end(); - } - }).then(() => closeServer(fixture.server), err => { - if (commandResponse) { - commandResponse.end(); - } - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('treats command timeout alias as seconds while waiting for stream start', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - setTimeout(() => { - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 81 } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - }, 20); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_timeout_seconds', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('sleep 1', { - timeout: 1 - }).then(handle => { - handle.pid.should.eql(81); - return handle.wait(); - }).then(result => { - result.exitCode.should.eql(0); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('does not reject command wait after disconnecting the live stream', function () { - let commandResponse; - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - commandResponse = res; - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.write(encodeConnectEnvelope({ event: { start: { pid: 83 } } })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_disconnect', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('sleep 30').then(handle => { - return handle.disconnect() - .then(() => handle.wait()) - .then(result => { - result.exitCode.should.eql(0); - }); - }).then(() => { - if (commandResponse) { - commandResponse.end(); - } - return closeServer(fixture.server); - }, err => { - if (commandResponse) { - commandResponse.end(); - } - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects command wait when the live Connect stream ends with a partial frame after start', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 82 } } }), - encodeTruncatedConnectHeader() - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_truncated_tail', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('echo bad').then(handle => { - handle.pid.should.eql(82); - return handle.wait(); - }).then(() => { - throw new Error('expected command wait to reject'); - }, err => { - err.message.should.eql('Sandbox envd stream truncated unexpectedly'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects command wait when the live Connect stream ends before process end', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeConnectEnvelope({ event: { start: { pid: 84 } } })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd_missing_end', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.start('echo bad').then(handle => { - handle.pid.should.eql(84); - return handle.wait(); - }).then(() => { - throw new Error('expected command wait to reject'); - }, err => { - err.message.should.eql('Command stream ended before process end'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('uses configured HTTP agents for live envd streams', function () { - const agent = new http.Agent(); - const requestsThroughAgent = []; - const originalAddRequest = agent.addRequest; - agent.addRequest = function (req, options) { - requestsThroughAgent.push(options.path); - return originalAddRequest.call(this, req, options); - }; - - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 83 } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - return; - } - if (req.url === '/filesystem.Filesystem/WatchDir') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeConnectEnvelope({ event: { start: {} } })); - return; - } - if (req.url === '/filesystem.Filesystem/Stat') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - result: { - entry: { name: 'agent.txt', path: '/agent.txt', type: 'FILE_TYPE_FILE', size: 5 } - } - })); - return; - } - if (req.method === 'GET' && req.url.indexOf('/files') === 0) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/octet-stream'); - res.end('agent'); - return; - } - if (req.method === 'POST' && req.url.indexOf('/files') === 0) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify([{ name: 'agent.txt', path: '/agent.txt', type: 'file' }])); - return; - } - if (req.url === '/health') { - res.statusCode = 204; - res.end(); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_agent', - envdUrl: fixture.endpoint, - httpAgent: agent, - info: { - envdAccessToken: 'token', - envdVersion: '0.5.7' - } - }); - - return sandbox.commands.start('echo ok') - .then(handle => handle.wait()) - .then(() => sandbox.files.watchDir('/workspace', () => {}, { - recursive: true - })) - .then(handle => { - handle._stopped.should.eql(true); - return sandbox.files.getInfo('/agent.txt'); - }) - .then(info => { - info.path.should.eql('/agent.txt'); - return sandbox.files.readText('/agent.txt'); - }) - .then(text => { - text.should.eql('agent'); - return sandbox.files.write('/agent.txt', 'agent'); - }) - .then(info => { - info.path.should.eql('/agent.txt'); - return sandbox.isRunning(); - }) - .then(running => { - running.should.eql(true); - return sandbox.pty.create({ - cols: 80, - rows: 24 - }); - }) - .then(handle => handle.wait()) - .then(() => { - requestsThroughAgent[0].should.eql('/process.Process/Start'); - requestsThroughAgent[1].should.eql('/filesystem.Filesystem/WatchDir'); - requestsThroughAgent[2].should.eql('/filesystem.Filesystem/Stat'); - requestsThroughAgent[3].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); - requestsThroughAgent[4].should.startWith('/files?path=%2Fagent.txt&username=user&signature='); - requestsThroughAgent[5].should.eql('/health'); - requestsThroughAgent[6].should.eql('/process.Process/Start'); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }).then(result => { - agent.destroy(); - return result; - }, err => { - agent.destroy(); - throw err; - }); - }); - - it('supports E2B git auth, branches, reset, restore, and safe remote cleanup', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - if (cmd.indexOf('branch ') >= 0 && cmd.indexOf('%(refname:short)') >= 0) { - return Promise.resolve({ stdout: '* main\n feature\n', exitCode: 0 }); - } - if (cmd.indexOf('remote get-url') >= 0) { - return Promise.resolve({ stdout: 'https://github.com/acme/repo.git\n', exitCode: 0 }); - } - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.clone('https://github.com/acme/repo.git', '/repo', { - username: 'u', - password: 'p', - depth: 1, - branch: 'main' - }).then(() => git.branches('/repo')) - .then(branches => { - branches.should.eql([ - { name: 'main', current: true }, - { name: 'feature', current: false } - ]); - return git.reset('/repo', { hard: true, ref: 'HEAD~1' }); - }) - .then(() => git.restore('/repo', { staged: true, paths: ['a.txt'] })) - .then(() => git.reset('/repo', { ref: 'HEAD', paths: 'single.txt' })) - .then(() => git.restore('/repo', { files: 'restore.txt' })) - .then(() => git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git', { overwrite: true, fetch: true })) - .then(() => git.commit('/repo', 'msg', { - authorName: 'Alice', - authorEmail: 'alice@example.com', - allowEmpty: true - })) - .then(() => git.setConfig('/repo', 'user.name', 'Alice', { scope: 'global' })) - .then(() => { - const commandText = commandsSeen.map(item => item.cmd).join('\n'); - commandText.should.containEql('credential.helper='); - commandText.should.containEql('clone \'https://github.com/acme/repo.git\''); - commandText.should.not.containEql('u:p'); - commandsSeen[0].opts.envs.should.eql({ - GIT_USERNAME: 'u', - GIT_PASSWORD: 'p' - }); - commandText.should.containEql('branch \'--format=%(HEAD) %(refname:short)\''); - commandText.should.containEql('reset --hard \'HEAD~1\''); - commandText.should.containEql('restore --staged -- \'a.txt\''); - commandText.should.containEql('reset \'HEAD\' -- \'single.txt\''); - commandText.should.containEql('restore --worktree -- \'restore.txt\''); - commandText.should.containEql('remote remove \'origin\''); - commandText.should.containEql('remote add \'origin\''); - commandText.should.containEql('fetch \'origin\''); - commandText.should.containEql('commit -m \'msg\' --author \'Alice \' --allow-empty'); - commandText.should.containEql('config --global \'user.name\' \'Alice\''); - }); - }); - - it('returns git remote add result when fetch is not requested', function () { - const git = new qiniu.sandbox.Git({ - run: function () { - return Promise.resolve({ - stdout: 'added', - stderr: '', - exitCode: 0 - }); - } - }); - - return git.remoteAdd('/repo', 'origin', 'https://github.com/acme/repo.git').then(result => { - result.should.eql({ - stdout: 'added', - stderr: '', - exitCode: 0 - }); - }); - }); - - it('does not fetch after git remote add fails', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd) { - commandsSeen.push(cmd); - if (cmd.indexOf(' remote add ') >= 0) { - return Promise.resolve({ stdout: '', stderr: 'bad remote', exitCode: 1 }); - } - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.remoteAdd('/repo', 'origin', 'bad-url', { fetch: true }).then(result => { - result.exitCode.should.eql(1); - commandsSeen.should.eql([ - 'git remote add \'origin\' \'bad-url\'' - ]); - }); - }); - - it('passes git push credentials through a helper when push fails', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - if (cmd.indexOf(' push ') >= 0) { - return Promise.reject(new Error('push failed')); - } - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.push('/repo', { - username: 'u', - password: 'p', - remote: 'origin', - branch: 'main' - }).then(() => { - throw new Error('expected git push to fail'); - }, err => { - err.message.should.eql('push failed'); - commandsSeen.length.should.eql(1); - commandsSeen[0].cmd.should.containEql('credential.helper='); - commandsSeen[0].cmd.should.containEql('printf "username=%s\\npassword=%s\\n" "$GIT_USERNAME" "$GIT_PASSWORD"'); - commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); - commandsSeen[0].cmd.should.not.containEql('u:p'); - commandsSeen[0].opts.envs.should.eql({ - GIT_USERNAME: 'u', - GIT_PASSWORD: 'p' - }); - }); - }); - - it('passes git pull credentials through a helper without rewriting remotes', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.pull('/repo', { - username: 'u', - password: 'p', - remote: 'origin', - branch: 'main' - }).then(() => { - commandsSeen.length.should.eql(1); - commandsSeen[0].cmd.should.containEql('credential.helper='); - commandsSeen[0].cmd.should.containEql('pull \'origin\' \'main\''); - commandsSeen[0].cmd.should.not.containEql('u:p'); - commandsSeen[0].opts.envs.should.eql({ - GIT_USERNAME: 'u', - GIT_PASSWORD: 'p' - }); - }); - }); - - it('passes git clone credentials through a helper instead of the command line', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.clone('https://github.com/acme/private.git', { - username: 'u', - password: 'p' - }).then(() => { - commandsSeen.length.should.eql(1); - commandsSeen[0].cmd.should.containEql('credential.helper='); - commandsSeen[0].cmd.should.containEql('clone \'https://github.com/acme/private.git\''); - commandsSeen[0].cmd.should.not.containEql('u:p'); - commandsSeen[0].opts.envs.should.eql({ - GIT_USERNAME: 'u', - GIT_PASSWORD: 'p' - }); - }); - }); - - it('keeps embedded git clone credentials when no helper credentials are provided', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.clone('https://u:p@github.com/acme/private.git').then(() => { - commandsSeen.length.should.eql(1); - commandsSeen[0].cmd.should.containEql('clone \'https://u:p@github.com/acme/private.git\''); - should.not.exist(commandsSeen[0].opts.envs); - }); - }); - - it('passes http git clone credentials through a helper instead of the command line', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.clone('http://git.example.com/acme/private.git', { - username: 'u', - password: 'p' - }).then(() => { - commandsSeen.length.should.eql(1); - commandsSeen[0].cmd.should.containEql('credential.helper='); - commandsSeen[0].cmd.should.containEql('clone \'http://git.example.com/acme/private.git\''); - commandsSeen[0].cmd.should.not.containEql('u:p'); - commandsSeen[0].opts.envs.should.eql({ - GIT_USERNAME: 'u', - GIT_PASSWORD: 'p' - }); - }); - }); - - it('rejects unsafe git reset modes', function () { - const git = new qiniu.sandbox.Git({ - run: function () { - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - try { - git.reset('/repo', { - mode: 'hard; touch /tmp/pwned' - }); - throw new Error('expected git reset to reject unsafe mode'); - } catch (err) { - err.name.should.eql('InvalidArgumentError'); - err.message.should.match(/Invalid git reset mode/); - } - - try { - git.reset('/repo', { - mode: 'hard', - paths: ['a.txt'] - }); - throw new Error('expected git reset to reject mode with paths'); - } catch (err) { - err.name.should.eql('InvalidArgumentError'); - err.message.should.match(/mode cannot be used when paths are specified/); - } - - try { - git.restore('/repo'); - throw new Error('expected git restore to reject missing paths'); - } catch (err) { - err.name.should.eql('InvalidArgumentError'); - err.message.should.match(/At least one path/); - } - }); - - it('surfaces git auth and validation errors on auth helpers', function () { - const git = new qiniu.sandbox.Git({ - run: function () { - return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); - } - }); - - try { - git.push('/repo', { - username: 'u' - }); - throw new Error('expected missing password'); - } catch (err) { - err.name.should.eql('GitAuthError'); - } - - return git.dangerouslyAuthenticate('/repo', 'origin', 'u', 'p').then(() => { - throw new Error('expected missing upstream'); - }, err => { - err.name.should.eql('GitUpstreamError'); - return git.commit('/repo', 'msg', { - authorName: 'Alice' - }); - }).then(() => { - throw new Error('expected missing author email'); - }, err => { - err.name.should.eql('GitAuthError'); - }); - }); - - it('replaces existing git remote credentials when dangerously authenticating', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - if (cmd.indexOf('remote get-url') >= 0) { - return Promise.resolve({ - stdout: 'https://old:secret@example.com/acme/repo.git\n', - stderr: '', - exitCode: 0 - }); - } - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { - commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); - commandsSeen[1].cmd.should.not.containEql('old:secret'); - }); - }); - - it('replaces token-only git remote credentials when dangerously authenticating', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - if (cmd.indexOf('remote get-url') >= 0) { - return Promise.resolve({ - stdout: 'https://old-token@example.com/acme/repo.git\n', - stderr: '', - exitCode: 0 - }); - } - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.dangerouslyAuthenticate('/repo', 'origin', 'new user', 'new/pass').then(() => { - commandsSeen[1].cmd.should.eql('git remote set-url \'origin\' \'https://new%20user:new%2Fpass@example.com/acme/repo.git\''); - commandsSeen[1].cmd.should.not.containEql('old-token@'); - }); - }); - - it('keeps original git push error without credential cleanup', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - if (cmd.indexOf(' push ') >= 0) { - return Promise.reject(new Error('push failed')); - } - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.push('/repo', { - username: 'u', - password: 'p', - remote: 'origin', - branch: 'main' - }).then(() => { - throw new Error('expected git push to fail'); - }, err => { - err.message.should.eql('push failed'); - commandsSeen.length.should.eql(1); - commandsSeen[0].cmd.should.containEql('credential.helper='); - commandsSeen[0].cmd.should.containEql('push \'origin\' \'main\''); - commandsSeen[0].cmd.should.not.containEql('remote set-url'); - }); - }); - - it('normalizes git config helpers when options are omitted', function () { - const commandsSeen = []; - let missingConfig = false; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - if (missingConfig && cmd.indexOf(' config --get ') >= 0) { - return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }); - } - return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); - } - }); - - return git.setConfig('user.name', 'Alice') - .then(() => git.getConfig('user.name')) - .then(value => { - value.should.eql('Alice'); - return git.configureUser('Alice', 'alice@example.com'); - }) - .then(() => { - missingConfig = true; - return git.getConfig('missing.name'); - }) - .then(value => { - should.not.exist(value); - }) - .then(() => { - const shellQuote = require('../qiniu/sandbox/util').shellQuote; - commandsSeen.map(item => item.cmd).should.eql([ - 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), - 'git config --get ' + shellQuote('user.name'), - 'git config ' + shellQuote('user.name') + ' ' + shellQuote('Alice'), - 'git config ' + shellQuote('user.email') + ' ' + shellQuote('alice@example.com'), - 'git config --get ' + shellQuote('missing.name') - ]); - commandsSeen.forEach(item => { - should.not.exist(item.opts.cwd); - }); - }); - }); - - it('covers Sandbox.connect, Sandbox.list, wait polling, and stopped health checks', function () { - let infoCalls = 0; - return startServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - if (req.method === 'POST' && req.url === '/sandboxes/sbx_9/connect') { - res.statusCode = 200; - res.end(JSON.stringify({ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' })); - return; - } - if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=1') { - res.statusCode = 200; - res.end(JSON.stringify([{ sandboxID: 'sbx_9', domain: 'd.example.com', envdAccessToken: 'token' }])); - return; - } - if (req.method === 'POST' && req.url === '/sandboxes') { - res.statusCode = 201; - res.end(JSON.stringify({ sandboxID: 'sbx_10', envdAccessToken: 'token' })); - return; - } - if (req.method === 'GET' && req.url === '/sandboxes/sbx_10') { - infoCalls += 1; - res.statusCode = 200; - res.end(JSON.stringify({ sandboxID: 'sbx_10', state: infoCalls > 1 ? 'running' : 'pending' })); - return; - } - if (req.method === 'GET' && req.url === '/health') { - res.statusCode = 502; - res.end(JSON.stringify({ message: 'stopped' })); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ message: req.method + ' ' + req.url })); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - apiKey: 'sandbox-key', - endpoint: fixture.endpoint - }); - - return qiniu.sandbox.Sandbox.connect('sbx_9', { - client, - timeout: 12 - }).then(sandbox => { - sandbox.sandboxId.should.eql('sbx_9'); - sandbox.envdAccessToken.should.eql('token'); - return qiniu.sandbox.Sandbox.list({ client, limit: 1 }); - }).then(sandboxes => { - sandboxes[0].sandboxId.should.eql('sbx_9'); - return client.createAndWait({ template: 'base' }, { intervalMs: 1, timeoutMs: 100 }); - }).then(sandbox => { - sandbox.sandboxId.should.eql('sbx_10'); - sandbox.envdAccessToken.should.eql('token'); - infoCalls.should.eql(2); - const stopped = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_11', - envdUrl: fixture.endpoint, - info: {} - }); - return stopped.isRunning(); - }).then(running => { - running.should.eql(false); - return closeServer(fixture.server); - }, err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('retries transient waitForReady polling errors before timeout', function () { - let infoCalls = 0; - return startServer((req, res) => { - if (req.method === 'GET' && req.url === '/sandboxes/sbx_retry') { - infoCalls += 1; - if (infoCalls === 1) { - res.statusCode = 502; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ message: 'temporary' })); - return; - } - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ sandboxID: 'sbx_retry', state: 'running' })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_retry', - client: new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }), - info: {} - }); - - return sandbox.waitForReady({ - intervalMs: 1, - timeoutMs: 50 - }).then(info => { - info.state.should.eql('running'); - infoCalls.should.eql(2); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('treats waitForReady timeout alias as seconds while polling', function () { - let infoCalls = 0; - return startServer((req, res) => { - if (req.method === 'GET' && req.url === '/sandboxes/sbx_poll_seconds') { - infoCalls += 1; - if (infoCalls === 1) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'starting' })); - return; - } - setTimeout(() => { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ sandboxID: 'sbx_poll_seconds', state: 'running' })); - }, 20); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_poll_seconds', - client: new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }), - info: {} - }); - - return sandbox.waitForReady({ - intervalMs: 1, - timeout: 1 - }).then(info => { - info.state.should.eql('running'); - infoCalls.should.eql(2); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('does not retry fatal client errors while polling', function () { - let calls = 0; - return startServer((req, res) => { - calls += 1; - res.statusCode = 404; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ message: 'missing' })); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_missing', - client: new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }), - info: {} - }); - - return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { - throw new Error('expected waitForReady to fail'); - }, err => { - err.response.statusCode.should.eql(404); - calls.should.eql(1); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('does not retry programming errors while polling', function () { - let calls = 0; - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_programming_error', - info: {} - }); - sandbox.getInfo = function () { - calls += 1; - return Promise.reject(new TypeError('bad poll logic')); - }; - - return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 100 }).then(() => { - throw new Error('expected waitForReady to fail'); - }, err => { - err.message.should.eql('bad poll logic'); - calls.should.eql(1); - }); - }); - - it('returns false for transient envd gateway health errors and rethrows others', function () { - let calls = 0; - return startServer((req, res) => { - calls += 1; - if (calls === 1) { - res.statusCode = 503; - res.end('starting'); - return; - } - if (calls === 2) { - res.statusCode = 504; - res.end('timeout'); - return; - } - res.statusCode = 500; - res.end('broken'); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_health_error', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.isRunning().then(running => { - running.should.eql(false); - return sandbox.isRunning(); - }).then(running => { - running.should.eql(false); - return sandbox.isRunning(); - }).then(() => { - throw new Error('expected health error'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.containEql('500'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('returns false from isRunning on connection failures', function () { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_down', - envdUrl: 'http://127.0.0.1:9', - info: {} - }); - - return sandbox.isRunning().then(running => { - running.should.eql(false); - }); - }); - - it('supports JSON fallback for process stream responses and poll timeout errors', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.flushHeaders(); - setTimeout(() => { - res.end(JSON.stringify({ - events: [ - { event: { start: { pid: 22 } } }, - { event: { data: { stdout: Buffer.from('ok').toString('base64') } } }, - { event: { end: { exitCode: 0 } } } - ] - })); - }, 20); - return; - } - if (req.url === '/sandboxes/sbx_pending') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ sandboxID: 'sbx_pending', state: 'pending' })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pending', - envdUrl: fixture.endpoint, - client: new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }), - info: {} - }); - - return sandbox.commands.run('echo ok', { timeoutMs: 5 }) - .then(result => { - result.stdout.should.eql('ok'); - return sandbox.waitForReady({ intervalMs: 1, timeoutMs: 5 }); - }) - .then(() => { - throw new Error('expected waitForReady timeout'); - }, err => { - err.name.should.eql('TimeoutError'); - err.message.should.eql('Sandbox poll timed out'); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('treats envd RPC timeout alias as seconds', function () { - const envd = require('../qiniu/sandbox/envd'); - return startServer((req, res) => { - if (req.url === '/test.RPC/Call') { - setTimeout(() => { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ result: { ok: true } })); - }, 20); - return; - } - if (req.url === '/test.RPC/Stream') { - setTimeout(() => { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeConnectEnvelope({ event: { ok: true } })); - }, 20); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_envd_timeout_seconds', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return envd.connectRPC(sandbox, '/test.RPC/Call', {}, { - timeout: 1 - }).then(result => { - result.should.eql({ ok: true }); - return envd.connectStreamRPC(sandbox, '/test.RPC/Stream', {}, { - timeout: 1 - }); - }).then(events => { - events.should.eql([{ event: { ok: true } }]); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects truncated buffered Connect stream responses', function () { - try { - require('../qiniu/sandbox/envd').decodeConnectEnvelopes(encodeTruncatedConnectHeader()); - throw new Error('expected buffered decoder to reject truncated stream'); - } catch (err) { - err.message.should.eql('Sandbox envd stream truncated unexpectedly'); - } - - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeTruncatedConnectHeader()); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_truncated', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.run('echo bad').then(() => { - throw new Error('expected truncated stream error'); - }, err => { - err.message.should.eql('Sandbox envd stream truncated unexpectedly'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('throws TemplateBuildError when a template build finishes with error status', function () { - return startServer((req, res) => { - if (req.url === '/templates/tpl_1/builds/bld_1/status') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ status: 'error', error: { message: 'compile failed' } })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }); - - return client.waitForBuild('tpl_1', 'bld_1', { intervalMs: 1, timeoutMs: 20 }).then(() => { - throw new Error('expected template build error'); - }, err => { - err.name.should.eql('TemplateBuildError'); - err.message.should.eql('compile failed'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports process stream JSON array and single event fallback responses', function () { - let calls = 0; - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - calls += 1; - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - if (calls === 1) { - res.end(JSON.stringify([ - { event: { start: { pid: 31 } } }, - { event: { data: { stdout: Buffer.from('array').toString('base64') } } }, - { event: { end: { exitCode: 0 } } } - ])); - return; - } - res.end(JSON.stringify({ - event: { - end: { - exitCode: 0 - } - } - })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_json_fallback', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.run('echo array') - .then(result => { - result.pid.should.eql(31); - result.stdout.should.eql('array'); - return sandbox.commands.run('true'); - }) - .then(result => { - result.exitCode.should.eql(0); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports E2B command timeout aliases and optional exit throwing', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 77 } } }), - encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('out').toString('base64'), stderr: Buffer.from('err').toString('base64') } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 7 } } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_cmd', - envdUrl: fixture.endpoint, - envdAccessToken: 'token', - info: {} - }); - - return sandbox.commands.run('false', { - requestTimeoutMs: 12000 - }).then(result => { - result.exitCode.should.eql(7); - result.stdout.should.eql('out'); - result.stderr.should.eql('err'); - return sandbox.commands.run('false', { - requestTimeoutMs: 12000, - throwOnError: true - }); - }).then(() => { - throw new Error('expected command to throw'); - }, err => { - err.name.should.eql('CommandExitError'); - err.exitCode.should.eql(7); - err.stdout.should.eql('out'); - err.stderr.should.eql('err'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('normalizes snake_case sandbox info and camelCase injection inputs', function () { - return startServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - if (req.method === 'POST' && req.url === '/sandboxes') { - res.statusCode = 201; - res.end(JSON.stringify({ - sandbox_id: 'sbx_snake', - sandbox_domain: 'snake.example.com' - })); - return; - } - if (req.method === 'GET' && req.url === '/sandboxes/sbx_snake') { - res.statusCode = 200; - res.end(JSON.stringify({ - sandbox_id: 'sbx_snake', - domain: 'snake.example.com', - envd_access_token: 'snake-token', - envd_version: '1.2.3' - })); - return; - } - if (req.method === 'POST' && req.url === '/injection-rules') { - res.statusCode = 201; - res.end(JSON.stringify({ ruleID: 'rule_1' })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const mac = new qiniu.auth.digest.Mac('ak', 'sk', { - disableQiniuTimestampSignature: true - }); - const client = new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key', - mac - }); - - return qiniu.sandbox.Sandbox.create({ client, template: 'base' }) - .then(sandbox => { - sandbox.sandboxId.should.eql('sbx_snake'); - sandbox.envdAccessToken.should.eql('snake-token'); - sandbox.envdVersion.should.eql('1.2.3'); - return client.createInjectionRule({ - name: 'qiniu', - injection: { - type: 'qiniu', - baseUrl: 'https://api.qnaigc.com', - apiKey: 'secret' - } - }); - }) - .then(() => { - JSON.parse(fixture.requests[2].body).should.eql({ - name: 'qiniu', - injection: { - type: 'qiniu', - base_url: 'https://api.qnaigc.com', - api_key: 'secret' - } - }); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports E2B style sandbox paginator, snapshots, and MCP helpers', function () { - let tokenReads = 0; - return startServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - if (req.method === 'GET' && req.url === '/v2/sandboxes?limit=2&nextToken=n1&metadata%5Buser%5D=alice&state=running') { - res.statusCode = 200; - res.end(JSON.stringify({ - items: [{ sandboxID: 'sbx_page', domain: 'page.example.com' }], - nextToken: 'n2' - })); - return; - } - if (req.method === 'POST' && req.url === '/sandboxes/sbx_page/snapshots') { - res.statusCode = 201; - res.end(JSON.stringify({ snapshotID: 'snap_1', snapshotId: 'snap_1' })); - return; - } - if (req.method === 'GET' && req.url === '/snapshots?limit=1&sandboxId=sbx_page') { - res.statusCode = 200; - res.end(JSON.stringify({ - items: [{ snapshotID: 'snap_1', snapshotId: 'snap_1' }], - nextToken: 'snap_next' - })); - return; - } - const parsed = parseUrl(req.url); - if (req.method === 'GET' && parsed.pathname === '/files' && parsed.searchParams.get('path') === '/etc/mcp-gateway/.token') { - tokenReads += 1; - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('mcp-token\n'); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ message: req.method + ' ' + req.url })); - }).then(fixture => { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }); - const paginator = qiniu.sandbox.Sandbox.list({ - client, - limit: 2, - nextToken: 'n1', - query: { - metadata: { user: 'alice' }, - state: ['running'] - } - }); - - return paginator.nextItems().then(items => { - items[0].sandboxId.should.eql('sbx_page'); - paginator.hasNext.should.eql(true); - paginator.nextToken.should.eql('n2'); - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_page', - envdUrl: fixture.endpoint, - info: { - domain: 'page.example.com', - envdAccessToken: 'token' - }, - client - }); - sandbox.getMcpUrl().should.eql('https://50005-sbx_page.page.example.com/mcp'); - return Promise.all([ - sandbox.getMcpToken(), - sandbox.getMcpToken() - ]).then(tokens => { - tokens.should.eql(['mcp-token', 'mcp-token']); - tokenReads.should.eql(1); - return sandbox.getMcpToken(); - }).then(token => { - token.should.eql('mcp-token'); - tokenReads.should.eql(1); - return sandbox.createSnapshot({ name: 'snap' }); - }).then(snapshot => { - snapshot.snapshotId.should.eql('snap_1'); - return sandbox.listSnapshots({ limit: 1 }).nextItems(); - }); - }).then(snapshots => { - snapshots[0].snapshotId.should.eql('snap_1'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports E2B style PTY connect, input, resize, and kill operations', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start' || req.url === '/process.Process/Connect') { - const body = decodeConnectEnvelope(req.rawBody); - if (req.url === '/process.Process/Start') { - body.pty.size.should.eql({ cols: 80, rows: 24 }); - body.process.envs.TERM.should.eql('xterm-256color'); - } else { - body.process.selector.pid.should.eql(44); - } - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 44 } } }), - encodeConnectEnvelope({ event: { data: { pty: Buffer.from('ok').toString('base64') } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - return; - } - if (req.url === '/process.Process/SendInput' || req.url === '/process.Process/Update' || req.url === '/process.Process/SendSignal') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end('{}'); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - const data = []; - - return sandbox.pty.create({ - cols: 80, - rows: 24, - user: 'root', - requestTimeoutMs: 1000, - onData: chunk => data.push(Buffer.from(chunk).toString()) - }).then(handle => { - handle.pid.should.eql(44); - return handle.kill().then(killed => { - killed.should.eql(true); - fixture.requests[1].headers.authorization.should.eql('Basic ' + Buffer.from('root:').toString('base64')); - return handle.wait(); - }); - }).then(() => sandbox.pty.connect(44, { - onData: chunk => data.push(Buffer.from(chunk).toString()) - })).then(handle => handle.wait()) - .then(() => sandbox.pty.sendInput(44, 123)) - .then(() => sandbox.pty.resize(44, { cols: 100, rows: 30 })) - .then(() => sandbox.pty.kill(44)) - .then(killed => { - killed.should.eql(true); - data.should.eql(['ok', 'ok']); - const sendBody = JSON.parse(fixture.requests[3].body); - sendBody.input.pty.should.eql(Buffer.from('123').toString('base64')); - const resizeBody = JSON.parse(fixture.requests[4].body); - resizeBody.pty.size.should.eql({ cols: 100, rows: 30 }); - const killBody = JSON.parse(fixture.requests[5].body); - killBody.signal.should.eql('SIGNAL_SIGKILL'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects malformed live PTY stream payloads', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeRawConnectEnvelope('not-json')); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_bad_json', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24 - }).then(() => { - throw new Error('expected pty stream parse error'); - }, err => { - err.message.should.match(/Unexpected token/); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('uses live PTY creation for default and args-based create calls', function () { - let starts = 0; - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - starts += 1; - const body = decodeConnectEnvelope(req.rawBody); - if (starts === 1) { - body.process.cmd.should.eql('/bin/bash'); - body.process.args.should.eql(['-i', '-l']); - } else { - body.process.cmd.should.eql('node'); - body.process.args.should.eql(['-i']); - } - should.exist(body.pty); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 48 + starts } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_args', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create() - .then(handle => handle.wait()) - .then(() => sandbox.pty.create({ cmd: 'node', args: ['-i'] })) - .then(handle => handle.wait()) - .then(result => { - result.exitCode.should.eql(0); - starts.should.eql(2); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects PTY wait when Connect end-stream carries an error', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 45 } } }), - encodeConnectEndEnvelope({ error: { code: 'internal', message: 'pty failed' } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_trailer', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24 - }).then(handle => { - handle.pid.should.eql(45); - return handle.wait(); - }).then(() => { - throw new Error('expected pty wait to reject'); - }, err => { - err.message.should.eql('pty failed'); - err.code.should.eql('internal'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects PTY wait when the live Connect stream ends with a partial frame after start', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 46 } } }), - encodeTruncatedConnectHeader() - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_truncated_tail', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24 - }).then(handle => { - handle.pid.should.eql(46); - return handle.wait(); - }).then(() => { - throw new Error('expected pty wait to reject'); - }, err => { - err.message.should.eql('Sandbox envd stream truncated unexpectedly'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects PTY wait when the live Connect stream ends before process end', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeConnectEnvelope({ event: { start: { pid: 47 } } })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_missing_end', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24 - }).then(handle => { - handle.pid.should.eql(47); - return handle.wait(); - }).then(() => { - throw new Error('expected pty wait to reject'); - }, err => { - err.message.should.eql('PTY stream ended before process end'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('does not reject PTY wait after disconnecting the live stream', function () { - let ptyResponse; - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - ptyResponse = res; - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.write(encodeConnectEnvelope({ event: { start: { pid: 48 } } })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_disconnect', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24 - }).then(handle => { - return handle.disconnect() - .then(() => handle.wait()) - .then(result => { - result.exitCode.should.eql(0); - }); - }).then(() => { - if (ptyResponse) { - ptyResponse.end(); - } - return closeServer(fixture.server); - }, err => { - if (ptyResponse) { - ptyResponse.end(); - } - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects oversized PTY stream envelopes', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(encodeOversizedConnectHeader()); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_huge', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24 - }).then(() => { - throw new Error('expected pty stream to reject oversized frame'); - }, err => { - err.message.should.containEql('envelope too large'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('rejects live PTY start when the process stream does not start before timeout', function () { - let ptyResponse; - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - ptyResponse = res; - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_timeout', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24, - requestTimeoutMs: 5 - }).then(() => { - throw new Error('expected pty start to time out'); - }, err => { - err.message.should.eql('PTY stream start timed out'); - if (ptyResponse) { - ptyResponse.end(); - } - }).then(() => closeServer(fixture.server), err => { - if (ptyResponse) { - ptyResponse.end(); - } - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('treats PTY timeout alias as seconds while waiting for stream start', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Start') { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - setTimeout(() => { - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 47 } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - }, 20); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_timeout_seconds', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.create({ - cols: 80, - rows: 24, - timeout: 1 - }).then(handle => { - handle.pid.should.eql(47); - return handle.wait(); - }).then(result => { - result.exitCode.should.eql(0); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports filesystem gzip and octet-stream write compatibility options', function () { - return startServer((req, res) => { - const parsed = parseUrl(req.url); - if (req.method === 'GET' && parsed.pathname === '/files') { - req.headers['accept-encoding'].should.eql('gzip'); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('zip'); - return; - } - if (req.method === 'POST' && parsed.pathname === '/files') { - req.headers['content-type'].should.eql('application/octet-stream'); - req.headers['content-encoding'].should.eql('gzip'); - parsed.searchParams.get('path').should.eql('/zip.txt'); - Array.from(zlib.gunzipSync(req.rawBody)).should.eql([1, 2, 3]); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_zip', - envdUrl: fixture.endpoint, - info: { - envdVersion: '0.5.7', - envdAccessToken: 'token' - } - }); - - return sandbox.files.read('/zip.txt', { gzip: true }) - .then(text => { - text.should.eql('zip'); - return sandbox.files.write('/zip.txt', new Uint8Array([1, 2, 3]), { - gzip: true, - useOctetStream: true - }); - }) - .then(info => { - info.path.should.eql('/zip.txt'); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('falls back to multipart uploads when envd does not support octet-stream', function () { - return startServer((req, res) => { - const parsed = parseUrl(req.url); - if (req.method === 'POST' && parsed.pathname === '/files') { - should(req.headers['content-type']).startWith('multipart/form-data; boundary='); - should.not.exist(req.headers['content-encoding']); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify([{ name: 'zip.txt', path: '/zip.txt', type: 'file' }])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_zip_old', - envdUrl: fixture.endpoint, - info: { - envdVersion: '0.5.5', - envdAccessToken: 'token' - } - }); - - return sandbox.files.write('/zip.txt', 'zip', { - gzip: true, - useOctetStream: true - }).then(info => { - info.path.should.eql('/zip.txt'); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('exports sandbox constants and typed helpers aligned with common runtime names', function () { - qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(300000); - qiniu.sandbox.FileType.FILE.should.eql('file'); - qiniu.sandbox.FileType.DIR.should.eql('dir'); - qiniu.DEFAULT_SANDBOX_TIMEOUT_MS.should.eql(qiniu.sandbox.DEFAULT_SANDBOX_TIMEOUT_MS); - qiniu.FileType.should.equal(qiniu.sandbox.FileType); - new qiniu.sandbox.InvalidArgumentError('bad arg').name.should.eql('InvalidArgumentError'); - new qiniu.sandbox.FileNotFoundError('missing').name.should.eql('FileNotFoundError'); - }); - - it('supports instance connect and betaPause aliases', function () { - return startServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/connect') { - res.statusCode = 200; - res.end(JSON.stringify({ - sandboxID: 'sbx_alias', - domain: 'alias.example.com', - envdAccessToken: 'token2', - envdVersion: '0.5.7' - })); - return; - } - if (req.method === 'POST' && req.url === '/sandboxes/sbx_alias/pause') { - res.statusCode = 204; - res.end(); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_alias', - client: new qiniu.sandbox.SandboxClient({ - endpoint: fixture.endpoint, - apiKey: 'sandbox-key' - }), - info: {} - }); - - return sandbox.connect({ timeoutMs: 30000 }) - .then(connected => { - connected.should.equal(sandbox); - sandbox.envdAccessToken.should.eql('token2'); - sandbox.envdVersion.should.eql('0.5.7'); - sandbox.domain.should.eql('alias.example.com'); - return sandbox.betaPause(); - }) - .then(paused => { - should(paused).equal(null); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports commands.connect with E2B style command handle semantics', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/Connect') { - const body = decodeConnectEnvelope(req.rawBody); - body.process.selector.pid.should.eql(55); - res.statusCode = 200; - res.setHeader('Content-Type', 'application/connect+json'); - res.end(Buffer.concat([ - encodeConnectEnvelope({ event: { start: { pid: 55 } } }), - encodeConnectEnvelope({ event: { data: { stdout: Buffer.from('connected').toString('base64') } } }), - encodeConnectEnvelope({ event: { end: { exitCode: 0 } } }) - ])); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_connect_cmd', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.commands.connect(55, { - requestTimeoutMs: 9000 - }).then(handle => { - handle.pid.should.eql(55); - return handle.wait(); - }).then(result => { - result.stdout.should.eql('connected'); - result.exitCode.should.eql(0); - }).then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('supports E2B style git option signatures for config and restore helpers', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ stdout: 'Alice\n', stderr: '', exitCode: 0 }); - } - }); - - return git.setConfig('user.name', 'Alice', { - path: '/repo', - scope: 'local' - }).then(() => git.getConfig('user.name', { - path: '/repo', - scope: 'local' - })).then(value => { - value.should.eql('Alice'); - return git.configureUser('Alice', 'alice@example.com', { - path: '/repo', - scope: 'local' - }); - }).then(() => git.reset('/repo', { - mode: 'hard', - target: 'HEAD~1' - })).then(() => git.restore('/repo', { - paths: ['a.txt'] - })).then(() => { - commandsSeen.map(item => item.cmd).should.eql([ - 'git config --local \'user.name\' \'Alice\'', - 'git config --local --get \'user.name\'', - 'git config --local \'user.name\' \'Alice\'', - 'git config --local \'user.email\' \'alice@example.com\'', - 'git reset --hard \'HEAD~1\'', - 'git restore --worktree -- \'a.txt\'' - ]); - commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); - }); - }); - - it('keeps legacy git configureUser repo path when delegating to config helpers', function () { - const commandsSeen = []; - const git = new qiniu.sandbox.Git({ - run: function (cmd, opts) { - commandsSeen.push({ cmd, opts }); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - }); - - return git.configureUser('/repo', 'Alice', 'alice@example.com', { - config: { - 'http.version': 'HTTP/1.1' - } - }).then(result => { - result.exitCode.should.eql(0); - commandsSeen.map(item => item.cmd).should.eql([ - 'git -c \'http.version=HTTP/1.1\' config \'user.name\' \'Alice\'', - 'git -c \'http.version=HTTP/1.1\' config \'user.email\' \'alice@example.com\'' - ]); - commandsSeen.every(item => item.opts.cwd === '/repo').should.eql(true); - }); - }); - - it('rejects missing sandbox ids and encodes nested template file paths', function () { - const client = new qiniu.sandbox.SandboxClient({ - endpoint: 'http://sandbox.test', - apiKey: 'sandbox-key' - }); - const requests = []; - client.httpClient.sendRequest = req => { - requests.push(req); - return Promise.resolve({ - ok: () => true, - data: { ok: true } - }); - }; - - return client.getSandbox('').then(() => { - throw new Error('expected getSandbox to reject missing id'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.eql('sandboxID is required'); - return client.deleteSandbox(''); - }).then(() => { - throw new Error('expected deleteSandbox to reject missing id'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.eql('sandboxID is required'); - return client.getSandboxLogs('sbx/with space', { cursor: 'next/page' }); - }).then(() => client.getTemplateFiles('tpl/with space', 'dir/file hash')) - .then(() => { - requests.map(req => req.url).should.eql([ - 'http://sandbox.test/sandboxes/sbx%2Fwith%20space/logs?cursor=next%2Fpage', - 'http://sandbox.test/templates/tpl%2Fwith%20space/files/dir%2Ffile%20hash' - ]); - }); - }); - - it('parses envd stream fallback responses shaped as events, arrays, and single events', function () { - const envd = require('../qiniu/sandbox/envd'); - return startServer((req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - if (req.url === '/stream.EventsObject') { - res.end(JSON.stringify({ - events: [{ event: { start: { pid: 1 } } }] - })); - return; - } - if (req.url === '/stream.Array') { - res.end(JSON.stringify([{ event: { end: { exitCode: 0 } } }])); - return; - } - if (req.url === '/stream.Single') { - res.end(JSON.stringify({ event: { data: { stdout: 'aGVsbG8=' } } })); - return; - } - if (req.url === '/stream.Empty') { - res.end(JSON.stringify({ ok: true })); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_envd_fallbacks', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return envd.connectStreamRPC(sandbox, '/stream.EventsObject', {}) - .then(events => { - events.should.eql([{ event: { start: { pid: 1 } } }]); - return envd.connectStreamRPC(sandbox, '/stream.Array', {}); - }) - .then(events => { - events.should.eql([{ event: { end: { exitCode: 0 } } }]); - return envd.connectStreamRPC(sandbox, '/stream.Single', {}); - }) - .then(events => { - events.should.eql([{ event: { data: { stdout: 'aGVsbG8=' } } }]); - return envd.connectStreamRPC(sandbox, '/stream.Empty', {}); - }) - .then(events => { - events.should.eql([]); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('returns false for PTY kill 404 responses and rethrows other failures', function () { - return startServer((req, res) => { - if (req.url === '/process.Process/SendSignal') { - const body = JSON.parse(req.body); - if (body.process.selector.pid === 404) { - res.statusCode = 404; - res.end('missing'); - return; - } - res.statusCode = 500; - res.end('boom'); - return; - } - res.statusCode = 404; - res.end(); - }).then(fixture => { - const sandbox = new qiniu.sandbox.Sandbox({ - sandboxId: 'sbx_pty_kill', - envdUrl: fixture.endpoint, - info: { - envdAccessToken: 'token' - } - }); - - return sandbox.pty.kill(404) - .then(killed => { - killed.should.eql(false); - return sandbox.pty.kill(500); - }) - .then(() => { - throw new Error('expected non-404 PTY kill to reject'); - }, err => { - err.name.should.eql('SandboxError'); - err.message.should.containEql('status 500'); - }) - .then(() => closeServer(fixture.server), err => { - return closeServer(fixture.server).then(() => { - throw err; - }); - }); - }); - }); - - it('covers Template package helper option branches and build option cleanup', function () { - const bodySeen = []; - const client = { - createTemplateV3: body => { - bodySeen.push(body); - return Promise.resolve({ templateID: 'tpl_1' }); - } - }; - const template = qiniu.sandbox.Template() - .pipInstall({ g: false }) - .npmInstall({ g: true, dev: true }) - .bunInstall({ g: true }) - .setEnvs({}) - .setStartCmd('npm start') - .setReadyCmd('curl -f http://localhost:3000'); - - return template.build({ - client, - endpoint: 'https://sandbox.example.com', - apiKey: 'api-key', - accessToken: 'access-token', - accessKey: 'ak', - secretKey: 'sk', - timeout: 1, - requestTimeoutMs: 2000, - alias: 'tpl-alias' - }).then(ret => { - ret.templateID.should.eql('tpl_1'); - bodySeen.length.should.eql(1); - bodySeen[0].alias.should.eql('tpl-alias'); - should.not.exist(bodySeen[0].client); - should.not.exist(bodySeen[0].endpoint); - should.not.exist(bodySeen[0].apiKey); - should.not.exist(bodySeen[0].accessToken); - should.not.exist(bodySeen[0].accessKey); - should.not.exist(bodySeen[0].secretKey); - should.not.exist(bodySeen[0].timeout); - should.not.exist(bodySeen[0].requestTimeoutMs); - bodySeen[0].buildConfig.startCmd.should.eql('npm start'); - bodySeen[0].buildConfig.readyCmd.should.eql('curl -f http://localhost:3000'); - bodySeen[0].buildConfig.steps.should.eql([ - { type: 'RUN', args: ['pip install --user .'] }, - { type: 'RUN', args: ['npm install -g --save-dev', 'root'] }, - { type: 'RUN', args: ['bun install', 'root'] } - ]); - }); - }); -}); - -function handleGitAndPty (git, pty, commandsSeen, fixture) { - return git.clone('https://example.com/repo.git', { path: '/repo' }) - .then(() => git.init('/repo')) - .then(() => git.add('/repo', { all: true })) - .then(() => git.commit('/repo', 'msg', { allowEmpty: true })) - .then(() => git.pull('/repo', { remote: 'origin', branch: 'main' })) - .then(() => git.push('/repo', { remote: 'origin', branch: 'main' })) - .then(() => git.createBranch('/repo', 'feature')) - .then(() => git.checkoutBranch('/repo', 'main')) - .then(() => git.deleteBranch('/repo', 'feature', { force: true })) - .then(() => git.remoteAdd('/repo', 'origin', 'https://example.com/repo.git')) - .then(() => git.remoteGet('/repo', 'origin')) - .then(value => { - value.should.eql('value'); - return git.setConfig('/repo', 'user.name', 'Alice'); - }) - .then(() => git.getConfig('/repo', 'user.name')) - .then(value => { - value.should.eql('value'); - return git.configureUser('/repo', 'Alice', 'alice@example.com'); - }) - .then(() => pty.create({ cmd: 'bash', cwd: '/repo' })) - .then(handle => { - handle.pid.should.eql(12); - commandsSeen[0].cmd.should.eql('git clone \'https://example.com/repo.git\' \'/repo\''); - commandsSeen[1].cmd.should.eql('git init'); - commandsSeen[1].opts.cwd.should.eql('/repo'); - commandsSeen.some(item => item.cmd.indexOf('git commit -m') === 0).should.eql(true); - const ptyBody = decodeConnectEnvelope(fixture.requests[6].rawBody); - ptyBody.process.cmd.should.eql('bash'); - ptyBody.process.cwd.should.eql('/repo'); - should.exist(ptyBody.pty); - }); -} diff --git a/test/sandbox_helpers.js b/test/sandbox_helpers.js new file mode 100644 index 0000000..2ac6746 --- /dev/null +++ b/test/sandbox_helpers.js @@ -0,0 +1,141 @@ +const should = require('should'); +const http = require('http'); +const fs = require('fs'); +const stream = require('stream'); +const zlib = require('zlib'); + +const qiniu = require('../index'); + +function startServer (handler) { + const requests = []; + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', chunk => { + chunks.push(chunk); + }); + req.on('end', () => { + const rawBody = Buffer.concat(chunks); + const record = { + method: req.method, + url: req.url, + headers: req.headers, + body: rawBody.toString(), + rawBody + }; + requests.push(record); + handler(record, res); + }); + }); + + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + resolve({ + server, + requests, + endpoint: `http://127.0.0.1:${server.address().port}` + }); + }); + }); +} + +function closeServer (server) { + return new Promise(resolve => server.close(resolve)); +} + +function parseUrl (value) { + return new URL(value, 'http://127.0.0.1'); +} + +function decodeConnectEnvelope (body) { + body[0].should.eql(0); + const length = body.readUInt32BE(1); + return JSON.parse(body.slice(5, 5 + length).toString()); +} + +function encodeConnectEnvelope (message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function encodeRawConnectEnvelope (payload, flags) { + payload = Buffer.from(payload); + const header = Buffer.alloc(5); + header[0] = flags || 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function encodeConnectEndEnvelope (message) { + return encodeRawConnectEnvelope(JSON.stringify(message || {}), 2); +} + +function encodeOversizedConnectHeader () { + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(10 * 1024 * 1024 + 1, 1); + return header; +} + +function encodeTruncatedConnectHeader () { + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(20, 1); + return header; +} + +function handleGitAndPty (git, pty, commandsSeen, fixture) { + return git.clone('https://example.com/repo.git', { path: '/repo' }) + .then(() => git.init('/repo')) + .then(() => git.add('/repo', { all: true })) + .then(() => git.commit('/repo', 'msg', { allowEmpty: true })) + .then(() => git.pull('/repo', { remote: 'origin', branch: 'main' })) + .then(() => git.push('/repo', { remote: 'origin', branch: 'main' })) + .then(() => git.createBranch('/repo', 'feature')) + .then(() => git.checkoutBranch('/repo', 'main')) + .then(() => git.deleteBranch('/repo', 'feature', { force: true })) + .then(() => git.remoteAdd('/repo', 'origin', 'https://example.com/repo.git')) + .then(() => git.remoteGet('/repo', 'origin')) + .then(value => { + value.should.eql('value'); + return git.setConfig('/repo', 'user.name', 'Alice'); + }) + .then(() => git.getConfig('/repo', 'user.name')) + .then(value => { + value.should.eql('value'); + return git.configureUser('/repo', 'Alice', 'alice@example.com'); + }) + .then(() => pty.create({ cmd: 'bash', cwd: '/repo' })) + .then(handle => { + handle.pid.should.eql(12); + commandsSeen[0].cmd.should.eql('git clone \'https://example.com/repo.git\' \'/repo\''); + commandsSeen[1].cmd.should.eql('git init'); + commandsSeen[1].opts.cwd.should.eql('/repo'); + commandsSeen.some(item => item.cmd.indexOf('git commit -m') === 0).should.eql(true); + const ptyBody = decodeConnectEnvelope(fixture.requests[6].rawBody); + ptyBody.process.cmd.should.eql('bash'); + ptyBody.process.cwd.should.eql('/repo'); + should.exist(ptyBody.pty); + }); +} + +module.exports = { + should, + http, + fs, + stream, + zlib, + qiniu, + startServer, + closeServer, + parseUrl, + decodeConnectEnvelope, + encodeConnectEnvelope, + encodeRawConnectEnvelope, + encodeConnectEndEnvelope, + encodeOversizedConnectHeader, + encodeTruncatedConnectHeader, + handleGitAndPty +}; From 33e0921cd0bbed623445881a3f273aab644da68d Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Tue, 16 Jun 2026 11:23:50 +0800 Subject: [PATCH 45/48] test(sandbox): rename split tests Remove the temporary ordering prefix now that sandbox coverage is run explicitly before env-gated tests. --- package.json | 2 +- test/{00_sandbox_client.test.js => sandbox_client.test.js} | 0 test/{00_sandbox_commands.test.js => sandbox_commands.test.js} | 0 test/{00_sandbox_facade.test.js => sandbox_facade.test.js} | 0 ...00_sandbox_filesystem.test.js => sandbox_filesystem.test.js} | 0 test/{00_sandbox_git.test.js => sandbox_git.test.js} | 0 test/{00_sandbox_pty.test.js => sandbox_pty.test.js} | 0 test/{00_sandbox_template.test.js => sandbox_template.test.js} | 0 8 files changed, 1 insertion(+), 1 deletion(-) rename test/{00_sandbox_client.test.js => sandbox_client.test.js} (100%) rename test/{00_sandbox_commands.test.js => sandbox_commands.test.js} (100%) rename test/{00_sandbox_facade.test.js => sandbox_facade.test.js} (100%) rename test/{00_sandbox_filesystem.test.js => sandbox_filesystem.test.js} (100%) rename test/{00_sandbox_git.test.js => sandbox_git.test.js} (100%) rename test/{00_sandbox_pty.test.js => sandbox_pty.test.js} (100%) rename test/{00_sandbox_template.test.js => sandbox_template.test.js} (100%) diff --git a/package.json b/package.json index 71d57d0..5823e95 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "scripts": { "check-type": "tsc --noEmit", "test": "NODE_ENV=test mocha -t 300000 --retries 3", - "test:sandbox": "NODE_ENV=test mocha -t 300000 --retries 3 test/00_sandbox_client.test.js test/00_sandbox_commands.test.js test/00_sandbox_facade.test.js test/00_sandbox_filesystem.test.js test/00_sandbox_git.test.js test/00_sandbox_pty.test.js test/00_sandbox_template.test.js", + "test:sandbox": "NODE_ENV=test mocha -t 300000 --retries 3 test/sandbox_client.test.js test/sandbox_commands.test.js test/sandbox_facade.test.js test/sandbox_filesystem.test.js test/sandbox_git.test.js test/sandbox_pty.test.js test/sandbox_template.test.js", "cover": "nyc npm run test", "report": "nyc report --reporter=html", "lint": "eslint ." diff --git a/test/00_sandbox_client.test.js b/test/sandbox_client.test.js similarity index 100% rename from test/00_sandbox_client.test.js rename to test/sandbox_client.test.js diff --git a/test/00_sandbox_commands.test.js b/test/sandbox_commands.test.js similarity index 100% rename from test/00_sandbox_commands.test.js rename to test/sandbox_commands.test.js diff --git a/test/00_sandbox_facade.test.js b/test/sandbox_facade.test.js similarity index 100% rename from test/00_sandbox_facade.test.js rename to test/sandbox_facade.test.js diff --git a/test/00_sandbox_filesystem.test.js b/test/sandbox_filesystem.test.js similarity index 100% rename from test/00_sandbox_filesystem.test.js rename to test/sandbox_filesystem.test.js diff --git a/test/00_sandbox_git.test.js b/test/sandbox_git.test.js similarity index 100% rename from test/00_sandbox_git.test.js rename to test/sandbox_git.test.js diff --git a/test/00_sandbox_pty.test.js b/test/sandbox_pty.test.js similarity index 100% rename from test/00_sandbox_pty.test.js rename to test/sandbox_pty.test.js diff --git a/test/00_sandbox_template.test.js b/test/sandbox_template.test.js similarity index 100% rename from test/00_sandbox_template.test.js rename to test/sandbox_template.test.js From e8ec878245238c73fec3e807bf3d3c8ab97b01e1 Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Tue, 16 Jun 2026 11:31:16 +0800 Subject: [PATCH 46/48] test(sandbox): support legacy url parsing Use the WHATWG URL helper when available and fall back to url.parse for Node 6 and 8 sandbox tests. --- test/sandbox_helpers.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/sandbox_helpers.js b/test/sandbox_helpers.js index 2ac6746..8f40445 100644 --- a/test/sandbox_helpers.js +++ b/test/sandbox_helpers.js @@ -2,6 +2,7 @@ const should = require('should'); const http = require('http'); const fs = require('fs'); const stream = require('stream'); +const legacyUrl = require('url'); const zlib = require('zlib'); const qiniu = require('../index'); @@ -43,7 +44,22 @@ function closeServer (server) { } function parseUrl (value) { - return new URL(value, 'http://127.0.0.1'); + const href = /^https?:\/\//.test(value) ? value : 'http://127.0.0.1' + value; + if (typeof URL !== 'undefined') { + return new URL(href); + } + // eslint-disable-next-line node/no-deprecated-api, dot-notation + const parsed = legacyUrl['parse'](href, true); + parsed.searchParams = { + get: function (key) { + const value = parsed.query[key]; + if (typeof value === 'undefined') { + return null; + } + return Array.isArray(value) ? value[0] : value; + } + }; + return parsed; } function decodeConnectEnvelope (body) { From e670855e32cda70bd1b0aa96d0634f3224980c9a Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Tue, 16 Jun 2026 11:35:08 +0800 Subject: [PATCH 47/48] ci(sandbox): keep fork coverage focused Run sandbox coverage with a sandbox-only include list and avoid wrapping env-gated tests in nyc when Qiniu secrets are unavailable. --- .github/workflows/ci-test.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index da28875..8c608cd 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -24,8 +24,12 @@ jobs: - name: Run cases run: | npm run check-type - nyc --reporter=lcov npm run test:sandbox - nyc --no-clean --reporter=lcov npm test + nyc --reporter=lcov --include 'qiniu/sandbox/**/*.js' npm run test:sandbox + if [ -n "$QINIU_ACCESS_KEY" ] && [ -n "$QINIU_SECRET_KEY" ] && [ -n "$QINIU_TEST_BUCKET" ] && [ -n "$QINIU_TEST_DOMAIN" ]; then + nyc --no-clean --reporter=lcov npm test + else + npm test + fi env: QINIU_ACCESS_KEY: ${{ secrets.QINIU_ACCESS_KEY }} QINIU_SECRET_KEY: ${{ secrets.QINIU_SECRET_KEY }} From 32685e9c34d37dcd193bf688d610f9eabec667fe Mon Sep 17 00:00:00 2001 From: Miclle Zheng Date: Tue, 16 Jun 2026 15:07:00 +0800 Subject: [PATCH 48/48] fix(sandbox): resolve wait on disconnect Complete live command and PTY wait promises immediately when disconnecting so socket event timing cannot leave Node 14 tests hanging. --- qiniu/sandbox/commands.js | 20 +++++++++++++++++++- qiniu/sandbox/pty.js | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/qiniu/sandbox/commands.js b/qiniu/sandbox/commands.js index 4c44154..724aa0d 100644 --- a/qiniu/sandbox/commands.js +++ b/qiniu/sandbox/commands.js @@ -108,6 +108,7 @@ function CommandHandle (commands, pid, result, opts, request, waitPromise) { this.opts = opts || {}; this._request = request; this._waitPromise = waitPromise; + this._resolveWait = null; this.stdout = result && result.stdout ? result.stdout : ''; this.stderr = result && result.stderr ? result.stderr : ''; } @@ -135,11 +136,26 @@ CommandHandle.prototype.kill = function () { }; CommandHandle.prototype.disconnect = function () { + this._disconnected = true; if (this._request) { - this._disconnected = true; this._request.destroy(); this._request = null; } + if (this._resolveWait) { + const result = this.result || { + pid: this.pid, + exitCode: 0, + stdout: this.stdout, + stderr: this.stderr, + error: '' + }; + if (result.exitCode === -1) { + result.exitCode = 0; + } + this.result = result; + this._resolveWait(result); + this._resolveWait = null; + } return Promise.resolve(); }; @@ -212,6 +228,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { cleanupStartTimer(); result.pid = pid || result.pid; handle = new CommandHandle(commands, result.pid, null, opts, req, waitPromise); + handle._resolveWait = resolveWait; settled = true; resolve(handle); } @@ -248,6 +265,7 @@ function connectLiveCommand (commands, procedure, body, opts, fallbackPid) { result.error = end && end.error ? end.error : ''; if (handle) { handle.result = result; + handle._resolveWait = null; } resolveWait(result); } diff --git a/qiniu/sandbox/pty.js b/qiniu/sandbox/pty.js index ba3d6ed..31335b4 100644 --- a/qiniu/sandbox/pty.js +++ b/qiniu/sandbox/pty.js @@ -33,6 +33,7 @@ function LivePtyHandle (pty, pid, request, waitPromise, opts) { this.pid = pid; this._request = request; this._waitPromise = waitPromise; + this._resolveWait = null; this.opts = opts || {}; this.stdout = ''; this.stderr = ''; @@ -52,11 +53,20 @@ LivePtyHandle.prototype.kill = function () { }; LivePtyHandle.prototype.disconnect = function () { + this._disconnected = true; if (this._request) { - this._disconnected = true; this._request.destroy(); this._request = null; } + if (this._resolveWait) { + const result = { + exitCode: 0, + stdout: this.stdout, + stderr: this.stderr + }; + this._resolveWait(result); + this._resolveWait = null; + } return Promise.resolve(); }; @@ -124,6 +134,7 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { if (event.start && !handle) { cleanupStartTimer(); handle = new LivePtyHandle(pty, event.start.pid, req, waitPromise, opts); + handle._resolveWait = resolveWait; settled = true; resolve(handle); } @@ -151,6 +162,9 @@ function connectLivePty (sandbox, procedure, body, opts, pty) { exitCode: event.end.exitCode === undefined ? 0 : event.end.exitCode, error: event.end.error || '' }); + if (handle) { + handle._resolveWait = null; + } resolveWait(result); } }