From 6470c150fd2413c91fd775b76627db98809fba21 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:07:20 -0700 Subject: [PATCH 01/12] test(setup): drive cloud setup through the public CLI entrypoint --- scripts/proof-cloud-setup-cli.mjs | 226 ++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 scripts/proof-cloud-setup-cli.mjs diff --git a/scripts/proof-cloud-setup-cli.mjs b/scripts/proof-cloud-setup-cli.mjs new file mode 100644 index 00000000..8e5ee160 --- /dev/null +++ b/scripts/proof-cloud-setup-cli.mjs @@ -0,0 +1,226 @@ +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' + +// This drives the executable and encrypted storage, not a constructed application or SDK double. +// The only provider is a loopback HTTP refusal. It does NOT prove a successful cloud task. +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 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 statePath = join(root, 'state.json') +const requested = { + repoUrl: 'https://github.com/tangle-network/braid.git', + gitRef: 'main', + cwd: { base: 'repository', path: 'src' }, +} +const requests = [] +const server = createServer((request, response) => { + requests.push({ method: request.method, path: request.url }) + request.resume() + response.writeHead(503, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'CLOUD_SETUP_LOOPBACK_REFUSAL' } })) +}) +await new Promise((done) => server.listen(0, '127.0.0.1', done)) +const endpoint = `http://127.0.0.1:${server.address().port}` +const credential = randomBytes(24).toString('hex') +let child +let emulator +let screen = '' +let exit +let raw = '' +const frames = [] +const evidence = { + binary, + version: execFileSync(process.execPath, [binary, '--version'], { encoding: 'utf8' }).trim(), + provider: 'loopback HTTP refusal, no live Tangle execution', + publicProbe, +} + +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 }) +} +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) +} +try { + await chmod(root, 0o700) + await mkdir(workspace, { mode: 0o700 }) + await writeFile(keyPath, randomBytes(32), { mode: 0o600 }) + // Initial saved configuration only. After launch, all changes go through the public UI. + 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) + const environment = { + 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, + } + emulator = new xterm.Terminal({ cols: 120, rows: 40, allowProposedApi: true }) + const args = [binary, '--workspace', workspace, '--config', configPath, + '--database-key-file', keyPath, '--no-color', '--record-state', statePath] + evidence.command = [process.execPath, ...args] + child = pty.spawn(process.execPath, args, { + name: 'xterm-256color', cols: 120, rows: 40, cwd: workspace, env: environment, + }) + child.onExit((value) => { exit = value }) + child.onData((chunk) => { + raw += chunk + emulator.write(chunk, () => { + const buffer = emulator.buffer.active + screen = Array.from({ length: 40 }, (_, index) => + buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '').join('\n') + }) + }) + await expect(/new message/u) + capture('configured CLI') + if (publicProbe) { + await key('\u000b') + 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) + } 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') + 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.ok(!savedBytes.includes(credential)) + assert.equal(requests.length, 0) + const afterSave = await snapshot() + assert.equal(afterSave.state.runs.length, 0) + await key('\u001b') + await openSetup() + capture('reopened saved workspace') + 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) + assert.deepEqual(await readFile(configPath), savedBytes) + assert.equal(requests.length, 0) + await key('CLOUD_SETUP_LATER_TASK') + await key('\r') + await until(() => requests.length > 0, 'later explicit task reaches loopback provider') + capture('later task submitted; provider deliberately refuses') + evidence.result = 'save, reopen, cancel, and later HTTP submission verified; provider completion not verified' + evidence.afterSaveRunCount = afterSave.state.runs.length + evidence.savedWorkspaceRequest = saved.workspaceRequest + evidence.savedLifecycle = connection.providerOptions.lifecycle + } + await key('\u0003') + await sleep(100) + await key('\u0003') + await until(() => exit !== undefined, 'CLI exit') + evidence.exit = exit +} finally { + if (child && exit === undefined) child.kill('SIGKILL') + emulator?.dispose() + server.closeAllConnections() + await new Promise((done) => server.close(done)) + await rm(root, { recursive: true, force: true }) + console.log(JSON.stringify({ ...evidence, requests, frames }, null, 2)) +} From fb8009c5a4b654f0ef16d11257629203c7c8f816 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:07:33 -0700 Subject: [PATCH 02/12] ci(setup): compare public and candidate CLI setup paths without live spending --- .github/workflows/cloud-setup-cli.yml | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/cloud-setup-cli.yml diff --git a/.github/workflows/cloud-setup-cli.yml b/.github/workflows/cloud-setup-cli.yml new file mode 100644 index 00000000..0ce03915 --- /dev/null +++ b/.github/workflows/cloud-setup-cli.yml @@ -0,0 +1,43 @@ +name: Cloud setup CLI + +on: + pull_request: + branches: [main] + paths: + - 'src/bin/**' + - 'src/views/tui/**' + - 'scripts/proof-cloud-setup-cli.mjs' + - '.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 + - 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: 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: Identify and install current public CLI without publishing + if: always() + run: | + npm view @tangle-network/braid dist-tags --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 From 8498b9e1941d65558d07e28978275429d0e63702 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:12:48 -0700 Subject: [PATCH 03/12] fix(setup): render acknowledged credential storage separately from pending input --- src/views/tui/setup-stage-rendering.ts | 2 ++ 1 file changed, 2 insertions(+) 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 }), From a3e527c5343f99bac261816991c908f56d08041f Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:13:31 -0700 Subject: [PATCH 04/12] fix(setup): distinguish saved credentials in normal and compact confirmation --- .../tui/configuration-wizard-presentation.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/views/tui/configuration-wizard-presentation.ts b/src/views/tui/configuration-wizard-presentation.ts index 83a52a8c..476bb2df 100644 --- a/src/views/tui/configuration-wizard-presentation.ts +++ b/src/views/tui/configuration-wizard-presentation.ts @@ -145,6 +145,7 @@ export function reviewSummary( state: ConfigurationSessionState, confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, credentialPrepared = false, + credentialCommitted = false, ): readonly string[] { try { const selection = session.previewSelection() @@ -163,7 +164,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 +176,7 @@ export function compactReviewSummary( state: ConfigurationSessionState, confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, credentialPrepared = false, + credentialCommitted = false, ): readonly string[] { try { const selection = session.previewSelection() @@ -184,7 +186,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,21 +202,38 @@ 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 { +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' } -function compactCredentialStatus(selection: ConfigurationSelection, prepared: boolean): string { +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' } From de9179e7ea7747b86b1a3e113bd8fa070f6d0b16 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:14:18 -0700 Subject: [PATCH 05/12] fix(setup): remember credential commit acknowledgement after zeroing input --- src/views/tui/configuration-wizard.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/views/tui/configuration-wizard.ts b/src/views/tui/configuration-wizard.ts index efe2c3c5..6835fc54 100644 --- a/src/views/tui/configuration-wizard.ts +++ b/src/views/tui/configuration-wizard.ts @@ -52,6 +52,7 @@ export class ConfigurationWizard extends Container implements Focusable { #busy = false #commitError: string | undefined readonly #credential = new PreparedCredential() + #credentialCommitted = false constructor(options: ConfigurationWizardOptions) { super() @@ -104,6 +105,7 @@ export class ConfigurationWizard extends Container implements Focusable { rows: this.#rows, ...(this.#onReload === undefined ? {} : { onReload: this.#reload }), credentialPrepared: this.#credential.prepared, + credentialCommitted: this.#credentialCommitted, diagnostics: this.#diagnostics, busy: this.#busy, ...(this.#commitError === undefined ? {} : { commitError: this.#commitError }), @@ -208,14 +210,19 @@ export class ConfigurationWizard extends Container implements Focusable { } this.#busy = true this.#commitError = undefined + this.#credentialCommitted = false + const credentialPrepared = this.#credential.prepared this.#renderStage(this.#session.state) try { await this.#onCommit(selection, this.#credential.value) + // Keep only the acknowledgement, never the bytes, after secure storage succeeds. + this.#credentialCommitted = credentialPrepared this.#clearCredential() this.#busy = false this.#renderStage(this.#session.state) this.#onComplete(selection) } catch (error) { + this.#credentialCommitted = false this.#clearCredential() this.#busy = false this.#commitError = From 54f529588b8f1e073cb675e5969bb33c51b9b3cd Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:16:23 -0700 Subject: [PATCH 06/12] test(setup): assert saved credential message and use disk-reloaded CLI without attachments --- scripts/proof-cloud-setup-cli.mjs | 236 ++++++++++++++++++++---------- 1 file changed, 159 insertions(+), 77 deletions(-) diff --git a/scripts/proof-cloud-setup-cli.mjs b/scripts/proof-cloud-setup-cli.mjs index 8e5ee160..5c793b6a 100644 --- a/scripts/proof-cloud-setup-cli.mjs +++ b/scripts/proof-cloud-setup-cli.mjs @@ -10,49 +10,62 @@ import { defineAgentProfile } from '@tangle-network/agent-interface' import xterm from '@xterm/headless' import * as pty from 'node-pty' -// This drives the executable and encrypted storage, not a constructed application or SDK double. -// The only provider is a loopback HTTP refusal. It does NOT prove a successful cloud task. +// 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 statePath = join(root, 'state.json') 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 }) + 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' } })) }) -await new Promise((done) => server.listen(0, '127.0.0.1', done)) -const endpoint = `http://127.0.0.1:${server.address().port}` -const credential = randomBytes(24).toString('hex') let child let emulator +let statePath let screen = '' let exit let raw = '' const frames = [] const evidence = { binary, - version: execFileSync(process.execPath, [binary, '--version'], { encoding: 'utf8' }).trim(), - provider: 'loopback HTTP refusal, no live Tangle execution', + 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}`) + 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) } @@ -64,7 +77,7 @@ async function key(value) { } function capture(label) { assert.ok(!raw.includes(credential), 'credential appeared in terminal output') - frames.push({ label, screen }) + frames.push({ label, screen: screen.trim() }) } async function snapshot() { const path = `${statePath}.frame` @@ -94,66 +107,129 @@ async function openSetup() { await key('\r') await expect(/workspace · cloud sandbox/u) } -try { - await chmod(root, 0o700) - await mkdir(workspace, { mode: 0o700 }) - await writeFile(keyPath, randomBytes(32), { mode: 0o600 }) - // Initial saved configuration only. After launch, all changes go through the public UI. - 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) - const environment = { - 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, - } - emulator = new xterm.Terminal({ cols: 120, rows: 40, allowProposedApi: true }) - const args = [binary, '--workspace', workspace, '--config', configPath, - '--database-key-file', keyPath, '--no-color', '--record-state', statePath] - evidence.command = [process.execPath, ...args] +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: 120, rows: 40, cwd: workspace, env: environment, + 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.onExit((value) => { exit = value }) child.onData((chunk) => { raw += chunk emulator.write(chunk, () => { const buffer = emulator.buffer.active - screen = Array.from({ length: 40 }, (_, index) => - buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '').join('\n') + screen = Array.from( + { length: rows }, + (_, index) => buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '', + ).join('\n') }) }) await expect(/new message/u) - capture('configured CLI') + 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' + 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) @@ -178,6 +254,8 @@ try { 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) @@ -185,42 +263,46 @@ try { 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 openSetup() - capture('reopened saved workspace') - 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) + await reopenedWorkspace('reopened in same process') assert.deepEqual(await readFile(configPath), savedBytes) assert.equal(requests.length, 0) - await key('CLOUD_SETUP_LATER_TASK') - await key('\r') - await until(() => requests.length > 0, 'later explicit task reaches loopback provider') - capture('later task submitted; provider deliberately refuses') - evidence.result = 'save, reopen, cancel, and later HTTP submission verified; provider completion not verified' + // 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 key('\u0003') - await sleep(100) - await key('\u0003') - await until(() => exit !== undefined, 'CLI exit') + await stop() evidence.exit = exit } finally { if (child && exit === undefined) child.kill('SIGKILL') emulator?.dispose() server.closeAllConnections() - await new Promise((done) => server.close(done)) + 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)) } From 49b10ecdd45ff72f6760e72d59bb4a3651887ec4 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:17:36 -0700 Subject: [PATCH 07/12] test(setup): saved status requires successful commit and never retains credential bytes --- test/configuration-credential-flow.test.ts | 43 +++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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', () => { From c97497f86c0aa38927bd7580cc40b16bc855dfa4 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:18:11 -0700 Subject: [PATCH 08/12] ci(setup): prove exact PR head at two terminal sizes and identify registry build --- .github/workflows/cloud-setup-cli.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/cloud-setup-cli.yml b/.github/workflows/cloud-setup-cli.yml index 0ce03915..c13523ba 100644 --- a/.github/workflows/cloud-setup-cli.yml +++ b/.github/workflows/cloud-setup-cli.yml @@ -7,6 +7,7 @@ on: - 'src/bin/**' - 'src/views/tui/**' - 'scripts/proof-cloud-setup-cli.mjs' + - 'test/configuration-credential-flow.test.ts' - '.github/workflows/cloud-setup-cli.yml' permissions: @@ -22,21 +23,31 @@ jobs: 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() From 65ecb152a7668dc1468f00e00ed0b2b964e7d561 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:21:28 -0700 Subject: [PATCH 09/12] refactor(setup): keep credential acknowledgement and status in the existing helper --- src/views/tui/configuration-credential.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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 From 89624e7621792815d81335aace4a564147c98830 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:22:11 -0700 Subject: [PATCH 10/12] refactor(setup): reuse credential status presenters within the existing module bound --- .../tui/configuration-wizard-presentation.ts | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/views/tui/configuration-wizard-presentation.ts b/src/views/tui/configuration-wizard-presentation.ts index 476bb2df..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, @@ -216,28 +217,6 @@ export function configurationReviewSummaries( } } -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' -} - -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' -} - function effectiveValues( selection: ConfigurationSelection, confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, From 1cac9dc340988afad8b1ce694b78dffd85c75ee9 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:22:56 -0700 Subject: [PATCH 11/12] refactor(setup): let the credential helper own the non-secret save acknowledgement --- src/views/tui/configuration-wizard.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/views/tui/configuration-wizard.ts b/src/views/tui/configuration-wizard.ts index 6835fc54..a39d843f 100644 --- a/src/views/tui/configuration-wizard.ts +++ b/src/views/tui/configuration-wizard.ts @@ -52,7 +52,6 @@ export class ConfigurationWizard extends Container implements Focusable { #busy = false #commitError: string | undefined readonly #credential = new PreparedCredential() - #credentialCommitted = false constructor(options: ConfigurationWizardOptions) { super() @@ -105,7 +104,7 @@ export class ConfigurationWizard extends Container implements Focusable { rows: this.#rows, ...(this.#onReload === undefined ? {} : { onReload: this.#reload }), credentialPrepared: this.#credential.prepared, - credentialCommitted: this.#credentialCommitted, + credentialCommitted: this.#credential.committed, diagnostics: this.#diagnostics, busy: this.#busy, ...(this.#commitError === undefined ? {} : { commitError: this.#commitError }), @@ -210,19 +209,15 @@ export class ConfigurationWizard extends Container implements Focusable { } this.#busy = true this.#commitError = undefined - this.#credentialCommitted = false - const credentialPrepared = this.#credential.prepared this.#renderStage(this.#session.state) try { await this.#onCommit(selection, this.#credential.value) - // Keep only the acknowledgement, never the bytes, after secure storage succeeds. - this.#credentialCommitted = credentialPrepared + this.#credential.markCommitted() this.#clearCredential() this.#busy = false this.#renderStage(this.#session.state) this.#onComplete(selection) } catch (error) { - this.#credentialCommitted = false this.#clearCredential() this.#busy = false this.#commitError = From 835c178fc1832e325574a72b8ced2b5a9d0ac621 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 29 Sep 2026 01:24:30 -0700 Subject: [PATCH 12/12] refactor(setup): inline the single-use view control alias without raising size limits --- src/views/tui/configuration-wizard.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/views/tui/configuration-wizard.ts b/src/views/tui/configuration-wizard.ts index a39d843f..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