mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
ci(windows): qualify actual Terminal clipboard input before release
Run the canonical CI executable and Windows x64 release artifact through a pinned official portable Windows Terminal. Paste 24 Unicode lines via the Windows clipboard, assert no request before Enter, and verify one complete prompt plus rendered reply through a loopback provider. Retain screenshots, input receipts, exact binary identity and failure diagnostics in Actions. The fixture has an isolated home, no inherited secrets and no provider cost. Frozen-source gate: npm test 540 Node and 772 web passed, 0 failed, 7 skipped; npm run check:web passed (894 pages). Node syntax and actionlint with existing CI allowances passed. All 3,954 tracked blobs unchanged after the gate. Actual Windows desktop execution is a required hosted check, not claimed by these local checks. Refs #6877.
This commit is contained in:
1 parent
de77d9fef1
commit
642d7f3cb3
4 files changed
+283
No files matched your search
@@ -999,6 +999,19 @@ jobs:
|
||||
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
|
||||
env:
|
||||
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
- name: Windows Terminal clipboard acceptance
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
|
||||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: node scripts/release/windows-terminal-clipboard.mjs target/debug/codewhale.exe "${{ runner.temp }}/windows-clipboard"
|
||||
- name: Retain Windows Terminal clipboard evidence
|
||||
if: ${{ !cancelled() && matrix.os == 'windows-latest' && needs.changes.outputs.heavy == 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: windows-clipboard-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/windows-clipboard/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: Run tests
|
||||
# Same test binaries as `cargo test`, run by cargo-nextest: one
|
||||
# process per test, all runner cores busy, slow tests named instead
|
||||
|
||||
@@ -270,6 +270,19 @@ jobs:
|
||||
bin_dir="target/${{ matrix.target }}/dist"
|
||||
"${bin_dir}/${{ matrix.cli_binary }}" --version
|
||||
"${bin_dir}/${{ matrix.shim_binary }}" --version
|
||||
- name: Windows Terminal clipboard acceptance
|
||||
if: matrix.platform == 'windows-x64'
|
||||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: node scripts/release/windows-terminal-clipboard.mjs "target/${{ matrix.target }}/dist/codewhale.exe" "${{ runner.temp }}/windows-clipboard"
|
||||
- name: Retain Windows Terminal clipboard evidence
|
||||
if: ${{ !cancelled() && matrix.platform == 'windows-x64' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-windows-clipboard-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/windows-clipboard/
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ inputs.retention_days }}
|
||||
- name: Stage binaries
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Actual Windows Terminal clipboard acceptance on disposable GitHub runners.
|
||||
// Uses a synthetic home and loopback provider; no credentials or paid inference.
|
||||
// This qualifies clipboard delivery in the runner's interactive session, not
|
||||
// every Windows desktop, IME, remote-desktop client or terminal distribution.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
assert.equal(process.platform, 'win32', 'requires a real Windows desktop');
|
||||
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'disposable GitHub runner only');
|
||||
const [binaryArg, evidenceArg] = process.argv.slice(2);
|
||||
assert.ok(binaryArg && evidenceArg, 'usage: node windows-terminal-clipboard.mjs BINARY EVIDENCE_DIR');
|
||||
const binary = path.resolve(binaryArg);
|
||||
const evidence = path.resolve(evidenceArg);
|
||||
await fs.mkdir(evidence, { recursive: true });
|
||||
const root = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP, 'cw-clipboard-'));
|
||||
const qaHome = path.join(root, 'home');
|
||||
const workspace = path.join(root, 'workspace');
|
||||
const terminalRoot = path.join(root, 'terminal');
|
||||
const title = `Codewhale clipboard ${randomUUID()}`;
|
||||
const model = 'gpt-4o-mini';
|
||||
const expected = Array.from({ length: 24 }, (_, n) => `cw-clipboard-${String(n + 1).padStart(2, '0')} 漢字 🐳 line ${n + 1}`).join('\n');
|
||||
const requests = [];
|
||||
const receipt = { source: process.env.GITHUB_SHA, binary, result: 'failed', lineCount: 24, provider: 'loopback fixture', title };
|
||||
let desktop;
|
||||
let desktopExit;
|
||||
let desktopError = '';
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ object: 'list', data: [{ id: model, object: 'model', owned_by: 'fixture' }] }));
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404); res.end(); return;
|
||||
}
|
||||
try {
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
assert.ok(size <= 2 * 1024 * 1024, 'fixture request exceeds limit');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks));
|
||||
const users = (body.messages ?? []).filter(m => m.role === 'user').map(m => typeof m.content === 'string'
|
||||
? m.content : (m.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n'));
|
||||
requests.push(users);
|
||||
// Retain only synthetic user input; never dump headers or system prompts.
|
||||
await fs.writeFile(path.join(evidence, 'provider-user-messages.json'), JSON.stringify(requests, null, 2));
|
||||
const response = { id: 'clipboard-fixture', object: 'chat.completion', created: 1, model,
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'cw-clipboard-ok' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };
|
||||
if (body.stream) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
|
||||
res.write(`data: ${JSON.stringify({ ...response, object: 'chat.completion.chunk', choices: [{ index: 0, delta: { role: 'assistant', content: 'cw-clipboard-ok' }, finish_reason: null }] })}\n\n`);
|
||||
res.write(`data: ${JSON.stringify({ ...response, object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`);
|
||||
res.end('data: [DONE]\n\n');
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response));
|
||||
}
|
||||
} catch (error) { desktopError ||= String(error); res.writeHead(500); res.end(); }
|
||||
});
|
||||
|
||||
async function until(check, label, milliseconds = 60_000) {
|
||||
const deadline = Date.now() + milliseconds;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await check();
|
||||
if (result) return result;
|
||||
assert.equal(desktop?.exitCode, null, `desktop stopped while waiting for ${label}: ${desktopError}`);
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error(`deadline waiting for ${label}: ${desktopError}`);
|
||||
}
|
||||
const exists = async name => fs.access(path.join(evidence, name)).then(() => true, () => false);
|
||||
try {
|
||||
receipt.binarySha256 = createHash('sha256').update(await fs.readFile(binary)).digest('hex');
|
||||
receipt.version = execFileSync(binary, ['--version'], { encoding: 'utf8' }).trim();
|
||||
for (const dir of [workspace, path.join(qaHome, '.codewhale'), path.join(qaHome, '.deepseek'), path.join(qaHome, 'AppData', 'Roaming'), path.join(qaHome, 'AppData', 'Local')]) await fs.mkdir(dir, { recursive: true });
|
||||
const config = 'telemetry = false\n[notifications]\nmethod = "off"\ncompletion_sound = "off"\n';
|
||||
for (const folder of ['.codewhale', '.deepseek']) await fs.writeFile(path.join(qaHome, folder, 'config.toml'), config);
|
||||
await fs.writeFile(path.join(evidence, 'expected.txt'), expected.replaceAll('\n', '\r\n'));
|
||||
// Official Microsoft ZIP, pinned by release and GitHub's asset SHA-256.
|
||||
const terminalUrl = 'https://github.com/microsoft/terminal/releases/download/v1.25.2733.0/Microsoft.WindowsTerminal_1.25.2733.0_x64.zip';
|
||||
const terminalHash = 'bf3ef2012f6c44d8340a4c58125acc9498d19b580f9890dc043cdf831852e796';
|
||||
const download = await fetch(terminalUrl, { signal: AbortSignal.timeout(60_000) });
|
||||
assert.equal(download.status, 200, 'official Terminal download');
|
||||
const bytes = Buffer.from(await download.arrayBuffer());
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'), terminalHash, 'Terminal asset digest');
|
||||
const zip = path.join(root, 'terminal.zip'); await fs.writeFile(zip, bytes);
|
||||
await fs.mkdir(terminalRoot);
|
||||
execFileSync('tar.exe', ['-xf', zip, '-C', terminalRoot], { timeout: 60_000 });
|
||||
const files = await fs.readdir(terminalRoot, { recursive: true });
|
||||
const executables = files.filter(f => path.basename(f).toLowerCase() === 'windowsterminal.exe');
|
||||
assert.equal(executables.length, 1, 'unique portable Terminal executable');
|
||||
const terminal = path.join(terminalRoot, executables[0]);
|
||||
const terminalDir = path.dirname(terminal);
|
||||
await fs.writeFile(path.join(terminalDir, '.portable'), '');
|
||||
await fs.mkdir(path.join(terminalDir, 'settings'), { recursive: true });
|
||||
const profile = '{9e4b713f-d69c-4206-85fb-98663b729f17}';
|
||||
await fs.writeFile(path.join(terminalDir, 'settings', 'settings.json'), JSON.stringify({
|
||||
defaultProfile: profile, initialCols: 110, initialRows: 38, confirmCloseAllTabs: false,
|
||||
// The payload is trusted fixture text; this tests Codewhale's paste path,
|
||||
// excluding Terminal's own optional multiline confirmation dialog.
|
||||
multiLinePasteWarning: false, largePasteWarning: false,
|
||||
profiles: { defaults: { font: { size: 12 } }, list: [{ guid: profile, name: 'Codewhale fixture', commandline: 'cmd.exe' }] },
|
||||
actions: [{ command: 'paste', keys: 'ctrl+shift+v' }],
|
||||
}, null, 2));
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`;
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^(PATH|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|PATHEXT|SYSTEMDRIVE|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|PROCESSOR_ARCHITECTURE|NUMBER_OF_PROCESSORS)$/i.test(k)));
|
||||
Object.assign(env, {
|
||||
HOME: qaHome, USERPROFILE: qaHome, APPDATA: path.join(qaHome, 'AppData', 'Roaming'), LOCALAPPDATA: path.join(qaHome, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: path.join(qaHome, '.config'), XDG_DATA_HOME: path.join(qaHome, '.local', 'share'), XDG_CACHE_HOME: path.join(qaHome, '.cache'),
|
||||
CODEWHALE_CONFIG_PATH: path.join(qaHome, '.codewhale', 'config.toml'), DEEPSEEK_CONFIG_PATH: path.join(qaHome, '.deepseek', 'config.toml'),
|
||||
CODEWHALE_TELEMETRY: '0', CODEWHALE_NO_UPDATE_CHECK: '1', CODEWHALE_DISABLE_MODELS_DEV_FETCH: '1', CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE: '1', NO_ANIMATIONS: '1',
|
||||
});
|
||||
receipt.terminal = { version: '1.25.2733.0', sha256: terminalHash, distribution: 'official portable x64' };
|
||||
const script = fileURLToPath(new URL('./windows-terminal-clipboard.ps1', import.meta.url));
|
||||
desktop = spawn('powershell.exe', ['-NoProfile', '-STA', '-File', script, terminal, binary, workspace, baseUrl, title, evidence], { env, windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
desktop.stdout.on('data', data => { void fs.appendFile(path.join(evidence, 'desktop.log'), data); });
|
||||
desktop.stderr.on('data', data => { desktopError += data; void fs.appendFile(path.join(evidence, 'desktop-error.log'), data); });
|
||||
desktopExit = new Promise((resolve, reject) => { desktop.once('error', reject); desktop.once('exit', resolve); });
|
||||
await until(() => exists('pasted.json'), 'actual clipboard paste');
|
||||
assert.equal(requests.length, 0, 'pasting must not submit any partial turn');
|
||||
receipt.requestsBeforeEnter = 0;
|
||||
await fs.writeFile(path.join(evidence, 'submit'), 'enter once');
|
||||
await until(() => exists('completed.json'), 'rendered fixture reply');
|
||||
await delay(1000);
|
||||
assert.equal(requests.length, 1, 'one Enter must submit exactly one turn');
|
||||
const matching = requests[0].filter(text => text.includes('cw-clipboard-01'));
|
||||
assert.equal(matching.length, 1, 'one user message contains the pasted input');
|
||||
const normalized = matching[0].replaceAll('\r\n', '\n');
|
||||
assert.ok(normalized.includes(expected), 'all24 Unicode lines arrive contiguously and unchanged');
|
||||
for (let line = 1; line <= 24; line++) assert.equal(normalized.split(`cw-clipboard-${String(line).padStart(2, '0')}`).length - 1, 1, `line${line} occurs once`);
|
||||
assert.equal(await desktopExit, 0, desktopError);
|
||||
assert.equal(desktopError, '', 'no desktop or provider fixture errors');
|
||||
receipt.requestsAfterEnter = 1;
|
||||
receipt.result = 'passed';
|
||||
} catch (error) {
|
||||
receipt.error = String(error); process.exitCode = 1;
|
||||
} finally {
|
||||
if (desktop && desktop.exitCode === null) { desktop.kill(); await desktopExit.catch(() => {}); }
|
||||
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
|
||||
await fs.writeFile(path.join(evidence, 'receipt.json'), JSON.stringify(receipt, null, 2));
|
||||
console.log(JSON.stringify(receipt));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Terminal,
|
||||
[Parameter(Mandatory)][string]$Binary,
|
||||
[Parameter(Mandatory)][string]$Workspace,
|
||||
[Parameter(Mandatory)][string]$BaseUrl,
|
||||
[Parameter(Mandatory)][string]$Title,
|
||||
[Parameter(Mandatory)][string]$Evidence
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Windows.Forms, System.Drawing
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class ClipboardFixtureWindow {
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int command);
|
||||
}
|
||||
'@
|
||||
$window = $null
|
||||
$ownedPid = $null
|
||||
function Read-Terminal {
|
||||
$texts = $window.FindAll([System.Windows.Automation.TreeScope]::Descendants,
|
||||
[System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::IsTextPatternAvailableProperty, $true))
|
||||
return (@($texts | ForEach-Object {
|
||||
$_.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern).DocumentRange.GetText(-1)
|
||||
}) -join "`n")
|
||||
}
|
||||
function Wait-For([scriptblock]$Check, [string]$Label) {
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(45)
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
if (& $Check) { return }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
throw "Deadline waiting for $Label"
|
||||
}
|
||||
function Capture([string]$Name) {
|
||||
(Read-Terminal) | Set-Content -Encoding UTF8 (Join-Path $Evidence "$Name.txt")
|
||||
$r = $window.Current.BoundingRectangle
|
||||
if ($r.IsEmpty -or $r.Width -le 0 -or $r.Height -le 0) { throw 'Terminal has no visible window' }
|
||||
$bitmap = [System.Drawing.Bitmap]::new([int]$r.Width, [int]$r.Height)
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
try {
|
||||
$graphics.CopyFromScreen([int]$r.X, [int]$r.Y, 0, 0, $bitmap.Size)
|
||||
$bitmap.Save((Join-Path $Evidence "$Name.png"), [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
} finally { $graphics.Dispose(); $bitmap.Dispose() }
|
||||
}
|
||||
function Focus-OwnedWindow {
|
||||
$process = Get-Process -Id $ownedPid
|
||||
if ($process.Path -ne $Terminal) { throw 'Terminal process identity changed' }
|
||||
$handle = [IntPtr]$window.Current.NativeWindowHandle
|
||||
[void][ClipboardFixtureWindow]::ShowWindow($handle, 9)
|
||||
[void][ClipboardFixtureWindow]::SetForegroundWindow($handle)
|
||||
Wait-For { [ClipboardFixtureWindow]::GetForegroundWindow() -eq $handle } 'owned Terminal focus'
|
||||
}
|
||||
try {
|
||||
# All arguments are owned fixture paths; the fake key cannot reach a paid provider.
|
||||
foreach ($arg in @($Terminal, $Binary, $Workspace, $BaseUrl, $Title)) {
|
||||
if ($arg.Contains('"')) { throw 'Unexpected quote in fixture argument' }
|
||||
}
|
||||
$arguments = "-w new new-tab --title `"$Title`" --suppressApplicationTitle --startingDirectory `"$Workspace`" `"$Binary`" --workspace `"$Workspace`" --no-project-config --fresh --provider openai --model gpt-4o-mini --api-key clipboard-fixture-only --base-url `"$BaseUrl`""
|
||||
Start-Process -FilePath $Terminal -ArgumentList $arguments | Out-Null
|
||||
Wait-For {
|
||||
$windows = [System.Windows.Automation.AutomationElement]::RootElement.FindAll(
|
||||
[System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition)
|
||||
foreach ($candidate in $windows) {
|
||||
if ($candidate.Current.Name.Contains($Title)) {
|
||||
$process = Get-Process -Id $candidate.Current.ProcessId
|
||||
if ($process.Path -eq $Terminal) {
|
||||
$script:window = $candidate; $script:ownedPid = $process.Id; return $true
|
||||
}
|
||||
}
|
||||
}
|
||||
return $false
|
||||
} 'owned Windows Terminal window'
|
||||
Focus-OwnedWindow
|
||||
Wait-For { (Read-Terminal).Contains('Type a message') } 'Codewhale composer'
|
||||
Capture 'ready'
|
||||
$payload = [System.IO.File]::ReadAllText((Join-Path $Evidence 'expected.txt'), [System.Text.Encoding]::UTF8)
|
||||
[System.Windows.Forms.Clipboard]::SetText($payload)
|
||||
if ([System.Windows.Forms.Clipboard]::GetText() -ne $payload) { throw 'Clipboard readback mismatch' }
|
||||
Focus-OwnedWindow
|
||||
[System.Windows.Forms.SendKeys]::SendWait('^+v')
|
||||
# Let every pasted line arrive before asking the independent HTTP observer
|
||||
# whether anything was submitted prematurely.
|
||||
Start-Sleep -Milliseconds 1800
|
||||
Capture 'pasted'
|
||||
@{ processId = $ownedPid; sessionId = (Get-Process -Id $ownedPid).SessionId; chars = $payload.Length; input = 'Windows clipboard + Ctrl+Shift+V' } |
|
||||
ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $Evidence 'pasted.json')
|
||||
Wait-For { Test-Path (Join-Path $Evidence 'submit') } 'independent no-submit assertion'
|
||||
Focus-OwnedWindow
|
||||
[System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
|
||||
Wait-For { (Read-Terminal).Contains('cw-clipboard-ok') } 'actual provider reply'
|
||||
Capture 'completed'
|
||||
@{ enterCount = 1; renderedReply = 'cw-clipboard-ok' } | ConvertTo-Json |
|
||||
Set-Content -Encoding UTF8 (Join-Path $Evidence 'completed.json')
|
||||
} catch {
|
||||
if ($window) { try { Capture 'failure' } catch { Write-Warning $_ } }
|
||||
throw
|
||||
} finally {
|
||||
# Retire only the portable Terminal instance proven to belong to this fixture.
|
||||
if ($ownedPid) {
|
||||
$process = Get-Process -Id $ownedPid -ErrorAction SilentlyContinue
|
||||
if ($process -and $process.Path -eq $Terminal) { Stop-Process -Id $ownedPid -Force }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user