diff --git a/.github/workflows/windows-standard-user.yml b/.github/workflows/windows-standard-user.yml new file mode 100644 index 0000000000..4158964ea7 --- /dev/null +++ b/.github/workflows/windows-standard-user.yml @@ -0,0 +1,75 @@ +name: Windows standard-user startup +on: + pull_request: + paths: + - 'src/server/effects/storeio.js' + - 'desktop/**' + - 'scripts/windows-standard-user-smoke.ps1' + - '.github/workflows/windows-standard-user.yml' + workflow_dispatch: + inputs: + package_url: + description: Exact packaged Windows ZIP (HTTPS); blank runs production-effect tests only + type: string + default: '' + package_sha256: + type: string + default: '' + expected_version: + type: string + default: '0.14.1' + workflow_call: + inputs: + package_url: + type: string + required: true + package_sha256: + type: string + required: true + expected_version: + type: string + required: true +permissions: + contents: read +jobs: + regressions: + strategy: + matrix: + os: [ubuntu-24.04, macos-15] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: node --test desktop/test/store-link.test.cjs desktop/test/service-startup-error.test.cjs desktop/test/startup-recovery.test.cjs desktop/test/load-recovery.test.cjs desktop/test/windows-service-launch.test.cjs desktop/test/service-recovery.test.cjs + standard-user: + runs-on: windows-2025 + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm --prefix desktop ci --ignore-scripts --no-audit --no-fund + - name: Run with a newly created standard user and Developer Mode OFF + shell: pwsh + env: + PACKAGE_URL: ${{ inputs.package_url }} + PACKAGE_SHA256: ${{ inputs.package_sha256 }} + EXPECTED_VERSION: ${{ inputs.expected_version }} + run: ./scripts/windows-standard-user-smoke.ps1 -PackageUrl $env:PACKAGE_URL -PackageSHA256 $env:PACKAGE_SHA256 -ExpectedVersion $env:EXPECTED_VERSION + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-standard-user-evidence + path: | + build/windows-standard-user/result.json + build/windows-standard-user/package.json + build/windows-standard-user/unit-tests.txt + build/windows-standard-user/*.png + if-no-files-found: error + retention-days: 30 diff --git a/desktop/load-recovery.cjs b/desktop/load-recovery.cjs index 9a2f93ed31..e687cf9836 100644 --- a/desktop/load-recovery.cjs +++ b/desktop/load-recovery.cjs @@ -1,6 +1,6 @@ 'use strict'; -function installLoadRecovery(window, {startupPath, startupURL, origin, session, status, reconnect, quitting, setTimer=setTimeout, clearTimer=clearTimeout}) { +function installLoadRecovery(window, {startupPath, startupURL, origin, session, status, reconnect, quitting, setTimer=setTimeout, clearTimer=clearTimeout, maxProbes=30}) { let recovery; const active = run => run && run === recovery && !quitting() && !window.isDestroyed() && window.webContents.getURL() === startupURL; const wait = async run => { @@ -8,10 +8,16 @@ function installLoadRecovery(window, {startupPath, startupURL, origin, session, run.pending = true; try { // Probe the selected service. An outage must not start another hub. + run.probes++; const response = await session.fetch(origin() + '/hello', {cache:'no-store', signal:AbortSignal.timeout(1000)}); if (!active(run)) return; if (response.ok) { recovery = null;await reconnect();return; } - } catch {} finally { run.pending = false; } + run.error = 'Service returned HTTP ' + response.status; + } catch (error) { run.error = error.message; } finally { run.pending = false; } + if (active(run) && run.probes >= maxProbes) { + status('error', 'Backplane lost its service connection.\n' + run.error + '\nChoose Retry to reconnect.'); + recovery = null;return; + } if (active(run)) { run.timer = setTimer(() => { run.timer = null;void wait(run); }, 1000);run.timer?.unref?.(); } }; window.webContents.on('did-finish-load', () => { @@ -19,10 +25,10 @@ function installLoadRecovery(window, {startupPath, startupURL, origin, session, // Start only after the local document really owns the main frame. if (active(recovery) && !recovery.timer) void wait(recovery); }); - window.webContents.on('did-fail-load', (_event, code, _description, url, main) => { + window.webContents.on('did-fail-load', (_event, code, description, url, main) => { if (!main || code === -3 || quitting() || window.isDestroyed()) return; try { if (new URL(url).origin !== origin()) return; } catch { return; } - clearTimer(recovery?.timer);recovery = {pending:false, timer:null}; + clearTimer(recovery?.timer);recovery = {pending:false, timer:null, probes:0, error:description + ' (' + code + ')'}; // Keep the error in main before replacing Chromium's empty error page. status('connecting', 'Reconnecting to your service…'); void window.loadFile(startupPath).catch(() => {}); diff --git a/desktop/main.cjs b/desktop/main.cjs index 4b04740de6..358f168d82 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -43,7 +43,6 @@ const startupPath = path.join(__dirname, 'startup.html'); const startupURL = pathToFileURL(startupPath).href; const launchedAtMs = Date.now(); let window, origin, connection, serviceReadyAtMs, firstVisibleAtMs, connecting; -let connectTries = 0, connectTimer = null; let connectionStatus = {phase:'connecting', message:'Connecting to your service…'}; let desktopBrowser, browserSession; const browserRestore=new Map(),browserRestoreTasks=new Map(),browserRestoreErrors=new Map(); @@ -178,16 +177,11 @@ async function connect(ses) { } // Start independently of the UI; failed private channels retry in main. if(directBrowser)void connectBrowser(ses).catch(()=>{}); - connectTries = 0; } catch (error) { - // Never a button to press: the service is tried again by itself, a - // growing delay up to 5 s, while the page says it is reconnecting. - status('connecting', 'Reconnecting to your service…'); + // Service.start already bounds recovery/readiness. A failed launch + // needs its actual diagnostic and a deliberate retry, not a spawn loop. + status('error', 'Backplane could not connect to its service.\n' + error.message + '\nFix the problem, then choose Retry.'); console.error('backplane: connect failed:', error?.message); - const wait = Math.min(5000, 500 * 2 ** Math.min(connectTries++, 4)); - clearTimeout(connectTimer); - connectTimer = setTimeout(() => { connectTimer = null;if (!quitting && window && !window.isDestroyed()) void connect(ses); }, wait); - connectTimer.unref?.(); } finally { connecting = null; } })(); return connecting; diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8def4b3e5f..a92fb81bd1 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "backplane-desktop", - "version": "0.14.0", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backplane-desktop", - "version": "0.14.0", + "version": "0.14.1", "devDependencies": { "electron": "44.9.2", "playwright-core": "1.64.1" diff --git a/desktop/package.json b/desktop/package.json index abb719ec3c..730a5a1f3d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "backplane-desktop", - "version": "0.14.0", + "version": "0.14.1", "private": true, "description": "Electron desktop for the Bend Backplane service", "main": "main.cjs", diff --git a/desktop/service.cjs b/desktop/service.cjs index fd61bfbb49..d9f6bda86b 100644 --- a/desktop/service.cjs +++ b/desktop/service.cjs @@ -199,11 +199,21 @@ class Service { // Like `backplane --headless`: the listener stays on 127.0.0.1 and the // hub's own tailnet handling decides whether the owner's phone reaches it. const args = ['--home', launch.home, '--port', String(port), '--host', '127.0.0.1', '--web', launch.web, '--foreground']; - this.child = spawn(this.binary, args, { stdio: ['ignore', 'ignore', 'ignore'], env: launch.env, ...(launch.cwd ? { cwd: launch.cwd } : {}), windowsHide: true, detached: true }); + this.child = spawn(this.binary, args, { stdio: ['ignore', 'ignore', 'pipe'], env: launch.env, ...(launch.cwd ? { cwd: launch.cwd } : {}), windowsHide: true, detached: true }); this.child.unref(); - let failure; - this.child.once('error', () => { failure = new Error('Cannot start the packaged service'); }); - this.child.once('exit', code => { failure = new Error(`Service exited (${code})`); }); + // Bound diagnostics in memory; keep draining after readiness for resident + // services so their stderr can never block. No persistent credential log. + let failure, stderr = Buffer.alloc(0), starting = true; + const stream = this.child.stderr; + stream?.on('data', chunk => { + if (starting) stderr = Buffer.concat([stderr, Buffer.from(chunk)]).subarray(-8192); + }); + stream?.unref?.(); + const diagnostic = () => stderr.toString('utf8').replace(/\x1b\[[0-9;]*m/g, '').trim(); + this.child.once('error', error => { failure = new Error(`Cannot start the packaged service: ${error.message}`); }); + // close follows the final stderr bytes; exit can precede them. + this.child.once('close', (code, signal) => { failure = new Error(`Service exited (${signal || code})${diagnostic() ? ':\n' + diagnostic() : ''}`); }); + try { while (Date.now() < deadline) { if (failure) throw failure; try { @@ -219,7 +229,8 @@ class Service { } await new Promise(resolve => setTimeout(resolve, 100)); } - throw new Error('Service readiness timed out'); + throw new Error(`Service readiness timed out${diagnostic() ? ':\n' + diagnostic() : ''}`); + } finally { starting = false; stderr = Buffer.alloc(0); } } async token() { const token = (await fs.readFile(path.join(this.home, 'token'), 'utf8')).trim(); diff --git a/desktop/startup.css b/desktop/startup.css index 8342b6b191..7db310f013 100644 --- a/desktop/startup.css +++ b/desktop/startup.css @@ -1 +1,2 @@ :root{color-scheme:dark;background:#111410;color:#d9dde4;font:16px ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}body{margin:0;min-height:100vh;display:grid;place-items:center}main{padding:32px;max-width:42rem}h1{font-size:20px}p{overflow-wrap:anywhere;color:#a6adba}button{font:inherit;color:inherit;background:#1a1e25;border:1px solid #5fb3ff;padding:8px 20px}button:focus-visible{outline:2px solid #5fb3ff;outline-offset:3px}@media(max-width:480px){main{padding:20px}}:root.light{color-scheme:light;background:#fafaf8;color:#1b1d21}:root.light p{color:#5b6068}:root.light button{background:#e8e7e2;border-color:#1f6fd1}:root.light button:focus-visible{outline-color:#1f6fd1} +#status{white-space:pre-wrap} diff --git a/desktop/startup.html b/desktop/startup.html index 7704eedafe..746ccc51a4 100644 --- a/desktop/startup.html +++ b/desktop/startup.html @@ -2,4 +2,4 @@ Backplane -

Backplane

Connecting to your service…

+

Backplane

Connecting to your service…

diff --git a/desktop/startup.js b/desktop/startup.js index 155df3053b..8a6923ed4b 100644 --- a/desktop/startup.js +++ b/desktop/startup.js @@ -1,7 +1,7 @@ const status = document.getElementById('status'); -// Main reconnects by itself (it retries until the service answers): this -// page only says so, and has nothing to press. -const update = ({message}) => { status.textContent = message; }; +const retry = document.getElementById('retry'); +const update = ({phase, message}) => { status.textContent = message; retry.hidden = phase !== 'error'; }; +retry.addEventListener('click', () => { retry.hidden = true; void window.backplaneDesktop.retry(); }); window.backplaneDesktop.onConnection(update); // Main outlives this document, including an error and a press on Reload. const current = window.backplaneDesktop.connectionState(); diff --git a/desktop/test/load-recovery.test.cjs b/desktop/test/load-recovery.test.cjs index 361646d03e..ec28a9a4ef 100644 --- a/desktop/test/load-recovery.test.cjs +++ b/desktop/test/load-recovery.test.cjs @@ -24,3 +24,9 @@ test('closing or leaving recovery cancels probing and cannot reopen a window',as const f=fixture();f.fail();await settle();const probes=f.state.probes;change(f);await f.tick();assert.equal(f.state.probes,probes);assert.equal(f.state.reconnects,0); } }); +test('a persistent outage ends with a visible diagnostic and no scheduled retry',async()=>{ + const f=fixture();f.fail();await settle(); + for(let i=0;i<35;i++)await f.tick(); + assert.equal(f.state.probes,30);assert.equal(f.state.timers.size,0); + assert.equal(f.state.status.at(-1)[0],'error');assert.match(f.state.status.at(-1)[1],/Retry/); +}); diff --git a/desktop/test/service-startup-error.test.cjs b/desktop/test/service-startup-error.test.cjs new file mode 100644 index 0000000000..aa737e87ca --- /dev/null +++ b/desktop/test/service-startup-error.test.cjs @@ -0,0 +1,27 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'); +const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),vm=require('node:vm'); +const {EventEmitter}=require('node:events'),{PassThrough}=require('node:stream'); +function fixture(t,fail){ + const home=fs.mkdtempSync(path.join(os.tmpdir(),'bp-launch-failure-'));t.after(()=>fs.rmSync(home,{recursive:true,force:true})); + const child=new EventEmitter();child.stderr=new PassThrough();child.unref=()=>{}; + const module={exports:{}}; + const ctx={module,Buffer,AbortSignal,Date,setTimeout,process:{...process,platform:'linux'},require:n=>n==='node:child_process'?{ + execFile(){},spawn(_binary,_args,opts){assert.equal(opts.stdio[2],'pipe');queueMicrotask(()=>fail(child));return child;} + }:require(n)}; + vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../service.cjs'),'utf8'),ctx); + const service=new module.exports.Service({home,binary:path.join(home,'missing-service'),web:home});service.discover=async()=>null; + return service; +} +test('a service failure includes its final stderr and exit code',async t=>{ + const service=fixture(t,c=>{c.emit('exit',1);c.stderr.write('EPERM: symlink events.jsonl');c.emit('close',1,null)}); + await assert.rejects(service.start(),/Service exited \(1\):\nEPERM: symlink events.jsonl/); +}); +test('spawn errors retain the native failure',async t=>{ + const service=fixture(t,c=>c.emit('error',Error('spawn ENOENT /packaged/service'))); + await assert.rejects(service.start(),/Cannot start the packaged service: spawn ENOENT/); +}); +test('startup diagnostics retain only a bounded tail',async t=>{ + const service=fixture(t,c=>{c.stderr.write('x'.repeat(20000)+'final error');c.emit('close',2,null)}); + await assert.rejects(service.start(),e=>e.message.length<8300&&e.message.endsWith('final error')); +}); diff --git a/desktop/test/startup-recovery.test.cjs b/desktop/test/startup-recovery.test.cjs index d0c849b9d2..af47569a06 100644 --- a/desktop/test/startup-recovery.test.cjs +++ b/desktop/test/startup-recovery.test.cjs @@ -4,39 +4,33 @@ const source=name=>fs.readFileSync(path.join(__dirname,'..',name),'utf8'); function fixture(){ const s=source('main.cjs'),events=[],handlers={}; let reject,resolve; - const ctx={connectionStatus:{phase:'connecting',message:'Connecting to your service…'},connecting:null,quitting:false,directBrowser:false, + const ctx={connectionStatus:{phase:'connecting',message:'Connecting to your service…'},connecting:null,quitting:false,directBrowser:false,pendingPairLink:null,savedRemote:null, service:{start:()=>new Promise((yes,no)=>{resolve=yes;reject=no;}),token:async()=> 'private'}, window:{isDestroyed:()=>false,webContents:{send:(channel,value)=>events.push(value)},loadURL:async()=>{}}, senderIsWindow:e=>e.main===true,startupURL:'file:///app/startup.html',ipcMain:{on:(n,f)=>handlers[n]=f},Date,console:{error(){}}, connectTries:0,connectTimer:null,timers:[],setTimeout:(f,ms)=>{ctx.timers.push({f,ms});return {unref(){}};},clearTimeout:()=>{}}; - vm.createContext(ctx);vm.runInContext(s.slice(s.indexOf('function status('),s.indexOf('// one attempt at a time:')),ctx); + vm.createContext(ctx);vm.runInContext(s.slice(s.indexOf('function status('),s.indexOf('// Startup, wake and retry')),ctx); const ses={fetch:async()=>({ok:true})}; return {ctx,events,handlers,start:()=>ctx.connect(ses),fail:message=>reject(Error(message)),ready:()=>resolve({origin:'http://127.0.0.1:4000'})}; } function page(main){ - const nodes={status:{textContent:'Connecting to your service…'}}; + let click; + const nodes={status:{textContent:'Connecting to your service…'},retry:{hidden:true,addEventListener:(name,fn)=>{assert.equal(name,'click');click=fn;}}}; let listener; vm.runInNewContext(source('startup.js'),{document:{getElementById:id=>nodes[id]},window:{backplaneDesktop:{onConnection:f=>listener=f,connectionState:()=>main.ctx.connectionStatus,retry:()=>main.start()}}}); - return{nodes,receive:()=>listener(main.ctx.connectionStatus)}; + return{nodes,receive:()=>listener(main.ctx.connectionStatus),click:()=>click()}; } -test('a failed startup reconnects by itself, says so, and has nothing to press',async()=>{ +test('a failed startup retains the actual error across reloads and waits for Retry',async()=>{ const f=fixture(),pending=f.start();f.fail('Could not read credentials');await pending; const first=page(f),reloaded=page(f); - for(const p of [first,reloaded]){assert.equal(p.nodes.status.textContent,'Reconnecting to your service…');assert.equal(p.nodes.retry,undefined);} - assert.equal(f.ctx.connectionStatus.phase,'connecting'); - // the next attempt is due by itself, after a delay that grows to at most 5 s - assert.equal(f.ctx.timers.length,1);assert.equal(f.ctx.timers[0].ms,500); - const again=f.ctx.timers.shift();again.f(); - assert.notEqual(f.ctx.connecting,null);f.fail('still down');await new Promise(r=>setImmediate(r)); - assert.equal(f.ctx.timers.length,1);assert.equal(f.ctx.timers[0].ms,1000); - f.ctx.connectTries=10;f.ctx.timers.shift().f();f.fail('still down');await new Promise(r=>setImmediate(r)); - assert.equal(f.ctx.timers[0].ms,5000); - f.ctx.timers.shift().f();f.ready();await new Promise(r=>setImmediate(r));await new Promise(r=>setImmediate(r)); - assert.equal(f.ctx.connecting,null);assert.equal(f.ctx.connectTries,0);assert.equal(f.ctx.timers.length,0); + for(const p of [first,reloaded]){assert.match(p.nodes.status.textContent,/Could not read credentials/);assert.equal(p.nodes.retry.hidden,false);} + assert.equal(f.ctx.connectionStatus.phase,'error');assert.equal(f.ctx.timers.length,0); + first.click();assert.notEqual(f.ctx.connecting,null);first.receive();assert.equal(first.nodes.retry.hidden,true); + f.ready();await f.ctx.connecting;assert.equal(f.ctx.connecting,null);assert.equal(f.ctx.timers.length,0); }); test('a reload during an attempt never starts a second service attempt',async()=>{ const f=fixture(),pending=f.start();page(f);page(f);assert.equal(f.events.length,1);f.fail('Unavailable');await pending; - assert.equal(f.ctx.timers.length,1);assert.equal(page(f).nodes.status.textContent,'Reconnecting to your service…'); + assert.equal(f.ctx.timers.length,0);assert.match(page(f).nodes.status.textContent,/Unavailable/); }); test('connection state is answered only to this window main frame on the local startup document',()=>{ const f=fixture(),read=(main,url)=>{const e={main,senderFrame:{url}};f.handlers['desktop:connection-now'](e);return e.returnValue;}; diff --git a/desktop/test/store-link.test.cjs b/desktop/test/store-link.test.cjs new file mode 100644 index 0000000000..21b388f777 --- /dev/null +++ b/desktop/test/store-link.test.cjs @@ -0,0 +1,33 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'); +const fs=require('node:fs'),path=require('node:path'),os=require('node:os'),vm=require('node:vm'); +// Execute the production effect, with the real filesystem. The Windows CI +// invokes this as a standard user after proving file symlinks are forbidden. +function effect(platform=process.platform,files=fs){ + const ctx={process:{platform},require:n=>n==='fs'?files:require(n)}; + const source=fs.readFileSync(path.join(__dirname,'../../src/server/effects/storeio.js'),'utf8'); + vm.createContext(ctx);vm.runInContext(source.slice(0,source.indexOf('function store_sync(')),ctx); + return ctx.store_temp; +} +test('Windows rebuild reads the same log through a hardlink and unlink leaves the source intact',t=>{ + const home=fs.mkdtempSync(path.join(os.tmpdir(),'bp-log 日本語 '));t.after(()=>fs.rmSync(home,{recursive:true,force:true})); + const log=path.join(home,'events.jsonl');fs.writeFileSync(log,'original\n'); + const files=Object.create(fs);files.symlinkSync=()=>{throw Error('Windows symlink forbidden')}; + const temp=effect('win32',files)(home),linked=path.join(temp,'events.jsonl'); + assert.equal(fs.lstatSync(linked).isSymbolicLink(),false);assert.equal(fs.statSync(log).nlink,2); + assert.equal(fs.statSync(linked).ino,fs.statSync(log).ino); + fs.appendFileSync(log,'next\n');assert.equal(fs.readFileSync(linked,'utf8'),'original\nnext\n'); + fs.unlinkSync(linked);fs.rmdirSync(temp);assert.equal(fs.readFileSync(log,'utf8'),'original\nnext\n'); +}); +test('POSIX rebuild retains its symlink behavior',{skip:process.platform==='win32'},t=>{ + const home=fs.mkdtempSync(path.join(os.tmpdir(),'bp-log-'));t.after(()=>fs.rmSync(home,{recursive:true,force:true})); + fs.writeFileSync(path.join(home,'events.jsonl'),'keep');const temp=effect()(home); + assert(fs.lstatSync(path.join(temp,'events.jsonl')).isSymbolicLink()); +}); +test('failed link creation preserves the log and removes only its empty rebuild directory',t=>{ + const home=fs.mkdtempSync(path.join(os.tmpdir(),'bp-log-'));t.after(()=>fs.rmSync(home,{recursive:true,force:true})); + fs.writeFileSync(path.join(home,'events.jsonl'),'keep');const files=Object.create(fs); + files.linkSync=()=>{throw Object.assign(Error('hardlink denied'),{code:'EACCES'})}; + assert.throws(()=>effect('win32',files)(home),/hardlink denied/); + assert.deepEqual(fs.readdirSync(home),['events.jsonl']);assert.equal(fs.readFileSync(path.join(home,'events.jsonl'),'utf8'),'keep'); +}); diff --git a/desktop/test/windows-standard-user.cjs b/desktop/test/windows-standard-user.cjs new file mode 100644 index 0000000000..58bb026eda --- /dev/null +++ b/desktop/test/windows-standard-user.cjs @@ -0,0 +1,70 @@ +'use strict'; +// Invoked only by the disposable hosted Windows runner's created local user. +// The packaged app starts its own hub from an EMPTY home; no service prestart, +// Electron replacement, source overlay, admin token or --no-sandbox. +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'); +const {execFileSync,spawnSync}=require('node:child_process'); +const {createHash}=require('node:crypto'); +const out=path.resolve(process.argv[2]),executable=process.argv[3],version=process.argv[4]; +fs.mkdirSync(out,{recursive:true}); +const report={status:'INCOMPLETE',platform:process.platform,checks:[],sourceCommit:process.env.GITHUB_SHA}; +const save=()=>fs.writeFileSync(path.join(out,'result.json'),JSON.stringify(report,null,2)+'\n'); +const shell=path.join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'); +async function main(){ + assert.equal(process.platform,'win32'); + report.identity=JSON.parse(execFileSync(shell,['-NoProfile','-NonInteractive','-Command', + "$i=[Security.Principal.WindowsIdentity]::GetCurrent(); $p=[Security.Principal.WindowsPrincipal]::new($i); $d=Get-ItemPropertyValue 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\AppModelUnlock' AllowDevelopmentWithoutDevLicense -ErrorAction SilentlyContinue; @{name=$i.Name;sid=$i.User.Value;admin=$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator);developerMode=($d -eq 1)} | ConvertTo-Json -Compress" + ],{encoding:'utf8'})); + assert.equal(report.identity.admin,false,'Smoke must run with a standard user token'); + assert.equal(report.identity.developerMode,false,'Developer Mode must be OFF'); + report.privileges=execFileSync('whoami.exe',['/priv'],{encoding:'utf8'}); + assert(!report.privileges.includes('SeCreateSymbolicLinkPrivilege'),'User must not hold symlink privilege'); + const source=path.join(out,'symlink-source');fs.writeFileSync(source,'negative control'); + let denied; + try{fs.symlinkSync(source,path.join(out,'forbidden-symlink'),'file');}catch(error){denied=error.code;} + assert(['EPERM','EACCES'].includes(denied),'Negative control: real file symlink must fail'); + report.symlinkNegativeControl=denied;report.checks.push('standard account, Developer Mode OFF, no symlink privilege, actual symlink denied');save(); + const units=spawnSync(process.execPath,['--test',...['store-link','service-startup-error','startup-recovery','load-recovery','windows-service-launch','service-recovery'].map(n=>path.join(__dirname,n+'.test.cjs'))],{encoding:'utf8',timeout:60000}); + fs.writeFileSync(path.join(out,'unit-tests.txt'),(units.stdout||'')+(units.stderr||'')); + assert.equal(units.status,0,'Standard-user regression tests failed');report.checks.push('production store effect and desktop error/recovery regressions');save(); + if(!executable){report.status='PASS';report.scope='Standard-user production-effect and desktop startup tests; packaged launch requires package input';return;} + assert(path.isAbsolute(executable));assert(version); + report.packageExe={path:executable,sha256:createHash('sha256').update(fs.readFileSync(executable)).digest('hex')}; + const {_electron}=require('playwright-core'); + const env={...process.env}; + for(const k of Object.keys(env))if(/TOKEN|API_KEY|SECRET|PASSWORD|CREDENTIAL/.test(k)||['BACKPLANE_SERVICE','BACKPLANE_WEB','BACKPLANE_SERVICE_VERSION','ELECTRON_RUN_AS_NODE','NODE_OPTIONS'].includes(k))delete env[k]; + const home=path.join(out,'Fresh home 日本語');assert(!fs.existsSync(home)); + const agents=path.join(out,'blocked-agents');fs.mkdirSync(agents); + Object.assign(env,{BACKPLANE_HOME:home,BACKPLANE_AGENTS:agents,BACKPLANE_NO_UPDATE:'1',BACKPLANE_TELEMETRY:'off',BACKPLANE_PEERS:'',HOME:process.env.USERPROFILE}); + let app,page; + try{ + app=await _electron.launch({executablePath:executable,args:[],env,chromiumSandbox:true,timeout:60000}); + page=await app.firstWindow(); + await page.waitForFunction(()=>document.documentElement.dataset.backplaneRemoteReady==='true',null,{timeout:90000}); + const url=new URL(page.url());assert.equal(url.hostname,'127.0.0.1');assert.equal(url.protocol,'http:'); + report.versions=await page.evaluate(()=>window.backplaneDesktop.version());assert.equal(report.versions.service,version); + const {Service}=require('../service.cjs'); + const service=new Service({home,expectedVersion:version});const connected=await service.discover();assert.equal(connected.origin,url.origin); + const db=path.join(home,'store.db');assert(fs.statSync(db).size>0,'Cold startup must build store.db'); + const cookies=await app.evaluate(async({BrowserWindow})=>{ + const w=BrowserWindow.getAllWindows()[0],p=w.webContents.getLastWebPreferences(); + const cookies=await w.webContents.session.cookies.get({url:w.webContents.getURL(),name:'bp_token'}); + return{count:cookies.length,httpOnly:cookies[0]?.httpOnly,prefs:{sandbox:p.sandbox,nodeIntegration:p.nodeIntegration,contextIsolation:p.contextIsolation}}; + }); + assert.equal(cookies.count,1);assert.equal(cookies.httpOnly,true);assert.deepEqual(cookies.prefs,{sandbox:true,nodeIntegration:false,contextIsolation:true}); + report.window={origin:url.origin,...cookies};await page.screenshot({path:path.join(out,'main-ui.png')}); + report.checks.push('exact packaged EXE starts its own hub from empty Unicode home, builds store, authenticates, reaches sandboxed main UI');save(); + }finally{if(app)await app.close();} + // An unavailable packaged service must remain a visible terminal error. + app=null; + try{ + const failedEnv={...env,BACKPLANE_HOME:path.join(out,'Failure home'),BACKPLANE_SERVICE:path.join(out,'missing-service.exe')}; + app=await _electron.launch({executablePath:executable,args:[],env:failedEnv,chromiumSandbox:true,timeout:60000}); + page=await app.firstWindow();await page.locator('#retry').waitFor({state:'visible',timeout:15000}); + const message=await page.locator('#status').innerText();assert.match(message,/ENOENT/); + await page.waitForTimeout(7000);assert.equal(await page.locator('#status').innerText(),message);assert(await page.locator('#retry').isVisible()); + await page.screenshot({path:path.join(out,'startup-error.png')});report.checks.push('packaged missing-service error stays visible with Retry after seven seconds'); + }finally{if(app)await app.close();} + report.status='PASS';report.scope='Exact packaged desktop startup, first-run service/store, authenticated main UI and terminal failure UI as standard user'; +} +main().catch(error=>{report.status='FAIL';report.error=error.stack;console.error(error.stack);process.exitCode=1;}).finally(save); diff --git a/scripts/windows-standard-user-smoke.ps1 b/scripts/windows-standard-user-smoke.ps1 new file mode 100644 index 0000000000..5383787f37 --- /dev/null +++ b/scripts/windows-standard-user-smoke.ps1 @@ -0,0 +1,58 @@ +param([string]$PackageUrl='', [string]$PackageSHA256='', [string]$ExpectedVersion='') +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$out = Join-Path $root 'build\windows-standard-user' +New-Item -ItemType Directory -Force $out | Out-Null +$exe = '' +if ($PackageUrl) { + if ($PackageUrl -notmatch '^https://' -or $PackageSHA256 -notmatch '^[a-f0-9]{64}$' -or !$ExpectedVersion) { throw 'Supply HTTPS package URL, SHA256 and expected version' } + $zip = Join-Path $out 'package.zip' + Invoke-WebRequest $PackageUrl -OutFile $zip + if ((Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() -ne $PackageSHA256) { throw 'Package SHA256 mismatch' } + $package = Join-Path $out 'package' + Expand-Archive $zip -DestinationPath $package + $found = @(Get-ChildItem $package -Recurse -File -Filter Backplane.exe) + if ($found.Count -ne 1) { throw 'Expected exactly one packaged Backplane.exe' } + $exe = $found[0].FullName + @{url=$PackageUrl;sha256=$PackageSHA256;version=$ExpectedVersion} | ConvertTo-Json | Set-Content (Join-Path $out 'package.json') +} +# This bootstrap runs on a disposable hosted runner. Only it is elevated; +# every assertion, native filesystem effect and Electron process runs below +# as a newly created user that has never belonged to Administrators. +$key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' +New-Item $key -Force | Out-Null +New-ItemProperty $key -Name AllowDevelopmentWithoutDevLicense -Value 0 -PropertyType DWord -Force | Out-Null +$name = 'bp_smoke_' + [Guid]::NewGuid().ToString('N').Substring(0,8) +$password = ConvertTo-SecureString ('Bp!9' + [Guid]::NewGuid().ToString('N')) -AsPlainText -Force +$user = New-LocalUser -Name $name -Password $password -AccountNeverExpires +$users = Get-LocalGroup -SID 'S-1-5-32-545' +Add-LocalGroupMember -Group $users -Member $name +try { + # Read checkout/dependencies/package; write only the disposable smoke area. + & icacls $root /grant "${name}:(OI)(CI)RX" /T /Q | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Cannot grant checkout read access' } + & icacls $out /grant "${name}:(OI)(CI)M" /T /Q | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Cannot grant evidence write access' } + $node = (Get-Command node.exe).Source + $test = Join-Path $root 'desktop\test\windows-standard-user.cjs' + $arguments = @('"'+$test+'"','"'+$out+'"') + if ($exe) { $arguments += @('"'+$exe+'"','"'+$ExpectedVersion+'"') } + $credential = [PSCredential]::new("$env:COMPUTERNAME\$name",$password) + $p = Start-Process -FilePath $node -ArgumentList $arguments -Credential $credential -LoadUserProfile -WorkingDirectory $out -PassThru + if (!$p.WaitForExit(240000)) { Stop-Process -Id $p.Id;throw 'Standard-user smoke exceeded four minutes' } + if ($p.ExitCode -ne 0) { throw "Standard-user smoke failed ($($p.ExitCode)); see result.json" } + $result = Get-Content (Join-Path $out 'result.json') -Raw | ConvertFrom-Json + if ($result.status -ne 'PASS') { throw 'Standard-user smoke did not pass' } + $result | ConvertTo-Json -Depth 10 +} finally { + # Retire only this disposable account's processes, including its resident + # hub. Never signal a process by name or a PID parsed from hub.lock. + Get-CimInstance Win32_Process | ForEach-Object { + $process = $_ + try { + $owner = Invoke-CimMethod -InputObject $process -MethodName GetOwnerSid -ErrorAction Stop + if ($owner.Sid -eq $user.SID.Value) { Stop-Process -Id $process.ProcessId -ErrorAction SilentlyContinue } + } catch {} + } + Remove-LocalUser -Name $name +} diff --git a/src/server/effects/storeio.js b/src/server/effects/storeio.js index 74085047b6..ed377df685 100644 --- a/src/server/effects/storeio.js +++ b/src/server/effects/storeio.js @@ -2,7 +2,14 @@ function store_temp(home) { const fs = require('fs'), path = require('path'); const temp = fs.mkdtempSync(path.join(home, '.store-rebuild-')); - fs.symlinkSync(path.resolve(home, 'events.jsonl'), path.join(temp, 'events.jsonl')); + try { + // The rebuild lives inside home, on the same volume. A Windows hardlink + // needs neither elevation nor Developer Mode, and still reads the exact + // authoritative log without copying it or changing its append semantics. + const source = path.resolve(home, 'events.jsonl'), dest = path.join(temp, 'events.jsonl'); + if (process.platform === 'win32') fs.linkSync(source, dest); + else fs.symlinkSync(source, dest); + } catch (error) { fs.rmdirSync(temp); throw error; } return temp; } function store_sync(path) {