From 495a13c8e2bda7b030bbab279909d425084388b0 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 20 Aug 2026 18:30:13 +0000 Subject: [PATCH] fix(autofix): pin resolver binary, make image check digest-aware (#9527) --- .../scripts/resolve-sandbox-image.test.mjs | 11 +++++ .github/workflows/qwen-autofix.yml | 14 ++++++ packages/cli/src/utils/sandbox.test.ts | 45 +++++++++++++++++++ packages/cli/src/utils/sandbox.ts | 7 ++- 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/.github/scripts/resolve-sandbox-image.test.mjs b/.github/scripts/resolve-sandbox-image.test.mjs index 88b209c235..ddac6161ca 100644 --- a/.github/scripts/resolve-sandbox-image.test.mjs +++ b/.github/scripts/resolve-sandbox-image.test.mjs @@ -678,6 +678,17 @@ test('every sandbox-image consumer binds the resolver step output', () => { resolver.id, `${name} job '${jobName}': the resolver step needs an id so its image output is addressable`, ); + // The export is derived from this binary's stdout: an unpinned + // SANDBOX_COMMAND (or the bare `docker` default) is steerable + // through the same $GITHUB_ENV-append channel the DOCKER_HOST pin + // closes, and a PATH shadow in the runner-writable qwen-bin dir + // defeats a bare-name pin — so step env must bind an absolute + // path (#9527 review). + assert.equal( + resolver.env?.SANDBOX_COMMAND, + '/usr/bin/docker', + `${name} job '${jobName}': pin SANDBOX_COMMAND to an absolute docker path so neither an appended $GITHUB_ENV value nor a $GITHUB_PATH shadow can steer the exported digest`, + ); } const bindings = resolvers.map( (resolver) => `\${{ steps.${resolver.id}.outputs.image }}`, diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index b42d96944d..18ea66cd1d 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -1099,6 +1099,13 @@ jobs: id: 'sandbox_image' if: |- ${{ steps.scan.outputs.has_candidates == 'true' }} + env: + # The export is derived from this binary's stdout, so step env + # pins it the way the DOCKER_HOST pins do: step env outranks an + # appended $GITHUB_ENV value, and the absolute path cannot be + # shadowed by a '${RUNNER_TEMP}/qwen-bin' or $GITHUB_PATH plant + # (#9527 review). + SANDBOX_COMMAND: '/usr/bin/docker' run: |- node .github/scripts/resolve-sandbox-image.mjs \ "$(node -p "require('./package.json').config.sandboxImageUri")" @@ -3926,6 +3933,13 @@ jobs: - name: 'Resolve sandbox image' id: 'sandbox_image' + env: + # The export is derived from this binary's stdout, so step env + # pins it the way the DOCKER_HOST pins do: step env outranks an + # appended $GITHUB_ENV value, and the absolute path cannot be + # shadowed by a '${RUNNER_TEMP}/qwen-bin' or $GITHUB_PATH plant + # (#9527 review). + SANDBOX_COMMAND: '/usr/bin/docker' run: |- node .github/scripts/resolve-sandbox-image.mjs \ "$(node -p "require('./package.json').config.sandboxImageUri")" diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index d0139d5029..4116b8e990 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -97,6 +97,51 @@ describe('start_sandbox', () => { child.emit('close', 0); await expect(result).resolves.toBe(0); }); + + it('checks image presence with an offline inspect that sees digest references', async () => { + vi.stubEnv('SANDBOX_SET_UID_GID', 'false'); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'realpathSync').mockImplementation((filePath) => + String(filePath), + ); + execSyncMock.mockReturnValue(Buffer.from('')); + + const digestImage = + 'ghcr.io/qwenlm/qwen-code@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + const imageCheck = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + }); + const child = new EventEmitter(); + spawnMock + .mockImplementationOnce(() => { + queueMicrotask(() => { + imageCheck.stdout.emit('data', Buffer.from('sha256:local')); + imageCheck.emit('close', 0); + }); + return imageCheck; + }) + .mockReturnValueOnce(child); + + const result = start_sandbox({ command: 'docker', image: digestImage }, []); + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)); + // Digest references never appear in a `docker images -q ` listing + // even when the content is local, which forced a needless network pull + // (and a FatalSandboxError whenever the registry was unreachable) at + // every consumer startup; the presence check must be the offline + // `image inspect` (#9527 review). + expect(spawnMock.mock.calls[0]).toEqual([ + 'docker', + ['image', 'inspect', '--format', '{{.Id}}', digestImage], + ]); + // Content found locally: no pull is attempted — the next spawn is the + // sandbox run itself. + expect((spawnMock.mock.calls[1]?.[1] as string[])[0]).toBe('run'); + + child.emit('close', 0); + await expect(result).resolves.toBe(0); + }); }); describe('resolveSeatbeltProfileFile', () => { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 706ef1dec4..af91d8e255 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -947,7 +947,12 @@ export async function start_sandbox( // Helper functions to ensure sandbox image is present async function imageExists(sandbox: string, image: string): Promise { return new Promise((resolve) => { - const args = ['images', '-q', image]; + // `images -q` lists repository:tag entries only, so a digest reference + // (`repo@sha256:…`) lists empty even when its content is local — forcing + // a needless registry round-trip for content already present. `image + // inspect` resolves digest references against local content offline + // (#9527). + const args = ['image', 'inspect', '--format', '{{.Id}}', image]; const checkProcess = spawn(sandbox, args); let stdoutData = '';