fix(windows,tui): DPAPI over stdin, browser opener without cmd, tag capability check #12
No files matched your search
@@ -11,6 +11,9 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows: the DPAPI keyring passes secrets to PowerShell over stdin**, not on the command line, so a token never appears in the process list or in the Windows event log, and JSON, shell syntax, Unicode and leading or trailing whitespace round-trip unchanged. Secrets are decoded with UTF-8 on both sides, the decrypted BSTR is zeroed after reading, and a PowerShell failure reports "Windows credential protection failed" instead of the subprocess arguments. Carried locally by GG Forge since cli-v1.0.0.
|
||||
- **Windows: `qqq login --browser` opens the whole authorization URL.** The opener was `cmd /c start`, which splits the URL at every `&`, so the browser received only the part before the first query parameter (`client_id`, `state` and `redirect_uri` were lost) and the loopback redirect never came. The URL now goes to PowerShell in the environment variable `QQQ_BROWSER_URL` (`Start-Process -FilePath $env:QQQ_BROWSER_URL`, a fixed encoded command, hidden window; `QQQ_POWERSHELL` picks the shell). Carried locally by GG Forge since cli-v1.0.0.
|
||||
- **`qqq ui`: Tags (t) asks the server first.** The record screen reads the record's collaboration features (`getRecordFeatures`) before opening the tag editor and says "Tags are not enabled for this table." or "Your role cannot tag this record." instead of opening an editor whose save the server would refuse. Carried locally by GG Forge since cli-v1.0.0.
|
||||
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
|
||||
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
|
||||
### Added
|
||||
|
||||
@@ -30,9 +30,9 @@ export interface BrowserSignInOptions {
|
||||
|
||||
/** Opens a URL with the platform's opener; failures are ignored (the URL is printed anyway). */
|
||||
export function openInBrowser(url: string): void {
|
||||
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]
|
||||
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? [process.env.QQQ_POWERSHELL || 'powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from('Start-Process -FilePath $env:QQQ_BROWSER_URL', 'utf16le').toString('base64')]] : ['xdg-open', [url]]
|
||||
try {
|
||||
const child = spawn(command, args as string[], { stdio: 'ignore', detached: true })
|
||||
const child = spawn(command, args as string[], { stdio: 'ignore', detached: true, windowsHide: true, env: { ...process.env, QQQ_BROWSER_URL: url } })
|
||||
child.on('error', () => undefined)
|
||||
child.unref()
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import childProcess from 'node:child_process'
|
||||
import { syncBuiltinESMExports } from 'node:module'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { openInBrowser } from '../browser-login.js'
|
||||
|
||||
test('Windows browser opener preserves OAuth query parameters without shell interpretation', { skip: process.platform !== 'win32' }, () => {
|
||||
const original = childProcess.spawn
|
||||
const url = 'https://example.invalid/authorize?response_type=code&client_id=fixture-native&state=fake%20state&redirect_uri=http%3A%2F%2F127.0.0.1%3A4567%2Fcallback'
|
||||
let captured: unknown[] = []
|
||||
childProcess.spawn = ((...args: unknown[]) => {
|
||||
captured = args
|
||||
return Object.assign(new EventEmitter(), { unref() {} })
|
||||
}) as typeof childProcess.spawn
|
||||
syncBuiltinESMExports()
|
||||
try { openInBrowser(url) } finally { childProcess.spawn = original; syncBuiltinESMExports() }
|
||||
const [command, args, options] = captured as [string, string[], { env: NodeJS.ProcessEnv; windowsHide: boolean }]
|
||||
assert.notEqual(command, 'cmd')
|
||||
assert.equal(options.env.QQQ_BROWSER_URL, url)
|
||||
assert.equal(options.windowsHide, true)
|
||||
const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le')
|
||||
const probe = 'function Start-Process { param([string]$FilePath) [Console]::Write($FilePath) }; ' + script
|
||||
const result = childProcess.spawnSync(command, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(probe, 'utf16le').toString('base64')], { env: options.env, encoding: 'utf8', windowsHide: true })
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(result.stdout, url)
|
||||
})
|
||||
@@ -104,13 +104,28 @@ export class DpapiFileStore implements SecretStore {
|
||||
}
|
||||
private async ps(script: string, input: string): Promise<string> {
|
||||
const shell = process.env.QQQ_POWERSHELL ?? 'powershell'
|
||||
const { stdout } = await run(shell, ['-NoProfile', '-NonInteractive', '-Command', script.replace('$INPUT', JSON.stringify(input))])
|
||||
return stdout.trim()
|
||||
const command = "$ErrorActionPreference = 'Stop'; [Console]::InputEncoding = [Text.UTF8Encoding]::new($false); "
|
||||
+ '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $qqqSecretInput = [Console]::In.ReadToEnd(); '
|
||||
+ '$qqqResult = & { ' + script.replace('$INPUT', '$qqqSecretInput') + ' }; '
|
||||
+ '[Console]::Write((ConvertTo-Json -InputObject $qqqResult -Compress))'
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const failed = () => reject(new Error('Windows credential protection failed; check PowerShell and your Windows profile'))
|
||||
const child = execFile(shell, ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15000 }, (error, stdout) => {
|
||||
if (error) { failed(); return }
|
||||
try {
|
||||
const result: unknown = JSON.parse(stdout.trim())
|
||||
if (typeof result !== 'string') { failed(); return }
|
||||
resolve(result)
|
||||
} catch { failed() }
|
||||
})
|
||||
child.stdin?.on('error', failed)
|
||||
child.stdin?.end(input, 'utf8')
|
||||
})
|
||||
}
|
||||
async get(account: string): Promise<string | undefined> {
|
||||
const blob = this.read()[account]
|
||||
if (!blob) return undefined
|
||||
return this.ps('$s = ConvertTo-SecureString -String $INPUT; [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', blob)
|
||||
return this.ps('$s = ConvertTo-SecureString -String $INPUT; $p = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s); try { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($p) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($p); $s.Dispose() }', blob)
|
||||
}
|
||||
async set(account: string, secret: string): Promise<void> {
|
||||
const blob = await this.ps('ConvertTo-SecureString -String $INPUT -AsPlainText -Force | ConvertFrom-SecureString', secret)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { DpapiFileStore } from '../session/keyring.js'
|
||||
|
||||
test('Windows DPAPI preserves JSON, shell syntax, Unicode and whitespace without plaintext storage', { skip: process.platform !== 'win32' }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-fixture-'))
|
||||
const file = join(dir, 'secrets.dpapi.json')
|
||||
const secret = ' ' + JSON.stringify({ token: 'obviously-fake-credential', syntax: '\"\'`$(){}\\', text: 'héllo 世界\nsecond line' }) + '\n '
|
||||
try {
|
||||
const store = new DpapiFileStore(file)
|
||||
await store.set('fixture', secret)
|
||||
assert.equal(readFileSync(file, 'utf8').includes('obviously-fake-credential'), false)
|
||||
assert.equal(await new DpapiFileStore(file).get('fixture'), secret)
|
||||
assert.equal(await store.delete('fixture'), true)
|
||||
assert.equal(await store.get('fixture'), undefined)
|
||||
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
test('Windows credential failure does not expose subprocess arguments or credential material', { skip: process.platform !== 'win32' }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-failure-'))
|
||||
const file = join(dir, 'secrets.dpapi.json')
|
||||
try {
|
||||
writeFileSync(file, JSON.stringify({ fixture: 'obviously-fake-invalid-dpapi-value' }))
|
||||
await assert.rejects(new DpapiFileStore(file).get('fixture'), error => {
|
||||
assert.equal(String(error), 'Error: Windows credential protection failed; check PowerShell and your Windows profile')
|
||||
return true
|
||||
})
|
||||
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
@@ -611,7 +611,15 @@ export class RecordScreen implements Screen {
|
||||
const collab = collaborationOf(this.store)
|
||||
if (!collab) return this.store.flash(this.store.extensions.missing('collaboration'), 'error')
|
||||
if (this.primaryKey === undefined) return
|
||||
this.store.open(new TagOverlay(this.store, collab, this.tableName, this.primaryKey, (t) => { this.tags = t; this.build() }))
|
||||
const tableName = this.tableName
|
||||
const primaryKey = this.primaryKey
|
||||
this.store.client!.call('getRecordFeatures', { path: { tableName, primaryKey: String(primaryKey) } }).then((result) => {
|
||||
if (this.tableName !== tableName || this.primaryKey !== primaryKey) return
|
||||
const features = result.data as { features?: string[]; capabilities?: Record<string, boolean> }
|
||||
if (!features?.features?.includes('TAGS')) return this.store.flash('Tags are not enabled for this table.', 'error')
|
||||
if (features.capabilities?.tag !== true) return this.store.flash('Your role cannot tag this record.', 'error')
|
||||
this.store.open(new TagOverlay(this.store, collab, tableName, primaryKey, (t) => { this.tags = t; this.build() }))
|
||||
}).catch((error) => this.store.flash(errorText(error), 'error'))
|
||||
}
|
||||
|
||||
/** Counts, follow state and the panel details of the strip and the panel (collaboration extension). */
|
||||
|
||||
@@ -13,7 +13,7 @@ import { TuiClient } from '../src/client'
|
||||
import { RecordCollaboration } from '../src/collab'
|
||||
import type { RecordScreen } from '../src/screens/record'
|
||||
import { readViewSettings } from '@qrun-io/qqq-core'
|
||||
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab } from './fake-collab'
|
||||
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab, recordTagPolicy } from './fake-collab'
|
||||
import type { RecordViewSettingsOverlay } from '../src/overlays/record-view-settings'
|
||||
import { fakeFetch } from './fake-server'
|
||||
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
|
||||
@@ -240,3 +240,18 @@ test('record view without the collaboration extension: no strip, no panel, the c
|
||||
expect(f).not.toContain('Collaboration')
|
||||
expect((h.store.screen as RecordScreen).list.current?.key).toStartWith('f:')
|
||||
})
|
||||
|
||||
for (const enabled of [false, true]) {
|
||||
test(`tag editor respects server capability: enabled=${enabled}, permission denied`, async () => {
|
||||
fakeExtensions.push(COLLABORATION)
|
||||
recordTagPolicy.enabled = enabled
|
||||
recordTagPolicy.allowed = false
|
||||
h = await startTui(160, 40)
|
||||
await command(h, 'table get person 1')
|
||||
await waitFor(h, (x) => x.includes('Tags (t)'))
|
||||
await key('t')
|
||||
const text = enabled ? 'Your role cannot tag this record.' : 'Tags are not enabled for this table.'
|
||||
await waitFor(h, (x) => x.includes(text))
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const json = (data: unknown, status = 200) => new Response(JSON.stringify(data),
|
||||
export const fakeExtensions: J[] = []
|
||||
|
||||
export const COLLABORATION = { name: 'collaboration', version: '1.1.0', pathPrefix: '/qbit/collaboration', description: 'Record collaboration' }
|
||||
export const recordTagPolicy = { enabled: true, allowed: true }
|
||||
export const QUICK_SEARCH = { name: 'quick-search', version: '1.1.0', pathPrefix: '/qbit/quick-search', description: 'Quick search' }
|
||||
|
||||
export interface CollabState {
|
||||
@@ -37,11 +38,14 @@ export function resetCollab(): void {
|
||||
collabState.uploads = []
|
||||
collabState.shares = []
|
||||
collabState.tags = []
|
||||
recordTagPolicy.enabled = true
|
||||
recordTagPolicy.allowed = true
|
||||
}
|
||||
resetCollab()
|
||||
|
||||
export async function collabRoute(path: string, method: string, body: unknown): Promise<Response | undefined> {
|
||||
const on = (name: string) => fakeExtensions.some((e) => e.name === name)
|
||||
if (/^\/table\/person\/\d+\/features$/.test(path)) return json({ features: recordTagPolicy.enabled ? ['TAGS'] : [], capabilities: { tag: recordTagPolicy.allowed } })
|
||||
if (path === '/search' && method === 'POST' && on('quick-search')) {
|
||||
const term = String((body as J | undefined)?.searchTerm ?? '').toLowerCase()
|
||||
const hits = 'ada lovelace'.includes(term) ? [{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: '1', highlights: [{ text: 'Ada Lovelace', fieldLabel: 'Name' }] }] : []
|
||||
|
||||
Reference in new issue
Block a user