diff --git a/.github/workflows/cloud-setup-cli.yml b/.github/workflows/cloud-setup-cli.yml new file mode 100644 index 00000000..c13523ba --- /dev/null +++ b/.github/workflows/cloud-setup-cli.yml @@ -0,0 +1,54 @@ +name: Cloud setup CLI + +on: + pull_request: + branches: [main] + paths: + - 'src/bin/**' + - 'src/views/tui/**' + - 'scripts/proof-cloud-setup-cli.mjs' + - 'test/configuration-credential-flow.test.ts' + - '.github/workflows/cloud-setup-cli.yml' + +permissions: + contents: read + +concurrency: + group: cloud-setup-cli-${{ github.ref }} + cancel-in-progress: true + +jobs: + cli-proof: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.19.0 + cache: pnpm + - name: Record exact source and toolchain + run: | + git rev-parse HEAD + node --version + pnpm --version + - name: Install exact repository dependencies + run: pnpm install --frozen-lockfile + - name: Build current CLI + run: pnpm build + - name: Exercise candidate setup and later submission + run: node scripts/proof-cloud-setup-cli.mjs dist/bin/braid.js + - name: Exercise 80-column setup + run: node scripts/proof-cloud-setup-cli.mjs dist/bin/braid.js --compact + - name: Identify and install current public CLI without publishing + if: always() + run: | + npm view @tangle-network/braid dist-tags --json + npm view @tangle-network/braid@latest version dist.integrity gitHead --json + npm install --prefix "$RUNNER_TEMP/braid-cloud-public" @tangle-network/braid@latest + - name: Probe current public CLI setup re-entry + if: always() + run: node scripts/proof-cloud-setup-cli.mjs "$RUNNER_TEMP/braid-cloud-public/node_modules/@tangle-network/braid/dist/bin/braid.js" --public-probe diff --git a/scripts/proof-cloud-setup-cli.mjs b/scripts/proof-cloud-setup-cli.mjs new file mode 100644 index 00000000..5c793b6a --- /dev/null +++ b/scripts/proof-cloud-setup-cli.mjs @@ -0,0 +1,308 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineAgentProfile } from '@tangle-network/agent-interface' +import xterm from '@xterm/headless' +import * as pty from 'node-pty' + +// Real executable, encrypted storage and HTTP. The endpoint deliberately refuses preflight. +// This proves setup and later submission, NOT successful execution in a live Tangle sandbox. +const repository = fileURLToPath(new URL('../', import.meta.url)) +const binary = resolve(process.argv[2] ?? join(repository, 'dist/bin/braid.js')) +const publicProbe = process.argv.includes('--public-probe') +const compact = process.argv.includes('--compact') +const columns = compact ? 80 : 120 +const rows = compact ? 24 : 40 +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)) +const root = await mkdtemp(join(tmpdir(), 'braid-cloud-cli-')) +const workspace = join(root, 'workspace') +const configPath = join(root, 'config.json') +const keyPath = join(root, 'database.key') +const requested = { + repoUrl: 'https://github.com/tangle-network/braid.git', + gitRef: 'main', + cwd: { base: 'repository', path: 'src' }, +} +const credential = randomBytes(24).toString('hex') +const requests = [] +const server = createServer((request, response) => { + requests.push({ + method: request.method, + path: request.url, + credentialReceived: Object.values(request.headers).some( + (value) => typeof value === 'string' && value.includes(credential), + ), + }) + request.resume() + response.writeHead(503, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'CLOUD_SETUP_LOOPBACK_REFUSAL' } })) +}) +let child +let emulator +let statePath +let screen = '' +let exit +let raw = '' +const frames = [] +const evidence = { + binary, + provider: 'loopback HTTP preflight refusal; no live Tangle execution', + publicProbe, + columns, + rows, + commands: [], +} + +async function until(predicate, label, timeout = 20_000) { + const deadline = Date.now() + timeout + while (!(await predicate())) { + assert.equal( + exit, + undefined, + `CLI exited while waiting for ${label}: ${JSON.stringify(exit)}\n${screen}`, + ) + if (Date.now() > deadline) throw new Error(`Timed out: ${label}\n${screen}`) + await sleep(25) + } +} +const expect = (pattern) => until(() => pattern.test(screen), String(pattern)) +async function key(value) { + child.write(value) + await sleep(100) +} +function capture(label) { + assert.ok(!raw.includes(credential), 'credential appeared in terminal output') + frames.push({ label, screen: screen.trim() }) +} +async function snapshot() { + const path = `${statePath}.frame` + await rm(path, { force: true }) + process.kill(child.pid, 'SIGUSR2') + let value + await until(async () => { + try { + value = JSON.parse(await readFile(path, 'utf8')) + return true + } catch (error) { + if (error.code === 'ENOENT' || error instanceof SyntaxError) return false + throw error + } + }, 'SIGUSR2 state snapshot') + return value +} +async function openSetup() { + await key('\u000b') + await expect(/Setup/u) + capture('setup menu') + await key('\u001b[B') + await key('\r') + await expect(/choose an AgentProfile/u) + await key('\r') + await expect(/choose a connection/u) + await key('\r') + await expect(/workspace · cloud sandbox/u) +} +async function start(endpoint, phase) { + exit = undefined + screen = '' + statePath = join(root, `${phase}.json`) + emulator = new xterm.Terminal({ cols: columns, rows, allowProposedApi: true }) + const args = [ + binary, + '--workspace', + workspace, + '--config', + configPath, + '--database-key-file', + keyPath, + '--no-color', + '--record-state', + statePath, + ] + evidence.commands.push([process.execPath, ...args]) + child = pty.spawn(process.execPath, args, { + name: 'xterm-256color', + cols: columns, + rows, + cwd: workspace, + env: { + PATH: process.env.PATH, + HOME: root, + TERM: 'xterm-256color', + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_STATE_PATH: join(root, 'state.sqlite'), + BRAID_CLI_BRIDGE_ENDPOINT: endpoint, + }, + }) + child.onExit((value) => { + exit = value + }) + child.onData((chunk) => { + raw += chunk + emulator.write(chunk, () => { + const buffer = emulator.buffer.active + screen = Array.from( + { length: rows }, + (_, index) => buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '', + ).join('\n') + }) + }) + await expect(/new message/u) + capture(phase) +} +async function stop() { + await expect(/new message/u) + await key('\u0015') + await key('/quit\r') + await until(() => exit !== undefined, 'CLI /quit') + assert.equal(exit.exitCode, 0) + emulator.dispose() + emulator = undefined +} +async function reopenedWorkspace(label) { + await openSetup() + capture(label) + assert.match(screen, /main/u) + assert.match(screen, /src/u) + await key('\u000c') + await expect(/files · lifetime/u) + assert.match(screen, /1800/u) + await key('\u001b') + await key('\u001b') + await expect(/new message/u) +} +try { + evidence.version = execFileSync(process.execPath, [binary, '--version'], { + encoding: 'utf8', + timeout: 10_000, + }).trim() + await new Promise((done) => server.listen(0, '127.0.0.1', done)) + const endpoint = `http://127.0.0.1:${server.address().port}` + await chmod(root, 0o700) + await mkdir(workspace, { mode: 0o700 }) + await writeFile(keyPath, randomBytes(32), { mode: 0o600 }) + // Seed only the pre-existing configuration. No file edits after starting the CLI. + await writeFile( + configPath, + `${JSON.stringify({ + format: 'braid-startup-config', + schemaVersion: 2, + profile: defineAgentProfile({ + name: 'Cloud CLI proof', + harness: 'opencode', + model: { provider: 'tangle-router', default: 'tangle-router/glm-5.3' }, + }), + connectionId: 'connection-tangle-sandbox', + connections: [ + { + id: 'connection-tangle-sandbox', + kind: 'tangle-sandbox', + name: 'Tangle Sandbox (loopback refusal)', + endpoint, + providerOptions: { + transport: 'local', + capabilityHints: ['stream', 'placement', 'usage'], + }, + createdAt: '2026-09-29T00:00:00.000Z', + updatedAt: '2026-09-29T00:00:00.000Z', + lastHealth: { status: 'unknown' }, + }, + ], + databaseKeyFile: keyPath, + })}\n`, + { mode: 0o600 }, + ) + const initialBytes = await readFile(configPath) + await start(endpoint, 'configured-cli') + if (publicProbe) { + await key('\u000b') + await expect(/Run configuration/u) + capture('public CLI Ctrl+K') + evidence.result = /Setup/u.test(screen) + ? 'setup menu present; full proof required' + : 'setup menu absent' + assert.deepEqual(await readFile(configPath), initialBytes) + assert.equal(requests.length, 0) + await key('\u001b') + } else { + await openSetup() + assert.match(screen, /files: ephemeral/u) + await key(requested.repoUrl) + await key('\r') + await key(requested.gitRef) + await key('\r') + await key(requested.cwd.path) + await key('\u000c') + await expect(/files · lifetime/u) + await key('\u001b[B') + await key('\r') + await key('\u0005\u00151800') + capture('retained lifetime') + await key('\r') + await expect(/credential|API key|api key/u) + await key(credential) + await key('\r') + await expect(/review and/u) + capture('review without execution') + assert.equal(requests.length, 0) + await key('\r') + await expect(/selection applied/u) + capture('saved without execution') + assert.match(screen, /credentials saved securely|cred saved/u) + assert.doesNotMatch(screen, /credentials not configured|cred not set/u) + const savedBytes = await readFile(configPath) + const saved = JSON.parse(savedBytes.toString('utf8')) + assert.deepEqual(saved.workspaceRequest, requested) + const connection = saved.connections.find((entry) => entry.id === saved.connectionId) + assert.equal(connection.endpoint, endpoint) + assert.equal(connection.providerOptions.lifecycle, 'retained') + assert.equal(connection.providerOptions.idleTtlSeconds, 1800) + assert.equal(typeof connection.credentialRef, 'string') + assert.ok(!savedBytes.includes(credential)) + assert.equal(requests.length, 0) + const afterSave = await snapshot() + assert.equal(afterSave.state.runs.length, 0) + await key('\u001b') + await reopenedWorkspace('reopened in same process') + assert.deepEqual(await readFile(configPath), savedBytes) + assert.equal(requests.length, 0) + // A later user submission must reach the existing authenticated provider preflight. + await key('CLOUD_SETUP_LATER_TASK\r') + await expect(/SERVER_ERROR/u) + assert.ok(requests.length > 0) + assert.ok(requests.some((request) => request.credentialReceived)) + capture('later explicit submission; unchanged provider refusal') + await key('\u001b') + await stop() + const beforeReloadRequests = requests.length + await start(endpoint, 'disk-reloaded-cli') + await reopenedWorkspace('reopened after encrypted disk reload') + assert.deepEqual(await readFile(configPath), savedBytes) + assert.equal(requests.length, beforeReloadRequests) + await key('CLOUD_SETUP_RELOADED_TASK\r') + await expect(/SERVER_ERROR/u) + assert.ok(requests.slice(beforeReloadRequests).some((request) => request.credentialReceived)) + capture('reloaded credential used by later submission') + await key('\u001b') + evidence.afterSaveRunCount = afterSave.state.runs.length + evidence.savedWorkspaceRequest = saved.workspaceRequest + evidence.savedLifecycle = connection.providerOptions.lifecycle + evidence.result = + 'save, same-process reopen, cancel, disk reload and authenticated preflight verified; no cloud task completed' + } + await stop() + evidence.exit = exit +} finally { + if (child && exit === undefined) child.kill('SIGKILL') + emulator?.dispose() + server.closeAllConnections() + if (server.listening) await new Promise((done) => server.close(done)) + await rm(root, { recursive: true, force: true }) + console.log(JSON.stringify({ ...evidence, requests, frames }, null, 2)) +} diff --git a/src/views/tui/configuration-credential.ts b/src/views/tui/configuration-credential.ts index 2f20e7a7..3258c751 100644 --- a/src/views/tui/configuration-credential.ts +++ b/src/views/tui/configuration-credential.ts @@ -22,6 +22,7 @@ export type ConfigurationCommit = ( export class PreparedCredential { #value: OwnedSecretBytes | undefined + #committed = false get value(): OwnedSecretBytes | undefined { return this.#value @@ -31,8 +32,18 @@ export class PreparedCredential { return this.#value !== undefined } + get committed(): boolean { + return this.#committed + } + + markCommitted(): void { + // Keep only the acknowledgement after the caller clears the secret bytes. + this.#committed = this.prepared + } + replace(value: OwnedSecretBytes): void { this.clear() + this.#committed = false this.#value = value } @@ -42,6 +53,28 @@ export class PreparedCredential { } } +export function credentialStatus( + selection: ConfigurationSelection, + prepared: boolean, + committed: boolean, +): string { + if (committed) return 'saved securely · value hidden' + if (selection.connection.credentialRef !== undefined) { + return 'configured outside Braid · value hidden' + } + return prepared ? 'ready for secure storage · value hidden' : 'not configured' +} + +export function compactCredentialStatus( + selection: ConfigurationSelection, + prepared: boolean, + committed: boolean, +): string { + if (committed) return 'saved · hidden' + if (selection.connection.credentialRef !== undefined) return 'hidden' + return prepared ? 'ready · hidden' : 'not set' +} + interface MountedCredentialOptions extends ConfigurationCredentialOptions { readonly container: Container readonly focused: boolean diff --git a/src/views/tui/configuration-wizard-presentation.ts b/src/views/tui/configuration-wizard-presentation.ts index 83a52a8c..9dc918dc 100644 --- a/src/views/tui/configuration-wizard-presentation.ts +++ b/src/views/tui/configuration-wizard-presentation.ts @@ -6,6 +6,7 @@ import type { ConfigurationSessionState, } from '../../app/configuration-session.js' import { sanitizeTerminalText } from '../shared/sanitize.js' +import { compactCredentialStatus, credentialStatus } from './configuration-credential.js' import { shortDigest } from './configuration-presenters.js' import { compactWorkspaceRequestSummary, @@ -145,6 +146,7 @@ export function reviewSummary( state: ConfigurationSessionState, confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, credentialPrepared = false, + credentialCommitted = false, ): readonly string[] { try { const selection = session.previewSelection() @@ -163,7 +165,7 @@ export function reviewSummary( ? effective.unsupported.map(sanitizeTerminalText).join(', ') : 'none' }`, - `credentials ${credentialStatus(selection, credentialPrepared)}`, + `credentials ${credentialStatus(selection, credentialPrepared, credentialCommitted)}`, ] } catch { return ['Effective values are unavailable until both choices are selected.'] @@ -175,6 +177,7 @@ export function compactReviewSummary( state: ConfigurationSessionState, confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, credentialPrepared = false, + credentialCommitted = false, ): readonly string[] { try { const selection = session.previewSelection() @@ -184,7 +187,7 @@ export function compactReviewSummary( const unsupported = effective.unsupported.length > 0 ? effective.unsupported.join(', ') : 'none' return [ `profile ${profile?.label ?? selection.profile.displayName} → ${connection?.label ?? selection.connection.name}`, - `cred ${compactCredentialStatus(selection, credentialPrepared)} · conn ${selection.connection.kind} · digest ${compactDigest(selection.profileDigest)}`, + `cred ${compactCredentialStatus(selection, credentialPrepared, credentialCommitted)} · conn ${selection.connection.kind} · digest ${compactDigest(selection.profileDigest)}`, `runner: ${shortValue(effective.runner, 14)} · model: ${shortValue(effective.model, 16)}`, `effort: ${shortValue(effective.effort, 12)} · start in: ${shortValue(effective.workdir, 18)}`, ...compactWorkspaceRequestSummary(effective.workspaceRequest), @@ -200,25 +203,20 @@ export function configurationReviewSummaries( state: ConfigurationSessionState, confirmation: ((selection: ConfigurationSelection) => ConfigurationEffectiveValues) | undefined, credentialPrepared: boolean, + credentialCommitted = false, ): { readonly summary: readonly string[]; readonly compactSummary: readonly string[] } { return { - summary: reviewSummary(session, state, confirmation, credentialPrepared), - compactSummary: compactReviewSummary(session, state, confirmation, credentialPrepared), + summary: reviewSummary(session, state, confirmation, credentialPrepared, credentialCommitted), + compactSummary: compactReviewSummary( + session, + state, + confirmation, + credentialPrepared, + credentialCommitted, + ), } } -function credentialStatus(selection: ConfigurationSelection, prepared: boolean): string { - if (selection.connection.credentialRef !== undefined) { - return 'configured outside Braid · value hidden' - } - return prepared ? 'ready for secure storage · value hidden' : 'not configured' -} - -function compactCredentialStatus(selection: ConfigurationSelection, prepared: boolean): string { - if (selection.connection.credentialRef !== undefined) return 'hidden' - return prepared ? 'ready · hidden' : 'not set' -} - function effectiveValues( selection: ConfigurationSelection, confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, diff --git a/src/views/tui/configuration-wizard.ts b/src/views/tui/configuration-wizard.ts index efe2c3c5..cfd124d7 100644 --- a/src/views/tui/configuration-wizard.ts +++ b/src/views/tui/configuration-wizard.ts @@ -27,11 +27,6 @@ import { import type { WorkspaceRequestForm } from './workspace-request-form.js' import { mountWorkspaceRequestForm } from './workspace-request-workflow.js' -type ConfigurationControl = - | ConfigurationStageControl - | ConfigurationCredential - | WorkspaceRequestForm - /** Keyboard-first profile, destination, credential, and review flow. */ export class ConfigurationWizard extends Container implements Focusable { readonly #theme: ConfigurationWizardOptions['theme'] @@ -47,7 +42,7 @@ export class ConfigurationWizard extends Container implements Focusable { readonly #rows: () => number #reloading = false #closed = false - #selector!: ConfigurationControl + #selector!: ConfigurationStageControl | ConfigurationCredential | WorkspaceRequestForm #focused = false #busy = false #commitError: string | undefined @@ -104,6 +99,7 @@ export class ConfigurationWizard extends Container implements Focusable { rows: this.#rows, ...(this.#onReload === undefined ? {} : { onReload: this.#reload }), credentialPrepared: this.#credential.prepared, + credentialCommitted: this.#credential.committed, diagnostics: this.#diagnostics, busy: this.#busy, ...(this.#commitError === undefined ? {} : { commitError: this.#commitError }), @@ -211,6 +207,7 @@ export class ConfigurationWizard extends Container implements Focusable { this.#renderStage(this.#session.state) try { await this.#onCommit(selection, this.#credential.value) + this.#credential.markCommitted() this.#clearCredential() this.#busy = false this.#renderStage(this.#session.state) diff --git a/src/views/tui/setup-stage-rendering.ts b/src/views/tui/setup-stage-rendering.ts index b8999de6..e1b9168e 100644 --- a/src/views/tui/setup-stage-rendering.ts +++ b/src/views/tui/setup-stage-rendering.ts @@ -32,6 +32,7 @@ export interface ConfigurationStageOptions { readonly theme: BraidTheme readonly confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues readonly credentialPrepared: boolean + readonly credentialCommitted?: boolean readonly diagnostics: readonly string[] readonly busy: boolean readonly commitError?: string @@ -86,6 +87,7 @@ export function renderConfigurationStage( state, options.confirmation, options.credentialPrepared, + applied && options.credentialCommitted === true, ), title: applied ? 'selection applied' : configurationTitle(state, busy, commitError), ...(commitError === undefined ? {} : { error: commitError }), diff --git a/test/configuration-credential-flow.test.ts b/test/configuration-credential-flow.test.ts index 6a023ee4..46e39838 100644 --- a/test/configuration-credential-flow.test.ts +++ b/test/configuration-credential-flow.test.ts @@ -90,7 +90,48 @@ test('first-run Tangle selection masks, transfers, and clears credential bytes', callbackBuffer?.every((byte) => byte === 0), true, ) - assert.doesNotMatch(wizard.render(80).join('\n'), /terminal-secret-canary/u) + for (const width of [40, 80]) { + const applied = wizard.render(width).join('\n') + assert.match(applied, /credentials saved securely|cred saved/u) + assert.doesNotMatch(applied, /not configured|not set|ready for secure storage/u) + assert.doesNotMatch(applied, /terminal-secret-canary/u) + } +}) + +test('pending and rejected credential commits do not report saved credentials', async () => { + let callbackBuffer: Uint8Array | undefined + let rejectCommit: ((error: Error) => void) | undefined + const wizard = new ConfigurationWizard({ + theme, + profiles: [profile], + connections: [connection('tangle-inference')], + requiresCredential: () => true, + onCommit: (_selection, credential) => { + callbackBuffer = credential + return new Promise((_resolve, reject) => { + rejectCommit = reject + }) + }, + onComplete: () => assert.fail('A rejected save cannot complete setup'), + onCancel: () => {}, + }) + wizard.focused = true + wizard.handleInput('\r') + wizard.handleInput('\r') + wizard.handleInput('rejected-secret-canary') + wizard.handleInput('\r') + wizard.handleInput('\r') + assert.doesNotMatch(wizard.render(80).join('\n'), /credentials saved|cred saved/u) + assert.ok(rejectCommit) + rejectCommit(new Error('Storage failed')) + await new Promise((resolve) => setImmediate(resolve)) + assert.ok(callbackBuffer) + assert.ok(callbackBuffer.every((byte) => byte === 0)) + for (const width of [40, 80]) { + const failed = wizard.render(width).join('\n') + assert.match(failed, /Storage failed/u) + assert.doesNotMatch(failed, /credentials saved|cred saved|rejected-secret-canary/u) + } }) test('Escape from credential input returns to connection choice without committing', () => {