diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9e8ad9..bbe0b30 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,9 @@ jobs: bash tests/user-env.test.sh bash tests/run-t3.test.sh bash tests/persist-credentials.test.sh - for f in docker/setup/app.js docker/setup/client/*.js docker/setup/design/ui.js docker/router/*.mjs docker/runtime/*.mjs; do + sh -n docker/setup/credentials/askpass.sh + shellcheck -s sh -S warning docker/setup/credentials/askpass.sh + for f in docker/setup/app.js docker/setup/client/*.js docker/setup/credentials.mjs docker/setup/credentials/*.mjs docker/setup/design/ui.js docker/router/*.mjs docker/runtime/*.mjs; do node --check "$f" done node --test tests/*.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd2d8a3..6c01a10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -155,6 +155,27 @@ and Chromium; set `CHROME_PATH` if needed. Screenshots go to `/tmp/t3-opencode-auth-ui` (`T3_AUTH_UI_OUTPUT` overrides it). These audits never read or change your real OpenCode credentials. +For Credentials changes, run `node --test tests/credential*.test.mjs` +and `node scripts/credentials-ui-audit.mjs`. The browser audit starts a real +Setup API in a disposable home and a loopback API server. It tests drafts, +secret replacement, concurrent edits, host-key review, stale addresses, failed +reads, repeated saves, deletion focus, deep links and long metadata in both +themes, standalone, legacy embed and Settings widths. SSH scans and host trust +are intercepted; it never contacts an external service or changes a production +credential. Screenshots go to `/tmp/t3-credentials-ui` +(`T3_CREDENTIALS_UI_OUTPUT` overrides it). `T3_CREDENTIALS_UI_WIDTHS=390,640,1280` +narrows the matrix; `T3_CREDENTIALS_UI_THEMES=light` narrows the themes. The image +smoke suite runs both themes at those three widths on both browser architectures, +plus installed-helper and loopback API checks on each image variant. + +For handoff comparisons, start `console-preview.mjs`, then run +`node scripts/credentials-visual-audit.mjs http://127.0.0.1:23785/__setup/`. +It captures the list, empty state, SSH/API details, editor, conflict, host-key +review and deletion, including phone states in both themes. It uses public +synthetic fixture records and keeps newly entered secrets masked. Outputs go to +`/tmp/t3-credentials-visual` (`T3_CREDENTIALS_VISUAL_OUTPUT` overrides it). Compare +these at the supplied handoff’s viewport; include real screenshots in the PR. + T3 functionality specific to this container and Setup belongs in the local source patch set under `docker/runtime/patches`. Maintain the patched distribution here, including Stable/Nightly update support. See [Maintaining native T3 diff --git a/Dockerfile b/Dockerfile index ed49ee8..a421c67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -287,6 +287,7 @@ RUN "$T3_INFRA_NODE" /usr/local/share/t3-client/patch.mjs # shell and anyone typing it. Nothing in the image resolves it by name: the # entrypoint, the setup service and the helpers all use T3_INFRA_LAUNCHER. RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/lib/t3code/run-t3.sh /usr/local/bin/t3-* \ + /opt/t3-setup/credentials/askpass.sh \ && ln -sfn t3-admin /usr/local/bin/t3 # The agent names, for `docker exec claude` and friends: links to diff --git a/README.md b/README.md index a548cf9..4e0eefa 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ instead. Details: [Reaching the server from a phone](docs/remote-access.md). | [Upgrading T3 Code](docs/t3code-updates.md) | Stable and Nightly, auto-update, rollback | | [Toolchains](docs/toolchains.md) | Go, Rust and friends, any mise tool, per-project versions | | [Source control](docs/source-control.md) | gh, glab, fj, tea and az sign-in | +| [Credentials](docs/credentials.md) | Saved SSH/API logins, explicit host trust, connection tests and skill usage | | [Publishing a port](docs/ports.md) | Public URLs for dev servers | | [Giving agents a browser](docs/agent-browser.md) | T3's server browser and the browser MCP servers | | [Context management](docs/context-management.md) | Global `AGENTS.md` and the shared skill library | diff --git a/docker/bin/t3-api b/docker/bin/t3-api new file mode 100755 index 0000000..2f2deed --- /dev/null +++ b/docker/bin/t3-api @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +inherited_path="$PATH" +[ "$EUID" -ne 0 ] || PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +if [ "$EUID" -eq 0 ] && command -v gosu >/dev/null 2>&1 && id t3 >/dev/null 2>&1; then + exec env PATH="$inherited_path" "$(command -v gosu)" t3 "$0" "$@" +fi +# shellcheck source=/dev/null +[ ! -r /etc/profile.d/t3-user-env.sh ] || . /etc/profile.d/t3-user-env.sh +exec "${T3_INFRA_NODE:-/usr/local/bin/node}" "${T3_CREDENTIAL_CLI:-/opt/t3-setup/credentials/cli.mjs}" api "$@" diff --git a/docker/bin/t3-ssh b/docker/bin/t3-ssh new file mode 100755 index 0000000..296ca7c --- /dev/null +++ b/docker/bin/t3-ssh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +inherited_path="$PATH" +[ "$EUID" -ne 0 ] || PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +if [ "$EUID" -eq 0 ] && command -v gosu >/dev/null 2>&1 && id t3 >/dev/null 2>&1; then + exec env PATH="$inherited_path" "$(command -v gosu)" t3 "$0" "$@" +fi +# shellcheck source=/dev/null +[ ! -r /etc/profile.d/t3-user-env.sh ] || . /etc/profile.d/t3-user-env.sh +exec "${T3_INFRA_NODE:-/usr/local/bin/node}" "${T3_CREDENTIAL_CLI:-/opt/t3-setup/credentials/cli.mjs}" ssh "$@" diff --git a/docker/setup/app.js b/docker/setup/app.js index 0327ffb..6d1f5d3 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -154,6 +154,7 @@ const skillsUI = T3SkillsUI.create({ api, getStatus: () => state.status, render: () => render(), readMarkdown: (file) => readMarkdownFile(file, 64 * 1024), downloadMarkdown: (...args) => downloadMarkdown(...args) }); const mcpUI = T3McpUI.create({ api, getStatus: () => state.status, render: () => render() }); + const credentialsUI = T3CredentialsUI.create({ api, getStatus: () => state.status, render: () => render(), onChange: () => loadStatus(true) }); const filesUI = T3FilesUI.create({ api, base: BASE, getStatus: () => state.status, getRoute: () => state.route, render: () => render(), go: route => go(route) }); /** Whether this browser came in on its T3 Code session rather than the key, so Lock would end nothing. */ @@ -214,8 +215,9 @@ } }; - const loadStatus = () => { - if (statusLoading) return statusLoading; + const loadStatus = (fresh = false) => { + // A completed mutation must read after an older poll, not join its snapshot. + if (statusLoading) return fresh === true ? statusLoading.then(() => loadStatus()) : statusLoading; statusLoading = (async () => { const accessRevision = access.revision; const res = await api('/status'); @@ -232,6 +234,7 @@ checkHere(); if (state.route === 'instructions' && state.contextTab === 'skills') skillsUI.activate(); if (state.route === 'mcp') mcpUI.activate(); + if (state.route === 'credentials') credentialsUI.activate(); if (state.route === 'files') filesUI.activate(); if (state.route === 'instructions' && state.contextTab === 'instructions') void loadInstructionsPreview(); } else if (!locked) { @@ -866,8 +869,8 @@ `; /** The page a "Needs you" item lives on, and that page's link when they all share one. */ - const NEEDS_PAGES = { agents: 'All agents', toolchains: 'All toolchains', sourcecontrol: 'Source control', ports: 'All ports', server: 'Server', storage: 'Storage', updates: 'Updates' }; - const needsPage = (x) => x.target === 'port' ? 'ports' : x.target === 'harness' ? 'agents' + const NEEDS_PAGES = { agents: 'All agents', toolchains: 'All toolchains', sourcecontrol: 'Source control', ports: 'All ports', server: 'Server', storage: 'Storage', updates: 'Updates', credentials: 'Credentials' }; + const needsPage = (x) => x.target === 'credential' ? 'credentials' : x.target === 'port' ? 'ports' : x.target === 'harness' ? 'agents' : x.target === 'server' ? (x.action && x.action.route) || 'server' : x.target === 'toolchain' && M.SOURCE_CONTROL[x.id] ? 'sourcecontrol' : 'toolchains'; @@ -1876,6 +1879,7 @@ files: () => html`${filesUI.phoneHead()}${pageHead('files')}${filesUI.content()}`, overview: overviewPage, access: accessPage, agents: agentsPage, instructions: instructionsPage, mcp: () => html`${mcpUI.phoneHead()}${pageHead('mcp', { sub: 'Servers your agents can start or connect to. Define one once, then add it to the agents you choose.' })}${mcpUI.content()}`, + credentials: () => html`${pageHead('credentials')}${credentialsUI.content()}`, toolchains: toolchainsPage, sourcecontrol: sourceControlPage, ports: portsPage, updates: updatesPage, storage: storagePage, server: serverPage, more: morePage, }; @@ -1997,6 +2001,7 @@ if (contextEditor.layer && !await contextEditor.requestClose()) return false; if (!await skillsUI.beforeNavigate()) return false; if (!await mcpUI.beforeNavigate()) return false; + if (!await credentialsUI.beforeNavigate()) return false; return filesUI.beforeNavigate(); }; let hostLeaving = false; @@ -2028,6 +2033,7 @@ if (route === 'instructions' && state.contextTab === 'skills' && state.status) skillsUI.activate(true); if (route === 'instructions' && state.contextTab === 'instructions' && state.status) void loadInstructionsPreview(true); if (route === 'mcp' && state.status) mcpUI.activate(true); + if (route === 'credentials' && state.status) credentialsUI.activate(true); if (route === 'files' && state.status) filesUI.activate(); }; const navigateFromHash = async focus => { @@ -4085,7 +4091,7 @@ // -------------------------------------------------------------- palette -- const openPalette = () => { const layer = Kit.palette({ - items: () => [...M.paletteItems(state.status || {}, state.ports, ui, now()), ...skillsUI.paletteItems().map(item => ({ ...item, group: 'Instructions & skills', keywords: item.desc || '' })), ...filesUI.paletteItems()], + items: () => [...M.paletteItems(state.status || {}, state.ports, ui, now()), ...skillsUI.paletteItems().map(item => ({ ...item, group: 'Instructions & skills', keywords: item.desc || '' })), ...filesUI.paletteItems(), ...credentialsUI.paletteItems()], search: M.searchPalette, run: (item) => { const { cmd, ...args } = item.cmd; @@ -4100,7 +4106,7 @@ // ------------------------------------------------------------ shortcuts -- Kit.bind('mod+k', openPalette); - Kit.bind('/', () => state.route === 'files' ? filesUI.focusSearch() : state.route === 'mcp' ? mcpUI.focusSearch() : state.route === 'instructions' && state.contextTab === 'skills' ? skillsUI.focusSearch() : openPalette()); + Kit.bind('/', () => state.route === 'files' ? filesUI.focusSearch() : state.route === 'mcp' ? mcpUI.focusSearch() : state.route === 'credentials' ? credentialsUI.focusSearch() : state.route === 'instructions' && state.contextTab === 'skills' ? skillsUI.focusSearch() : openPalette()); for (const page of T3Routes.PAGES) { if (page.key) Kit.bind('g ' + page.key, () => { keyboardNav = true; go(page.route); }); } diff --git a/docker/setup/client/credential-dialogs.js b/docker/setup/client/credential-dialogs.js new file mode 100644 index 0000000..c0f091f --- /dev/null +++ b/docker/setup/client/credential-dialogs.js @@ -0,0 +1,78 @@ +const T3CredentialDialogs = (() => { + 'use strict'; + const { html, raw, icon } = Kit, S = T3CredentialModel, M = T3Model; + const disabled = value => value ? raw(' disabled') : ''; + const create = ctx => { + let active = null; + const removing = new Set(); + const remove = async id => { + const profile = ctx.row(id); if (!profile || ctx.busy.has(id) || removing.has(id)) return; + removing.add(id); + try { + const yes = await Kit.confirm({ title: 'Delete ' + id + '?', body: html`Skills that call ${(profile.type === 'ssh' ? 't3-ssh ' : 't3-api ') + id} fail until a credential with this name exists again.`, + consequences: [ + { icon: 'book-open', text: profile.usedIn?.length ? 'Mentioned in ' + M.listOf(profile.usedIn.map(item => item.name)) : ctx.usageWarnings()?.length ? 'Some skills couldn’t be read; there may be mentions.' : 'Not used in a skill' }, + ...(ctx.usageWarnings()?.length && profile.usedIn?.length ? [{ icon: 'info', text: 'Some skills couldn’t be read; there may be other mentions.' }] : []), + { icon: 'key-round', text: 'The saved ' + S.secretKind(profile) + ' is erased from this server' }, + { icon: 'archive', text: 'Backups of this volume still hold it' }, + ...(profile.type === 'ssh' ? [{ icon: 'shield-alert', text: 'Its trusted host key is forgotten' }] : []), + ], confirm: 'Delete credential', tone: 'danger' }); + if (!yes) return; + const response = await ctx.request(id, 'remove', '/credentials/remove', { id, revision: profile.revision }); + if (response?.ok) { ctx.deleted(id); Kit.toast(id + ' deleted'); } + else if (response) Kit.toast('Couldn’t delete ' + id, { tone: 'danger', detail: response.error }); + } finally { removing.delete(id); } + }; + const forget = async id => { + if (ctx.busy.has(id)) return; + const yes = await Kit.confirm({ title: 'Forget ' + id + '’s host key?', body: 'Agents will be blocked from connecting until you review and trust the server’s key again.', confirm: 'Forget host key', tone: 'danger' }); + if (!yes) return; + const response = await ctx.request(id, 'forget', '/credentials/forget-host-key', { id }); + if (response?.ok) Kit.toast('Forgot ' + id + '’s host key'); + }; + const review = async id => { + let profile = ctx.row(id); if (!profile || ctx.busy.has(id) || active) return; + const view = { id, busy: false, scanned: null, error: null, changedAgain: false, stale: false }; active = view; + const close = reason => { if (view.busy) { Kit.announce('Wait for the host-key check to finish.'); return false; } view.layer.close(reason); return true; }; + const changed = () => view.scanned?.state === 'changed'; + const paint = () => { if (active === view) view.layer.render(); }; + const scan = async () => { + if (view.busy) return; + const current = ctx.row(id); if (current) profile = current; + view.scanned = null; view.stale = false; view.changedAgain = false; view.busy = true; view.error = null; paint(); + const response = await ctx.request(id, 'scan', '/credentials/scan', { id, revision: profile.revision }, { refresh: false }); + if (active !== view) return; + view.busy = false; + if (['changed', 'gone', 'not-found'].includes(response?.data?.code)) { view.stale = true; view.error = 'The saved connection details changed. Review the current host before trusting a key.'; } + else if (!response?.ok || !response.data.hostKey) view.error = response?.data?.result ? S.state({ ...profile, hostKey: profile.hostKey || {}, test: response.data.result }).desc : response?.error || 'Couldn’t read this server’s host key. Try again.'; + else view.scanned = response.data.hostKey; + paint(); view.layer.panel.querySelector('[data-cred-key="cancel"]')?.focus({ preventScroll: true }); + }; + view.layer = Kit.open({ kind: 'center', backdrop: 'ignore', panel: { class: 'tc-dialog tc-cred-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'cred-key-title', 'aria-describedby': 'cred-key-desc' }, focus: '[data-cred-key="cancel"]', onEscape: () => close('escape'), onClose: () => { if (active === view) active = null; }, render: () => html` +

${changed() ? id + '’s host key changed' : 'Trust ' + profile.host + '’s host key?'}

${changed() ? profile.host + ' now presents a different key. That’s expected after a reinstall, and also what an interception looks like. Agents can’t connect until you decide.' : 'Compare this fingerprint with the server’s own key before trusting it. No password is sent while the key is being checked.'}

+ ${view.busy ? html`

${view.scanned ? 'Checking the key again before trusting it…' : 'Reading the server’s host key…'}

` : ''} + ${view.scanned ? html`

On the server, this prints its real key:

` : ''} + ${view.error ? html`` : ''}${view.changedAgain ? html`` : ''}
+
${view.error && (!view.scanned || view.stale) ? html`` : html``}
` }); + view.layer.panel.addEventListener('click', async event => { + const button = event.target.closest('[data-cred-key]'); if (!button || button.disabled) return; + const action = button.dataset.credKey; + if (action === 'cancel') return close('cancel'); + if (action === 'copy') return void Kit.copy(S.keyCommand(view.scanned.presented.type), button); + if (action === 'retry') { await ctx.refresh(); return void scan(); } + if (action !== 'trust' || view.busy || !view.scanned) return; + view.busy = true; view.error = null; view.changedAgain = false; paint(); + const response = await ctx.request(id, 'trust', '/credentials/trust', { id, revision: profile.revision, fingerprint: view.scanned.presented.fingerprint }); + if (active !== view) return; view.busy = false; + if (response?.ok) { view.layer.close('trusted'); Kit.toast(id + '’s host key trusted'); return; } + if (['changed', 'gone', 'not-found'].includes(response?.data?.code)) { view.stale = true; view.error = 'The saved connection details changed. Review the current host before trusting a key.'; } + else if (response?.data?.code === 'key-changed' && response.data.presented) { view.scanned = { ...view.scanned, state: 'changed', presented: response.data.presented }; view.changedAgain = true; } + else view.error = response?.error || 'The host key could not be trusted. Try again.'; + paint(); (view.layer.panel.querySelector('#cred-key-error') || view.layer.panel.querySelector('[data-cred-key="cancel"]'))?.focus({ preventScroll: true }); + }); + await scan(); + }; + return { remove, forget, review, beforeNavigate: () => !active?.busy }; + }; + return { create }; +})(); diff --git a/docker/setup/client/credential-editor.js b/docker/setup/client/credential-editor.js new file mode 100644 index 0000000..b1ba445 --- /dev/null +++ b/docker/setup/client/credential-editor.js @@ -0,0 +1,155 @@ +// Drafts own their input subtree. Only a newly typed secret lives here; neither +// saved values nor server responses are copied into the form or a preview. +const T3CredentialEditor = (() => { + 'use strict'; + const { html, raw, icon } = Kit, S = T3CredentialModel, M = T3Model; + const disabled = value => value ? raw(' disabled') : ''; + const checked = value => value ? raw(' checked') : ''; + const create = ctx => { + let active = null; + const close = reason => active ? active.close(reason) : Promise.resolve(true); + const open = async (profile, { replace = false } = {}) => { + if (!await close('replaced')) return; + const view = { original: profile || null, draft: S.draftOf(profile), revision: profile?.revision, create: !profile, secret: '', replacing: !profile || replace, + touched: new Set(), busy: null, error: null, conflict: null, taken: false, result: null, scanned: null, trusted: false, revealed: false, railOpen: false, generation: 0, closing: false }; + active = view; + const baseline = () => JSON.stringify(S.payload(view.original ? S.draftOf(view.original) : S.defaults('ssh'))); + const dirty = () => Boolean(view.secret || view.trusted || JSON.stringify(S.payload(view.draft)) !== baseline()); + const errors = () => ({ ...S.validate(view.draft, { editing: !view.create, names: ctx.rows().filter(row => row.id !== view.recreateId).map(row => row.id), secret: view.secret, hasSecret: !view.create && view.original?.hasSecret }), ...(view.taken ? { id: view.draft.id + ' is taken.' } : {}) }); + const valid = () => !Object.keys(errors()).length; + const canSave = () => !view.busy && valid() && (!view.original || dirty()) && !view.conflict; + const sync = () => { + for (const input of view.layer.panel.querySelectorAll('[data-cred-field]')) { + const key = input.dataset.credField, value = key === 'secret' ? view.secret : view.draft[key]; + if (input.value !== String(value ?? '')) input.value = value ?? ''; + } + const trust = view.layer.panel.querySelector('[data-cred-editor="trust"]'); if (trust) trust.checked = view.trusted; + view.layer.panel.querySelector('.tc-wizard-rail-wrap')?.toggleAttribute('data-open', !Kit.isPhone() || view.railOpen); + }; + const paint = () => { if (active !== view || view.layer?.closed) return; view.layer.render(); sync(); }; + view.repaint = paint; + view.close = async reason => { + if (view.busy || view.closing) { Kit.announce('Wait for this credential check or save to finish.'); return false; } + if (dirty()) { + view.closing = true; + const yes = await Kit.confirm({ title: 'Discard changes?', body: 'Your credential changes have not been saved.', confirm: 'Discard changes', tone: 'danger' }); + view.closing = false; if (!yes || active !== view) return false; + } + view.layer.close(reason); return true; + }; + const beforeUnload = event => { if (view.busy || dirty()) { event.preventDefault(); event.returnValue = ''; } }; + window.addEventListener('beforeunload', beforeUnload); + const hint = (key, fallback) => { + const error = view.touched.has(key) && errors()[key]; + return html`${fallback ? html`

${fallback}

` : ''}${error ? html`` : ''}`; + }; + const input = (key, label, options = {}) => { + const invalid = view.touched.has(key) && Boolean(errors()[key]); + return html`
${hint(key, options.hint)}
`; + }; + const secret = () => html`
${view.replacing ? html`` : html`${view.draft.type === 'ssh' ? 'Password' : 'API key'}`}${!view.replacing && view.original ? html`
${icon('circle-check')}Saved ${view.original.secretChangedAt ? '· changed ' + S.day(view.original.secretChangedAt) : '· from before the upgrade'}
` : html`
`}${hint('secret', view.original ? 'Leaving the replacement empty keeps the saved ' + S.secretKind(view.draft) + '.' : view.draft.type === 'ssh' ? 'Not shown again after you save. Key-based sign-in isn’t supported yet.' : 'Not shown again after you save. Agents use the credential’s name.')}${view.original && view.replacing ? html`` : ''}
`; + const segment = (key, label, choices) => html`
${label}
${choices.map(([value, title, mark]) => html``)}
`; + const fields = () => html`
${view.create ? segment('type', 'Type', [['ssh', 'SSH server', 'terminal'], ['api', 'HTTP API', 'globe']]) : ''}${view.create ? input('id', 'Name', { max: 64, hint: Kit.isPhone() ? 'Skills use it, so it can’t change later.' : 'Lowercase letters, numbers and hyphens. Skills use it, so it can’t change later.' }) : html`
Name
${icon('lock')}${view.draft.id}Can’t change

${ctx.usageWarnings()?.length ? 'Some skills couldn’t be read.' : 'Mentioned in ' + S.usageCount(view.original) + '.'}

`} + ${view.draft.type === 'ssh' ? html`
${input('host', 'Host', { max: 253, placeholder: '192.0.2.10' })}${input('port', 'Port', { type: 'number' })}
${input('username', 'Username', { max: 64, placeholder: 'deploy' })}` : html`${input('baseUrl', 'Base URL', { max: 2048, placeholder: 'https://api.example.com/v1/', hint: 'Include the version or base path. Requests can’t leave it.' })}${segment('authType', 'Authentication', [['bearer', 'Bearer token'], ['header', 'Header'], ['query', 'Query parameter']])}${view.draft.authType === 'header' ? html`
${input('authName', 'Header name', { max: 128, placeholder: 'X-API-Key' })}${input('authPrefix', 'Value prefix (optional)', { max: 128, placeholder: 'None' })}
` : view.draft.authType === 'query' ? input('authName', 'Parameter name', { max: 128, placeholder: 'api_key', hint: 'Prefer a header when the service supports one: query keys can appear in its logs.' }) : ''}`}${secret()}
`; + const result = () => { + const profile = { ...view.draft, hostKey: view.scanned && view.trusted ? view.scanned.presented : view.original?.hostKey || (view.result ? {} : null), test: view.result }; + const state = view.result ? S.state(profile) : null, key = view.scanned?.presented; + return html`
${view.busy === 'test' ? html`

Testing…

` : ''}${key ? html`
${view.scanned.state === 'changed' ? 'Different host key from ' : 'First connection to '}${view.draft.host}${S.keyType(key.type)} ${key.fingerprint}${Kit.isPhone() ? html`
Compare with the server’s key${S.keyCommand(key.type)}
` : html`Compare it with the server’s own:${S.keyCommand(key.type)}`}
` : ''}${state && !['host-key-untrusted', 'host-key-changed'].includes(state.code) ? html`
${icon(state.icon || 'info')}${view.result.ok && view.draft.type === 'ssh' ? 'Signed in as ' + view.draft.username : state.label}${view.result.ms >= 1000 ? (view.result.ms / 1000).toFixed(1) + ' s' : view.result.ms + ' ms'}
${!view.result.ok ? html`

${state.desc}

` : ''}` : !key && view.busy !== 'test' ? html`

Test the login before saving it. Nothing is saved by a draft test.

` : ''}${view.result?.code === 'host-key-changed' && !view.trusted ? html`` : ''}
`; + }; + const testButton = (phone = false) => html``; + const testSection = () => html`

Test before saving${Kit.isPhone() ? '' : testButton()}

${view.draft.type === 'api' ? html`${input('testPath', 'GET test path (optional)', { max: 2048, placeholder: 'account', hint: 'Pick an endpoint that needs the key: a public one answers without it. Blank tests the base URL.' })}` : ''}${result()}
`; + const rail = () => html`
Agents run ${(view.draft.type === 'ssh' ? 't3-ssh ' : 't3-api ') + (view.draft.id || 'name')}${icon('chevron-down')}
`; + const conflict = () => !view.conflict ? '' : html``; + const submit = async () => { + if (view.busy || active !== view || view.conflict) return; + if (!valid()) { Object.keys(errors()).forEach(key => view.touched.add(key)); paint(); view.layer.panel.querySelector('[aria-invalid="true"]')?.focus(); return; } + if (view.original && !dirty()) return; + view.busy = 'save'; view.error = null; paint(); + const credential = { ...S.payload(view.draft), ...(view.secret ? { secret: view.secret } : {}) }; + const response = await ctx.api(view.create ? '/credentials/create' : '/credentials/save', { body: { credential, ...(!view.create ? { revision: view.revision } : {}), ...(view.trusted && view.scanned ? { trust: { fingerprint: view.scanned.presented.fingerprint } } : {}) } }); + delete credential.secret; + if (active !== view) return; + if (!response.ok) { + view.busy = null; + if (['changed', 'gone'].includes(response.data?.code)) view.conflict = response.data; + else if (response.data?.code === 'taken') { view.taken = true; view.touched.add('id'); } + else view.error = response.error || 'Couldn’t save this credential. Your draft is still here.'; + paint(); (view.layer.panel.querySelector('#cred-conflict, #cred-editor-error') || view.layer.panel.querySelector('[aria-invalid="true"]'))?.focus({ preventScroll: true }); return; + } + const id = view.draft.id, failedTrust = response.data.trust?.ok === false; + const refreshed = await ctx.saved(response.data.credential); + if (active !== view) return; + view.busy = null; view.layer.close('saved'); + Kit.toast(id + ' saved', !refreshed ? { tone: 'warn', detail: 'Saved, but the list could not be refreshed. Try again on the Credentials page.' } : failedTrust ? { tone: 'warn', detail: 'Its host key could not be trusted. Review it before agents connect.', action: { label: 'Review', run: () => ctx.review(id) } } : undefined); + }; + const test = async () => { + if (view.busy || !valid()) return; + view.busy = 'test'; view.error = null; const generation = ++view.generation; paint(); + const draft = { ...S.payload(view.draft), ...(view.secret ? { secret: view.secret } : {}) }; + const response = await ctx.api('/credentials/test', { body: { draft, ...(!view.create ? { id: view.draft.id } : {}), ...(view.trusted && view.scanned ? { trust: { fingerprint: view.scanned.presented.fingerprint } } : {}) } }); + delete draft.secret; + if (active !== view || generation !== view.generation) return; + view.busy = null; + if (!response.ok || !response.data.result) view.error = response.error || 'The test could not finish. Your draft is still here.'; + else { + view.result = response.data.result; + if (view.result.hostKey) { view.scanned = view.result.hostKey; view.trusted = false; } + else if (view.result.code === 'host-key-changed' && view.result.detail?.presented) { view.scanned = { state: 'changed', presented: view.result.detail.presented, trusted: view.result.detail.trusted }; view.trusted = false; } + Kit.announce(view.result.ok ? 'Connection succeeded. This draft is not saved yet.' : S.state({ ...view.draft, hostKey: view.original?.hostKey, test: view.result }).label); + } + paint(); + if (Kit.isPhone()) (view.layer.panel.querySelector(view.error ? '#cred-editor-error' : '.tc-cred-test-line') || view.layer.panel.querySelector('.tc-cred-hostkey--first'))?.scrollIntoView({ block: 'nearest' }); + }; + view.layer = Kit.open({ kind: 'center', backdrop: 'ignore', panel: { tag: 'form', class: 'tc-wizard tc-cred-editor', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'cred-editor-title', autocomplete: 'off', novalidate: true }, focus: replace ? '#cred-secret' : profile ? '#cred-host, #cred-baseUrl' : '#cred-id', onEscape: () => { void view.close('escape'); }, onClose: () => { + view.generation++; view.secret = ''; view.revealed = false; + const secret = view.layer.panel.querySelector('#cred-secret'); if (secret) { secret.value = ''; secret.type = 'password'; } + window.removeEventListener('beforeunload', beforeUnload); if (active === view) active = null; + }, render: () => html`

${view.create ? 'Add credential' : 'Edit ' + view.draft.id}

${view.create ? 'Save a login once. Agents use it by name.' : (view.draft.type === 'ssh' ? 'SSH server' : 'HTTP API') + ' · ' + (view.original.usedIn?.length ? 'used in ' + M.listOf(view.original.usedIn.map(item => item.name)) : ctx.usageWarnings()?.length ? 'usage partly checked' : 'not in a skill yet')}
${conflict()}${fields()}${Kit.isPhone() ? testSection() : ''}
${rail()}
${view.error ? html`` : ''}
${icon('lock', 'tc-icon--sm')}Saved on this server, in plain text.${Kit.isPhone() ? html`${testButton(true)}` : html``}
` }); + sync(); + view.layer.panel.addEventListener('submit', event => { event.preventDefault(); void submit(); }); + view.layer.panel.addEventListener('input', event => { + const key = event.target.dataset.credField; if (!key || view.busy) return; + if (key === 'secret') view.secret = event.target.value; + else { view.draft[key] = key === 'id' ? event.target.value.toLowerCase().replace(/\s/g, '-') : event.target.value; if (key === 'id') { view.taken = false; event.target.value = view.draft.id; } } + view.generation++; view.result = null; view.error = null; + if (['host', 'port'].includes(key)) { view.scanned = null; view.trusted = false; } + paint(); + }); + view.layer.panel.addEventListener('focusout', event => { + const key = event.target.dataset.credField; if (!key || view.busy) return; + view.touched.add(key); paint(); + }); + view.layer.panel.addEventListener('toggle', event => { + if (event.target.matches('.tc-cred-compare')) { view.compareOpen = event.target.open; return; } + if (!event.target.matches('.tc-wizard-rail-wrap') || !Kit.isPhone()) return; + view.railOpen = event.target.open; event.target.toggleAttribute('data-open', view.railOpen); + }, true); + view.layer.panel.addEventListener('click', async event => { + const button = event.target.closest('[data-cred-editor]'); if (!button || button.disabled || button.tagName === 'INPUT') return; + const action = button.dataset.credEditor; + if (action === 'close') return void view.close('close'); + if (view.busy) return; + if (action === 'copy') return void Kit.copy(S.commands(view.draft, true)[0], button, 'Draft command copied'); + if (action === 'test') return void test(); + if (action === 'replace') { view.replacing = true; paint(); view.layer.panel.querySelector('#cred-secret')?.focus(); return; } + if (action === 'keep-saved') { view.secret = ''; view.replacing = false; view.revealed = false; view.touched.delete('secret'); view.result = null; paint(); view.layer.panel.querySelector('#cred-replace')?.focus(); return; } + if (action === 'reveal') { view.revealed = !view.revealed; paint(); return; } + if (action === 'type') { const id = view.draft.id; view.draft = { ...S.defaults(button.dataset.value), id }; view.secret = ''; view.scanned = null; view.trusted = false; view.result = null; view.touched.clear(); view.revealed = false; paint(); return; } + if (action === 'authType') { view.draft.authType = button.dataset.value; view.draft.authName = button.dataset.value === 'query' ? 'api_key' : 'X-API-Key'; view.draft.authPrefix = ''; view.result = null; view.touched.delete('authName'); paint(); return; } + if (action === 'theirs') { + const current = view.conflict.current, keep = view.original.secretChangedAt === current.secretChangedAt; + view.secret = keep ? view.secret : ''; view.replacing = Boolean(view.secret); view.revealed = false; view.original = current; view.draft = S.draftOf(current); view.revision = current.revision; view.conflict = null; view.scanned = null; view.trusted = false; view.result = null; view.touched.clear(); paint(); return; + } + if (action === 'mine') { view.revision = view.conflict.current.revision; view.conflict = null; return void submit(); } + if (action === 'new') { view.recreateId = view.draft.id; view.create = true; view.original = null; view.conflict = null; view.revision = undefined; view.replacing = true; paint(); if (valid()) void submit(); else view.layer.panel.querySelector('#cred-secret')?.focus(); } + }); + view.layer.panel.addEventListener('change', event => { + if (event.target.dataset.credEditor !== 'trust' || view.busy) return; + view.trusted = event.target.checked; view.result = null; paint(); if (view.trusted) void test(); + }); + view.layer.panel.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); void submit(); } }); + }; + return { open, close, repaint: () => active?.repaint(), beforeNavigate: () => close('route'), isOpen: () => Boolean(active) }; + }; + return { create }; +})(); diff --git a/docker/setup/client/credential-inspector.js b/docker/setup/client/credential-inspector.js new file mode 100644 index 0000000..9c4581f --- /dev/null +++ b/docker/setup/client/credential-inspector.js @@ -0,0 +1,58 @@ +const T3CredentialInspector = (() => { + 'use strict'; + const { html, raw, icon } = Kit, S = T3CredentialModel, M = T3Model; + const disabled = value => value ? raw(' disabled') : ''; + const requestPreview = (profile, short = false) => { + const lines = S.request(profile, short).split('\n'); + const mask = text => text.split('••••••••').map((part, index) => html`${index ? html`••••••••` : ''}${part}`); + return html`
${lines.map((line, index) => html`${index ? '\n' : ''}${line.startsWith('GET ') ? html`GET ${mask(line.slice(4))}` : line.startsWith('Host: ') ? html`Host: ${line.slice(6)}` : mask(line)}`)}
`; + }; + const create = ctx => { + let active = null; + const close = reason => { active?.layer.close(reason); return true; }; + const repaint = () => { + if (!active || active.layer.closed) return; + active.layer.panel.classList.toggle('tc-sheet--bottom', Kit.isPhone()); active.layer.render(); + }; + const open = id => { + if (active?.id === id) { repaint(); return; } + close('replaced'); const view = { id, snippetOpen: false }; active = view; + const row = () => ctx.row(id), busy = () => ctx.busy.has(id); + const action = (name, label, options = {}) => html``; + const saved = profile => html`${icon('lock')}Saved ${profile.secretChangedAt ? '· changed ' + S.day(profile.secretChangedAt) : '· from before the upgrade'}${action('replace', 'Replace…', { ghost: true })}`; + const connection = profile => html`

Connection

${profile.type === 'ssh' ? html` +
Host
${profile.host}
Port
${profile.port}
Username
${profile.username}
Password
${saved(profile)}
Host key
${profile.hostKey ? html`${S.keyType(profile.hostKey.type)}${profile.hostKey.fingerprint}trusted ${S.day(profile.hostKey.trustedAt)}${profile.hostKey.carried ? ' · carried over' : ''}` : html`Not trusted yet${action('review', 'Trust…', { ghost: true })}`}
` : html` +
Base URL
${profile.baseUrl}
Auth
${profile.authType === 'header' ? 'Header ' + profile.authName : profile.authType === 'query' ? 'Query parameter ' + profile.authName : 'Bearer token'}
${profile.authType === 'bearer' ? 'Token' : 'Key'}
${saved(profile)}
Test with
GET ${profile.testPath || 'the base URL'}
`}
`; + const bannerActions = profile => { + const state = S.state(profile, { busy: ctx.busy.get(id)?.kind === 'test' }); + return html`
${state.action.startsWith('Review') ? action('review', 'Review…') : state.action.startsWith('Replace') ? action('replace', 'Replace ' + S.secretKind(profile) + '…') : state.action.startsWith('Edit') ? action('edit', 'Edit…') : ''}${!state.action.startsWith('Review') ? action('test', profile.test ? 'Test again' : 'Test now') : ''}
`; + }; + const banner = profile => { + const state = S.state(profile, { busy: ctx.busy.get(id)?.kind === 'test' }); + return html`
${state.icon ? icon(state.icon) : html``}
${state.title}${state.desc ? html`${profile.type === 'api' && profile.test?.ok ? html`GET ${profile.testPath || 'the base URL'} answered HTTP ${profile.test.detail.status} in ${profile.test.ms} ms.` : state.desc}` : ''}
${Kit.isPhone() ? '' : bannerActions(profile)}
${Kit.isPhone() ? bannerActions(profile) : ''}
`; + }; + const snippet = profile => html`
${S.snippet(profile).split('\n').map((line, index) => html`${index ? '\n' : ''}${line.startsWith('t3-ssh ') || line.startsWith('t3-api ') ? html`${line}` : line}`)}
`; + const use = profile => html`

Use it in a skill${action('snippet', 'Copy snippet', { icon: 'copy' })}

${profile.type === 'ssh' ? snippet(profile) : ''}${profile.type === 'api' ? html`
Snippet for SKILL.md${snippet(profile)}
` : ''}

${profile.type === 'ssh' ? 'Agents never need the password. Output that repeats it shows [redacted].' : 'Paths are relative to the base URL. JSON bodies go on stdin, up to 1 MiB.'}

`; + const usedIn = profile => html`

Used in${S.usageCount(profile, ctx.usageWarnings()?.length > 0)}

${profile.usedIn?.length ? profile.usedIn.map(item => html`
${item.name}${item.kind === 'instructions' ? 'Shared global instructions' : 'Global skill · ' + item.where}
Open${icon('chevron-right')}
`) : html`

${ctx.usageWarnings()?.length ? 'Couldn’t read all skills just now.' : 'Not in a skill yet'}

`}

Skills that mention ${id}, from Instructions & skills.${ctx.usageWarnings()?.length ? ' Some skills couldn’t be read.' : ''}

`; + const render = () => { + const profile = row(); + return html`

${id}

${profile ? (profile.type === 'ssh' ? 'SSH' : 'API') + ' · ' + S.auth(profile) + ' · added ' + S.day(profile.createdAt) : 'Credential unavailable'}
${profile ? action('edit', 'Edit', { icon: 'pencil' }) : ''}
${profile ? html`${ctx.errors.get(id) ? html`` : ''}${banner(profile)}${connection(profile)}${profile.type === 'api' ? html`

Every requestStays under the base URL · no redirects

${requestPreview(profile)}
` : ''}${use(profile)}${usedIn(profile)}` : html``}
`; + }; + view.layer = Kit.open({ kind: 'sheet', returnTo: document.activeElement, panel: { class: 'tc-sheet tc-skill-inspector tc-mcp-inspector tc-cred-inspector' + (Kit.isPhone() ? ' tc-sheet--bottom' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'cred-inspect-title' }, focus: '[data-cred-inspect="close"]', backdrop: 'ignore', onEscape: () => close('escape'), onClose: reason => { if (active === view) active = null; ctx.inspectorClosed(id, reason); }, render }); + view.layer.panel.addEventListener('toggle', event => { if (event.target.matches('.tc-cred-snippet-detail')) view.snippetOpen = event.target.open; }, true); + view.layer.panel.addEventListener('click', event => { + const button = event.target.closest('[data-cred-inspect]'); if (!button || button.disabled) return; + const action = button.dataset.credInspect, profile = row(); + if (action === 'close') return close('close'); + if (action === 'retry') return void ctx.refresh(); + if (!profile) return; + if (action === 'menu') return ctx.menu(button, profile, true); + if (action === 'snippet') return void Kit.copy(S.snippet(profile), button, 'Skill snippet copied'); + if (action === 'command') return void Kit.copy(S.commands(profile)[Number(button.dataset.index)], button, 'Command copied'); + ctx.action(action, id, button); + }); + }; + return { open, close, repaint, id: () => active?.id || null }; + }; + return { create, requestPreview }; +})(); diff --git a/docker/setup/client/credential-model.js b/docker/setup/client/credential-model.js new file mode 100644 index 0000000..8c05dae --- /dev/null +++ b/docker/setup/client/credential-model.js @@ -0,0 +1,78 @@ +// Pure credential presentation and draft validation. Saved secrets never enter +// this model; request previews and snippets use fixed masks or placeholders. +const T3CredentialModel = (() => { + 'use strict'; + const M = T3Model, C = T3McpCommand; + const fields = ['id', 'type', 'host', 'port', 'username', 'baseUrl', 'authType', 'authName', 'authPrefix', 'testPath']; + const validName = value => typeof value === 'string' && value.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value); + const text = (value, max, optional = false) => typeof value === 'string' && value.isWellFormed() && (optional || value.length > 0) && value.length <= max && !/[\x00-\x1f\x7f]/.test(value); + const defaults = type => ({ id: '', type, host: '', port: 22, username: '', baseUrl: '', authType: 'bearer', authName: 'X-API-Key', authPrefix: '', testPath: '' }); + const draftOf = profile => ({ ...defaults(profile?.type || 'ssh'), ...Object.fromEntries(fields.filter(field => profile?.[field] !== undefined).map(field => [field, profile[field]])) }); + const payload = draft => Object.fromEntries(fields.filter(field => draft[field] !== undefined).map(field => [field, field === 'port' ? Number(draft[field]) : draft[field]])); + const kind = profile => profile.type === 'ssh' ? 'Password' : profile.authType === 'header' ? profile.authName || 'Header key' : profile.authType === 'query' ? '?' + (profile.authName || 'key') : 'Bearer token'; + const secretKind = profile => profile.type === 'ssh' ? 'password' : profile.authType === 'bearer' ? 'token' : 'key'; + const auth = profile => profile.type === 'ssh' ? 'password' : profile.authType === 'header' ? (profile.authName || 'Header') + ' header' : profile.authType === 'query' ? (profile.authName || 'key') + ' query parameter' : 'bearer token'; + const target = profile => profile.type === 'ssh' ? (profile.username || 'user') + '@' + (profile.host || 'host') + (Number(profile.port || 22) !== 22 ? ':' + profile.port : '') : profile.baseUrl; + const usageCount = (profile, partial = false) => { const n = profile.usedIn?.length || 0, unit = profile.usedIn?.some(item => item.kind === 'instructions') ? 'source' : 'skill'; return partial ? n ? 'At least ' + M.plural(n, unit) : 'Unknown' : M.plural(n, unit); }; + const day = value => { const date = new Date(value); return Number.isFinite(date.getTime()) ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : 'unknown'; }; + const keyType = type => type === 'ssh-ed25519' ? 'ED25519' : type === 'ssh-rsa' ? 'RSA' : type?.startsWith('ecdsa') ? 'ECDSA' : 'Host key'; + const keyCommand = type => 'ssh-keygen -lf /etc/ssh/ssh_host_' + (type === 'ssh-rsa' ? 'rsa' : type?.startsWith('ecdsa') ? 'ecdsa' : 'ed25519') + '_key.pub'; + const commands = (profile, editing = false) => profile.type === 'ssh' + ? ['t3-ssh ' + (profile.id || 'name') + ' -- ' + (editing ? 'command' : 'uptime'), 't3-ssh ' + (profile.id || 'name') + ' -- bash -s < script.sh'] + : ['t3-api ' + (profile.id || 'name') + ' GET ' + C.quote(profile.testPath || ''), 't3-api ' + (profile.id || 'name') + ' POST --stdin < body.json']; + const snippet = profile => { + if (profile.type === 'ssh') return `Run commands on ${profile.id} with its saved login:\n\nt3-ssh ${profile.id} -- ''\n\nDon’t read or print credential files.`; + let host = profile.baseUrl; try { host = new URL(profile.baseUrl).host; } catch {} + return `Call ${host}’s API with its saved key:\n\nt3-api ${profile.id} GET \nt3-api ${profile.id} POST --stdin < body.json\n\nPaths are relative to ${profile.baseUrl}. Don’t read or print credential files.`; + }; + const request = (profile, short = false) => { + try { + const base = new URL(profile.baseUrl); if (!base.pathname.endsWith('/')) base.pathname += '/'; + const url = new URL(profile.testPath || '.', base); + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || url.origin !== base.origin || !url.pathname.startsWith(base.pathname.endsWith('/') ? base.pathname : base.pathname + '/')) return 'Enter a valid base URL and relative test path.'; + if (profile.authType === 'query') url.searchParams.set(profile.authName || 'key', '••••••••'); + const address = (short ? url.pathname + url.search : url.href).replace(/%E2%80%A2/gi, '•'); + const header = profile.authType === 'query' ? '' : profile.authType === 'header' ? (profile.authName || 'X-API-Key') + ': ' + (profile.authPrefix ? profile.authPrefix + ' ' : '') + '••••••••\n' : 'Authorization: Bearer ••••••••\n'; + return 'GET ' + address + '\n' + (short ? 'Host: ' + base.host + '\n' : '') + header + 'accept: application/json'; + } catch { return 'Enter a base URL to preview the request.'; } + }; + const validate = (draft, { editing = false, names = [], secret = '', hasSecret = false } = {}) => { + const errors = {}; + if (!validName(draft.id)) errors.id = 'Use up to 64 lowercase letters, numbers and single hyphens.'; + else if (!editing && names.includes(draft.id)) errors.id = draft.id + ' is taken.'; + if (!['ssh', 'api'].includes(draft.type)) errors.type = 'Choose SSH server or HTTP API.'; + if (draft.type === 'ssh') { + const host = draft.host || ''; let ipv6 = false; + if (host.includes(':')) { try { const [address, zone, extra] = host.split('%'); const addressUrl = 'http://[' + address + ']/'; ipv6 = new URL(addressUrl).hostname.startsWith('[') && extra === undefined && (zone === undefined || /^[a-z0-9_.-]+$/i.test(zone)); } catch {} } + if (!text(host, 253) || !(ipv6 || /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(host))) errors.host = 'Enter a hostname or IP address, without a username or port.'; + if (!Number.isInteger(Number(draft.port)) || Number(draft.port) < 1 || Number(draft.port) > 65535) errors.port = 'Use a port from 1 to 65535.'; + if (!text(draft.username || '', 64) || !/^[a-zA-Z_][a-zA-Z0-9_.-]*\$?$/.test(draft.username)) errors.username = 'Enter a valid SSH username.'; + } else if (draft.type === 'api') { + let base; + try { base = new URL(draft.baseUrl); } catch {} + if (!text(draft.baseUrl || '', 2048) || !base || !['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) errors.baseUrl = 'Use an HTTP or HTTPS address without credentials, a query or a fragment.'; + if (!['bearer', 'header', 'query'].includes(draft.authType)) errors.authType = 'Choose an authentication method.'; + if (draft.authType !== 'bearer') { + if (!text(draft.authName || '', 128)) errors.authName = 'Enter an authentication header or parameter name.'; + if (draft.authType === 'header') { + if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(draft.authName || '') || ['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'trailer', 'te', 'accept', 'content-type'].includes(draft.authName?.toLowerCase())) errors.authName = 'Use a valid authentication header, such as X-API-Key.'; + if (typeof draft.authPrefix !== 'string' || draft.authPrefix.length > 128 || /[^\x20-\x7e]/.test(draft.authPrefix)) errors.authPrefix = 'Use up to 128 printable characters for the prefix.'; + } + } + const relative = draft.testPath || ''; + if (!text(relative, 2048, true) || relative.startsWith('/') || relative.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(relative)) errors.testPath = 'Use a relative path, such as account.'; + else if (base && !errors.baseUrl) { + if (!base.pathname.endsWith('/')) base.pathname += '/'; + const target = new URL(relative || '.', base); + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname) || target.hash) errors.testPath = 'The test path must stay under the base URL.'; + } + } + if (secret ? !text(secret, 8192) : !hasSecret) errors.secret = 'Enter a ' + secretKind(draft) + ', up to 8192 characters without line breaks.'; + return errors; + }; + const changes = (previous, current) => fields.filter(field => field !== 'id' && field !== 'type' && String(previous?.[field] ?? '') !== String(current?.[field] ?? '')); + const fieldLabel = field => ({ host: 'Host', port: 'Port', username: 'Username', baseUrl: 'Base URL', authType: 'Authentication', authName: 'Authentication name', authPrefix: 'Prefix', testPath: 'Test path' }[field] || field); + const matches = (profile, query) => !query.trim() || [profile.id, profile.host, profile.username, profile.baseUrl, kind(profile), auth(profile), profile.type].filter(Boolean).join(' ').toLowerCase().includes(query.trim().toLowerCase()); + const needsAttention = (profile, attention) => profile.type === 'ssh' && (!profile.hostKey || profile.test?.code === 'host-key-changed') || profile.test?.ok === false && (profile.test.code !== 'timeout' || attention?.some(row => row.id === profile.id)); + return { fields, validName, defaults, draftOf, payload, kind, secretKind, auth, target, usageCount, day, keyType, keyCommand, commands, snippet, request, validate, changes, fieldLabel, matches, needsAttention, state: M.credentialState }; +})(); diff --git a/docker/setup/client/credentials.js b/docker/setup/client/credentials.js new file mode 100644 index 0000000..5b2fd6c --- /dev/null +++ b/docker/setup/client/credentials.js @@ -0,0 +1,139 @@ +// On-demand credentials library. Polling never replaces focused inputs or a +// draft, and an item stays pending until its saved truth has been read back. +const T3CredentialsUI = (() => { + 'use strict'; + const { html, raw, icon } = Kit, S = T3CredentialModel, M = T3Model; + const disabled = value => value ? raw(' disabled') : ''; + const create = ({ api, getStatus = () => null, render, onChange = async () => {} }) => { + let data = null, error = null, query = '', attentionOnly = false, started = false, loading = false, inFlight = null, epoch = 0, lastDeep = null; + const busy = new Map(), errors = new Map(); + const rows = () => [...(data?.credentials || [])].sort((a, b) => a.id.localeCompare(b.id, 'en')); + const row = id => data?.credentials.find(row => row.id === id); + const usageWarnings = () => data?.usageWarnings || []; + const attention = () => rows().filter(profile => S.needsAttention(profile, getStatus()?.credentials?.attention)); + const testing = () => [...busy.values()].filter(value => value.kind === 'test').length; + const paint = () => { render(); const mount = document.getElementById('credential-list'); if (mount) Kit.patch(mount, body()); inspector.repaint(); }; + const load = (force = false) => { + if (inFlight && !force) return inFlight; + const version = ++epoch; started = true; loading = true; error = null; paint(); + const work = api('/credentials').then(response => { + if (version !== epoch) return false; + if (response.ok && Array.isArray(response.data?.credentials)) { data = response.data; return true; } + error = response.error || 'Saved credentials could not be read. Restore the credential file before making changes.'; data = null; return false; + }).finally(() => { if (version === epoch) { loading = false; inFlight = null; paint(); } }); + inFlight = work; return work; + }; + const request = async (id, kind, route, input, { refresh = true } = {}) => { + if (busy.has(id)) return null; + const focused = document.activeElement?.getAttribute('data-key'); + busy.set(id, { kind }); errors.delete(id); paint(); + let response; + try { + response = await api(route, { body: input }); + if (!response.ok) errors.set(id, response.error || 'This action did not finish. Try again.'); + if (refresh) await Promise.all([load(true), onChange()]); + return response; + } finally { + busy.delete(id); paint(); + if (focused && document.activeElement === document.body) document.querySelector('[data-key="' + CSS.escape(focused) + '"]')?.focus({ preventScroll: true }); + } + }; + const inspect = id => { if (row(id)) inspector.open(id); }; + const edit = async (id, replace = false) => { + if (id && busy.has(id) || error) return; + const profile = id ? row(id) : null; if (id && !profile) return; + inspector.close('edit'); await editor.open(profile, { replace }); + }; + const ctx = { api, busy, errors, rows, row, usageWarnings, loadError: () => error, refresh: () => load(true), request, + saved: async credential => { + if (data) data.credentials = [...data.credentials.filter(row => row.id !== credential.id), { ...credential, usedIn: row(credential.id)?.usedIn || [] }]; + errors.delete(credential.id); const [loaded] = await Promise.all([load(true), onChange()]); return loaded; + }, + deleted: id => { + if (inspector.id() === id) inspector.close('removed'); + if (location.hash === '#credentials/' + id) history.replaceState(null, '', '#credentials'); + paint(); + requestAnimationFrame(() => { + const next = document.querySelector('#credential-list [data-cred-action="inspect"], #credential-list [data-cred-action="add"], #credential-list [data-cred-action="retry"]'); + next?.focus({ preventScroll: true }); + }); + }, + inspectorClosed: (id, reason) => { if (!['route', 'replaced'].includes(reason) && location.hash === '#credentials/' + id) { history.replaceState(null, '', '#credentials'); lastDeep = null; } }, + action: (action, id, button) => { void actionFor(action, id, button); }, menu: (button, profile, details) => menu(button, profile, details), review: id => dialogs.review(id), + }; + const inspector = T3CredentialInspector.create(ctx), editor = T3CredentialEditor.create(ctx), dialogs = T3CredentialDialogs.create(ctx); + const actionFor = async (action, id, button) => { + if (action === 'add') { if (!data && !await load()) return; return edit(); } + if (action === 'retry') return load(true).then(loaded => { if (loaded) return activate(); }); + if (action === 'clear') { query = ''; attentionOnly = false; paint(); const input = document.getElementById('credential-search'); if (input) input.value = ''; return; } + if (action === 'attention') { attentionOnly = !attentionOnly; paint(); return; } + const profile = row(id); if (!profile) return; + if (action === 'inspect') return inspect(id); + if (action === 'menu') return menu(button, profile); + if (action === 'copy-name') return Kit.copy(profile.id, button, 'Credential name copied'); + if (action === 'copy-command') return Kit.copy(S.commands(profile)[0], button, 'Command copied'); + if (busy.has(id) || error) return; + if (action === 'edit' || action === 'replace') return edit(id, action === 'replace'); + if (action === 'review') { if (profile.test?.code === 'host-key-changed' || !profile.hostKey && inspector.id() === id) return dialogs.review(id); return inspect(id); } + if (action === 'delete') return dialogs.remove(id); + if (action === 'forget') return dialogs.forget(id); + if (action === 'test') { + if (testing() >= 3) return Kit.announce('Three tests are already running. Wait for one to finish.'); + const response = await request(id, 'test', '/credentials/test', { id }); + if (response?.ok && response.data.result) Kit.announce(response.data.result.ok ? id + ' connected.' : id + ': ' + M.credentialProblem(response.data.result.code)); + } + }; + const menu = (button, profile, details = false) => Kit.menu(button, { label: 'More for ' + profile.id, head: profile.id, items: [ + { label: 'Test', icon: 'refresh-cw', disabled: busy.has(profile.id) || testing() >= 3, run: () => actionFor('test', profile.id, button) }, + { label: 'Edit…', icon: 'pencil', disabled: busy.has(profile.id), run: () => edit(profile.id) }, + { label: 'Copy name', icon: 'copy', run: () => actionFor('copy-name', profile.id, button) }, + { label: 'Copy command', icon: 'terminal', run: () => actionFor('copy-command', profile.id, button) }, + ...(details && profile.type === 'ssh' && profile.hostKey ? [{ label: 'Forget host key…', icon: 'shield-alert', disabled: busy.has(profile.id), run: () => dialogs.forget(profile.id) }] : []), + { sep: true }, { label: 'Delete…', icon: 'trash-2', danger: true, disabled: busy.has(profile.id), run: () => dialogs.remove(profile.id) }, + ] }); + const footer = () => html`
${icon('lock', 'tc-icon--sm')}${Kit.isPhone() ? 'Saved in plain text on this server. Chats and skills only need the name.' : 'Saved in plain text on this server’s volume. Anything running here can read them; chats and skills only need the name.'}
`; + const nameBadge = profile => html`${profile.type === 'ssh' ? 'SSH' : 'API'}`; + const rowState = profile => S.state(profile, { busy: busy.get(profile.id)?.kind === 'test' }); + const dot = tone => html``; + const primary = profile => { + const state = rowState(profile), action = state.action.startsWith('Review') ? 'review' : state.action.startsWith('Replace') ? 'replace' : state.action.startsWith('Edit') ? 'edit' : 'test'; + return html``; + }; + const item = profile => { + const state = rowState(profile), pending = busy.has(profile.id); + if (Kit.isPhone()) return html``; + return html`
${nameBadge(profile)}
${icon(profile.type === 'ssh' ? 'server' : 'globe')}${S.target(profile)}
${icon('key-round')}${S.kind(profile)}${!usageWarnings().length || profile.usedIn?.length ? html`${icon('book-open')}${profile.usedIn?.length ? html`Used in ${profile.usedIn.map(item => item.name).join(', ')}` : 'Not in a skill yet'}` : ''}
${errors.get(profile.id) ? html`${errors.get(profile.id)}` : ''}
${pending ? html`` : dot(state.tone)}${busy.get(profile.id)?.kind === 'scan' ? 'Reading host key…' : busy.get(profile.id)?.kind === 'trust' ? 'Trusting host key…' : state.label}${state.sub ? html`${state.sub}` : ''}
${primary(profile)}
`; + }; + const toolbar = () => html`
${attention().length || attentionOnly ? html`` : ''}
`; + const empty = () => html`
Keep secrets out of chats and skills

Save an SSH or API login here once. Agents use it by name, and never need the secret itself.

1Save a login once

An SSH password or an API key, with where it goes. It stays on this server.

2Use its name in a skill

Skills and chats only ever carry the name.

t3-ssh build-server -- uptime
3The helper signs in

t3-ssh and t3-api add the secret, and hide it if the output repeats it.

${footer()}
`; + const body = () => { + if (error) return html``; + if (!data) return html`
${[1, 2, 3].map(index => html``)}
`; + if (!data.credentials.length) return empty(); + const filtered = rows().filter(profile => S.matches(profile, query) && (!attentionOnly || attention().some(row => row.id === profile.id))); + return html`${toolbar()}${usageWarnings().length ? html`

${icon('info', 'tc-icon--sm')}Some skills couldn’t be read. Usage may be incomplete.

` : ''}
${filtered.length ? filtered.map(item) : html`
${attentionOnly ? 'No matching credentials need attention' : 'No credentials match this search'}
`}
${footer()}
`; + }; + document.addEventListener('click', event => { const button = event.target.closest('[data-cred-action]'); if (!button || button.disabled) return; void actionFor(button.dataset.credAction, button.dataset.id, button); }); + document.addEventListener('input', event => { if (event.target.id !== 'credential-search') return; query = event.target.value; paint(); }); + window.addEventListener('focus', () => { if (started && location.hash.replace(/^#\/?/, '').split('/')[0] === 'credentials' && !editor.isOpen()) void load(); }); + const resize = new ResizeObserver(() => { if (started) { paint(); editor.repaint(); } }); + resize.observe(document.documentElement); + const activate = async (force = false) => { + const id = location.hash.replace(/^#\/?/, '').split('/')[1]; + const loaded = force || !started ? await load() : Boolean(data); if (!loaded) return; paint(); + if (S.validName(id) && !editor.isOpen() && lastDeep !== id) { + lastDeep = id; + if (row(id)) { inspect(id); if (row(id).test?.code === 'host-key-changed') void dialogs.review(id); } + else Kit.toast('Credential not found', { detail: 'It may have been deleted in another window.' }); + } else if (!id) lastDeep = null; + }; + return { + content: () => html`
${body()}
`, + activate, + paletteItems: () => rows().filter(profile => !getStatus()?.credentials?.attention?.some(item => item.id === profile.id)).map(profile => ({ id: 'credential:' + profile.id, group: 'Credentials', label: profile.id, icon: 'key-round', cmd: { cmd: 'goto', route: 'credentials/' + profile.id }, keywords: 'credential ' + profile.type + ' ' + S.target(profile) + ' ' + S.kind(profile), meta: { text: rowState(profile).label } })), + focusSearch: () => { document.getElementById('credential-search')?.focus(); }, + beforeNavigate: async () => { if (!dialogs.beforeNavigate()) { Kit.announce('Wait for the host-key check to finish.'); return false; } return editor.beforeNavigate(); }, + }; + }; + return { create }; +})(); diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index 91d8fba..5b4e842 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -1344,10 +1344,68 @@ const T3Model = (() => { action: { cmd: 'port.publish', id: String(p.port), label: 'Retry', icon: 'refresh-cw' } }); } } + for (const item of s.credentials?.attention || []) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.id || '') || item.id.length > 64) continue; + const warn = item.code === 'host-key-untrusted' || item.code === 'host-key-changed'; + items.push({ target: 'credential', id: item.id, key: 'credential:' + item.id, icon: 'key-round', tone: warn ? 'warn' : 'danger', severity: warn ? 1 : 0, + name: item.id, text: credentialProblem(item.code), action: { cmd: 'goto', route: 'credentials/' + item.id, label: 'Review' } }); + } // Stable within a severity: the order each page lists them in. return items.map((item, i) => [item, i]).sort((a, b) => a[0].severity - b[0].severity || a[1] - b[1]).map(([item]) => item); }; + // Credential copy is shared by list, sheets and Overview attention. + const CREDENTIAL_RESULTS = { + ssh: { + "ok": {"tone": "ok", "row": ["Connected", "{when}"], "sheet": {"title": "Connected {when}", "desc": "Signed in as {user}. The host key matched."}, "action": "Test"}, + "auth-rejected": {"tone": "danger", "row": ["Password rejected", "{when}"], "sheet": {"title": "Password rejected", "desc": "{host} didn’t accept the saved password for {user}. Replace it if it changed."}, "action": "Replace…"}, + "unreachable": {"tone": "danger", "row": ["Unreachable", "{when}"], "sheet": {"title": "Couldn’t reach {host}", "desc": "Nothing answered on port {port}. Check the address, and that the server is on and allows SSH from here."}, "action": "Test"}, + "timeout": {"tone": "danger", "row": ["Timed out", "{when}"], "sheet": {"title": "Timed out", "desc": "{host} didn’t finish signing in within 15 seconds."}, "action": "Test"}, + "host-key-untrusted": {"tone": "warn", "row": ["Host key not trusted yet", "Agents can’t connect yet"], "sheet": {"title": "Host key not trusted yet", "desc": "Compare {host}’s key with the server’s own, then trust it. Nothing signs in until you do."}, "action": "Review…"}, + "host-key-changed": {"tone": "warn", "row": ["Host key changed", "Blocked until you review it"], "sheet": {"title": "Host key changed", "desc": "{host} presents a different key than the one you trusted. Agents can’t connect until you review it."}, "action": "Review…"}, + "scan-failed": {"tone": "danger", "row": ["Couldn’t read the host key", "{when}"], "sheet": {"title": "Couldn’t read {host}’s host key", "desc": "It answered on port {port} but didn’t offer an ed25519, ECDSA or RSA key."}, "action": "Test"}, + "failed": {"tone": "danger", "row": ["Failed", "{when}"], "sheet": {"title": "Couldn’t sign in", "desc": "SSH said: {line}"}, "action": "Test"}, + }, + api: { + "ok": {"tone": "ok", "row": ["Connected · HTTP {status}", "{when}"], "sheet": {"title": "Connected {when}", "desc": "GET {path} answered HTTP {status} in {ms} ms."}, "action": "Test"}, + "rejected": {"tone": "danger", "row": ["Rejected · HTTP {status}", "{when}"], "sheet": {"title": "Rejected · HTTP {status}", "desc": "Tested {when}. The service didn’t accept the saved {secretKind}. Replace it if it was revoked or expired."}, "action": "Replace…"}, + "not-found": {"tone": "danger", "row": ["Test path not found · HTTP 404", "{when}"], "sheet": {"title": "Test path not found", "desc": "GET {path} answered HTTP 404. Check the test path and the base URL’s version."}, "action": "Edit…"}, + "http-error": {"tone": "danger", "row": ["HTTP {status}", "{when}"], "sheet": {"title": "The service answered HTTP {status}", "desc": "GET {path} didn’t succeed. A test path must be an endpoint that accepts GET."}, "action": "Test"}, + "redirect": {"tone": "danger", "row": ["Redirected", "{when}"], "sheet": {"title": "The service redirected the request", "desc": "Requests never follow redirects, so the key isn’t sent elsewhere. Use the address it redirects to as the base URL."}, "action": "Edit…"}, + "certificate": {"tone": "danger", "row": ["Certificate not trusted", "{when}"], "sheet": {"title": "Certificate not trusted", "desc": "{host}’s HTTPS certificate didn’t verify, so the key wasn’t sent."}, "action": "Test"}, + "unreachable": {"tone": "danger", "row": ["Unreachable", "{when}"], "sheet": {"title": "Couldn’t reach {host}", "desc": "The address didn’t resolve or didn’t answer. Check it, and this server’s network."}, "action": "Test"}, + "timeout": {"tone": "danger", "row": ["Timed out", "{when}"], "sheet": {"title": "Timed out", "desc": "{host} didn’t answer within 15 seconds."}, "action": "Test"}, + }, + untested: { + "tone": "muted", + "attention": false, + "row": ["Not tested yet", ""], + "sheet": {"title": "Not tested yet", "desc": "Test it once to know agents can use it."}, + "action": "Test", + }, + testing: { + "tone": "muted", + "row": ["Testing…", ""], + "sheet": {"title": "Testing…", "desc": ""}, + }, + }; + const credentialState = (profile, { busy = false, now = Date.now() } = {}) => { + const result = profile.test, code = busy ? 'testing' : profile.type === 'ssh' && !profile.hostKey ? 'host-key-untrusted' : result?.code || 'untested'; + const source = CREDENTIAL_RESULTS[code] || CREDENTIAL_RESULTS[profile.type]?.[code] || CREDENTIAL_RESULTS.untested; + let host = profile.host || profile.baseUrl || ''; + if (profile.type === 'api') { try { host = new URL(profile.baseUrl).host; } catch {} } + const values = { id: profile.id, host, port: profile.port, status: result?.detail?.status, user: profile.username || result?.detail?.user, + when: result?.at ? relTime(result.at, now) : '', ms: result?.ms || 0, line: result?.detail?.line || 'The connection did not complete.', + path: profile.testPath || 'the base URL', secretKind: profile.authType === 'bearer' ? 'token' : profile.type === 'ssh' ? 'password' : 'key' }; + const fill = text => String(text || '').replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ''); + return { code, tone: source.tone, label: fill(source.row[0]), sub: fill(source.row[1]), title: fill(source.sheet.title), desc: fill(source.sheet.desc), + action: source.action || 'Test', at: result?.at || null, icon: busy ? null : source.tone === 'ok' ? 'circle-check' : source.tone === 'warn' ? 'triangle-alert' : source.tone === 'danger' ? 'circle-x' : 'info' }; + }; + const credentialProblem = code => { + const text = CREDENTIAL_RESULTS.ssh[code]?.row[0] || CREDENTIAL_RESULTS.api[code]?.row[0] || 'Connection needs attention'; + return code === 'http-error' ? 'The API request failed' : text.split(' · HTTP')[0]; + }; + // ------------------------------------------------------------- activity -- /** Work running now, queued behind it, and what finished recently. */ const activity = (status, ui, now) => { @@ -1449,6 +1507,7 @@ const T3Model = (() => { access: warn(!s.publicUrl && s.server && connectView(s).state !== 'on' && connectView(s).state !== 'linking' ? 1 : 0), agents: warn(agents.filter((a) => a.attention).length) || running(busy(agents)), instructions: null, + credentials: warn(s.credentials?.attention?.length || 0), mcp: warn((s.context?.mcp?.providers || []).filter(p => p.supported && s.harnesses?.some(a => a.id === p.provider && a.installed)).reduce((n, p) => n + (p.diverged?.length || 0) + (p.error ? 1 : 0), 0)), toolchains: warn(tools.filter((t) => t.state === 'failed').length) || running(busy(tools)), // A source control CLI someone installed and has not signed in is as @@ -1642,6 +1701,10 @@ const T3Model = (() => { add(item.id === 'runtime' ? 'Updates' : 'Server', fixes[item.id], item.icon, item.action.cmd === 'goto' ? { cmd: 'goto', route: item.action.route } : { cmd: item.action.cmd, id: item.action.id }, { attention: true }); } + for (const item of s.credentials?.attention || []) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.id || '') || item.id.length > 64) continue; + add('Credentials', item.id, 'key-round', { cmd: 'goto', route: 'credentials/' + item.id }, { attention: true, meta: { text: credentialProblem(item.code), dot: String(item.code || '').startsWith('host-key') ? 'warn' : 'danger' }, keywords: 'credential ssh api key password token secret ' + item.id }); + } add('MCP servers', 'Add MCP server…', 'plug', { cmd: 'mcp.add' }); add('MCP servers', 'Rescan MCP servers', 'refresh-cw', { cmd: 'mcp.refresh' }); for (const server of s.context?.mcp?.shared?.servers || []) add('MCP servers', server.name, 'plug', { cmd: 'mcp.view', id: server.name }); @@ -2026,7 +2089,7 @@ const T3Model = (() => { agentRow, agentRows, toolRow, toolchainRow, toolchainRows, sourceControlRows, managedSourceControlRows, packageRows, portRows, looksLikeDatabase, monogram, managedOn, isVersionSpec, isToolSpec, specPrefix, SPEC_SAMPLES, searchRegistry, resolveRelease, matchReleases, releaseIndex, heldRelease, versionOf, toolName, SUGGESTED_TOOLS, deviceRows, linkRows, deviceKind, deviceName, - readiness, readySummary, needsYou, containerItems, activity, setupBanner, setupFailures, + credentialState, credentialProblem, readiness, readySummary, needsYou, containerItems, activity, setupBanner, setupFailures, publicUrlSource, hereOffer, connectView, contextView, contextAgentsMdRow, contextProviderRows, mcpView, diff --git a/docker/setup/client/routes.js b/docker/setup/client/routes.js index 15a4603..c8c35ed 100644 --- a/docker/setup/client/routes.js +++ b/docker/setup/client/routes.js @@ -30,9 +30,12 @@ const T3Routes = (() => { { route: 'mcp', title: 'MCP servers', icon: 'plug', key: 'm', group: 'agents', summary: 'Servers your agents can start or connect to.', doc: 'mcp-servers.md', palette: { meta: 'Setup · shared definitions and provider configuration', terms: ['mcp tools servers'] } }, + { route: 'credentials', title: 'Credentials', icon: 'key-round', key: 'c', group: 'agents', + summary: 'SSH and API logins your agents use by name. Secrets stay out of chats and skills.', doc: 'credentials.md', palette: { meta: 'Setup · SSH and API logins agents use by name', terms: ['ssh', 'api key', 'token', 'password', 'secret', 'server credentials'] } }, { route: 'files', title: 'Files', icon: 'folder', key: 'f', group: 'tools', summary: 'Your projects, your home folder and what fills the disk.', doc: 'files.md', palette: { meta: 'Setup · projects, the home folder and disk use', terms: ['upload download', 'workspace'] } }, + { route: 'toolchains', title: 'Toolchains', icon: 'wrench', key: 't', group: 'tools', summary: 'Languages and tools on every agent’s path, at exact versions.', doc: 'toolchains.md', palette: { meta: 'Setup · Go, Rust, Bun, Deno, uv and any mise tool' } }, @@ -94,7 +97,7 @@ const T3Routes = (() => { /** The page an address names, through its aliases; null when it names none. */ const resolve = (name) => (BY_ROUTE.has(name) ? name : ALIASES[name] || null); /** The tab that holds a page on a phone. */ - const destination = name => resolve(name) || DESTINATIONS.find(item => item.hash === name)?.hash || null; + const destination = name => resolve(name) || DESTINATIONS.find(item => item.hash === name)?.hash || (typeof name === 'string' && /^credentials\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) && name.slice(12).length <= 64 ? name : null); const tabOf = (route) => (TABS.some((tab) => tab.route === route) ? route : 'more'); /** Pages in their sidebar groups, each group with its label. */ const grouped = () => GROUPS.map((group) => ({ ...group, pages: PAGES.filter((p) => p.group === group.id) })); diff --git a/docker/setup/console.css b/docker/setup/console.css index b6885ba..a2a6903 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -3183,7 +3183,6 @@ body[data-console-route="files"]:has(.tc-layer) .tc-toaster { visibility: hidden .tc-files-zip .tc-opt small { overflow-wrap: anywhere; } .tc-files-archive-contents span:not(.tc-small) { max-width: 100%; min-width: 0; } .tc-files-archive-contents code { min-width: 0; overflow-wrap: anywhere; } - /* The Settings breadcrumb stays with the page while its content scrolls. */ .tc-console-topbar { flex: none; height: var(--topbar); min-height: var(--topbar); border-bottom: 1px solid var(--border); padding-inline: var(--space-4); background: var(--bg); } .tc-console-topbar .tc-crumbs { gap: var(--space-3); } @@ -3358,3 +3357,259 @@ body[data-console-route="files"]:has(.tc-layer) .tc-toaster { visibility: hidden @container files-card (max-width: 720px) { #files-browser .tc-files-selection-actions :is([data-cmd="files.compress"], [data-cmd="files.move"], [data-cmd="files.details"]) { display: none; } } +/* Credentials uses the shared list, sheet, wizard, dialog and menu components. + Layout responds to the frame's data-compact density, including Settings. */ + +/* ------------------------------------------------------------- the list --- */ +.tc-cred-list > .tc-row { grid-template-columns: auto minmax(0, 1fr) 14.5rem 8rem; padding-block: 0.875rem; align-items: center; } +.tc-cred-list > .tc-row > .tc-tile { align-self: start; margin-top: 2px; } +.tc-cred-name { display: flex; align-items: center; gap: 0.5rem; min-width: 0; } +.tc-cred-name > button { appearance: none; border: 0; padding: 0; background: none; font: inherit; font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; color: var(--fg); + cursor: pointer; text-decoration: none; } +.tc-cred-name > button:hover { text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; } +.tc-cred-name .tc-badge, .tc-cred-prow .tc-prow-name .tc-badge { height: 1.125rem; font-size: 0.6875rem; font-weight: 500; color: var(--fg-muted); background: color-mix(in srgb, var(--fg) 5%, transparent); } +.tc-cred-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.tc-cred-main > .tc-keys { margin-top: 1px; } +.tc-cred-state { display: flex; flex-direction: column; gap: 1px; min-width: 0; font-size: 0.75rem; line-height: 1.125rem; } +.tc-cred-state-line { display: flex; align-items: center; gap: 0.375rem; color: var(--fg); font-weight: 500; min-width: 0; } +.tc-cred-state-line > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tc-cred-state-line[data-tone="warn"] { color: var(--warn-text); } +.tc-cred-state-line[data-tone="danger"] { color: var(--danger-text); } +.tc-cred-state-line[data-tone="muted"] { color: var(--fg-muted); font-weight: 400; } +.tc-cred-state-sub { color: var(--fg-muted); padding-left: calc(0.375rem + 0.375rem); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tc-cred-used { display: inline-flex; align-items: center; gap: 0.3125rem; font-size: 0.75rem; line-height: 1.125rem; color: var(--fg-muted); min-width: 0; } +.tc-cred-used > .tc-icon { width: 0.75rem; height: 0.75rem; flex: none; } +.tc-cred-used b { font-weight: 500; color: var(--fg); } +.tc-cred-used > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tc-cred-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; min-width: 0; } +.tc-cred-meta > .tc-keys { margin-top: 0; } +.tc-cred-list .tc-row-actions .tc-btn--sm { min-width: 3.75rem; justify-content: center; } +.tc-cred-list .tc-row-actions .tc-btn--icon { min-width: 0; } +.tc-cred-row[data-tone="warn"] { background: color-mix(in srgb, var(--warn) 4%, transparent); } + +/* -------------------------------------------------------- empty state --- */ +.tc-cred-how { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.tc-cred-how > div { display: flex; flex-direction: column; gap: 0.375rem; padding: 1rem 1.125rem 1.125rem; min-width: 0; } +.tc-cred-how > div + div { border-left: 1px solid var(--hairline); } +.tc-cred-how-num { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; line-height: 1.25rem; font-weight: 500; color: var(--fg); } +.tc-cred-how-num > span { width: 1.25rem; height: 1.25rem; border-radius: 50%; display: grid; place-items: center; background: var(--selected); font-size: 0.6875rem; font-weight: 600; } +.tc-cred-how p { margin: 0; font-size: 0.75rem; line-height: 1.125rem; color: var(--fg-muted); } +.tc-cred-how code { font: 400 0.6875rem/1rem var(--font-mono); color: var(--fg); background: var(--surface-sunken); border: 1px solid var(--hairline); border-radius: var(--radius-sm); + padding: 0.125rem 0.375rem; align-self: flex-start; } + +/* ------------------------------------------------------------ inspector --- */ +.tc-cred-banner { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0.75rem 0.75rem 0.875rem; border-radius: var(--radius-xl); border: 1px solid var(--border); } +.tc-cred-banner > .tc-icon { flex: none; } +.tc-cred-banner > div { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; } +.tc-cred-banner strong { font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; } +.tc-cred-banner span { font-size: 0.75rem; line-height: 1.125rem; color: var(--fg-muted); } +.tc-cred-banner code { font-size: 0.75rem; color: var(--fg); } +.tc-cred-banner[data-tone="ok"] { border-color: color-mix(in srgb, var(--ok) 30%, transparent); background: color-mix(in srgb, var(--ok) 5%, transparent); } +.tc-cred-banner[data-tone="ok"] > .tc-icon { color: var(--ok); } +.tc-cred-banner[data-tone="danger"] { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: var(--danger-soft); } +.tc-cred-banner[data-tone="danger"] > .tc-icon { color: var(--danger); } +.tc-cred-banner[data-tone="danger"] strong { color: var(--danger-text); } +.tc-cred-banner[data-tone="warn"] { border-color: color-mix(in srgb, var(--warn) 32%, transparent); background: var(--warn-soft); } +.tc-cred-banner[data-tone="warn"] > .tc-icon { color: var(--warn); } +.tc-cred-banner[data-tone="warn"] strong { color: var(--warn-text); } + +.tc-pathlist.tc-cred-facts { grid-template-columns: 6.5rem minmax(0, 1fr); } +.tc-cred-facts dd .tc-spacer { flex: 1; } +.tc-cred-facts dd .tc-btn { margin: -0.25rem -0.375rem -0.25rem 0; } +.tc-cred-saved { display: inline-flex; align-items: center; gap: 0.375rem; color: var(--fg); } +.tc-cred-saved > .tc-icon { width: 0.875rem; height: 0.875rem; color: var(--fg-muted); } +.tc-cred-saved small { font-size: 0.75rem; color: var(--fg-muted); } +.tc-cred-fp { font: 400 0.75rem/1.125rem var(--font-mono); color: var(--fg); overflow-wrap: anywhere; } +.tc-cred-fp small { font-family: var(--font-sans); color: var(--fg-muted); margin-left: 0.375rem; } + +/* A command or a request, as agents will run it. */ +.tc-cred-cmds { list-style: none; margin: 0; padding: 0; } +.tc-cred-cmds li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; padding: 0.5rem 0.5rem 0.5rem 0.875rem; } +.tc-cred-cmds li + li { border-top: 1px solid var(--hairline); } +.tc-cred-cmds code { font: 400 0.75rem/1.125rem var(--font-mono); color: var(--fg); overflow-wrap: anywhere; } +.tc-cred-cmds code i { font-style: normal; color: var(--fg-subtle); } +.tc-cred-snippet { position: relative; margin: 0; padding: 0.75rem 0.875rem; border-radius: var(--radius-lg); border: 1px solid var(--hairline); background: var(--surface-sunken); + font: 400 0.75rem/1.25rem var(--font-mono); color: var(--fg); white-space: pre-wrap; overflow-wrap: anywhere; } +.tc-cred-snippet i { font-style: normal; color: var(--fg-subtle); } +.tc-cred-snippet b { font-weight: 400; color: var(--accent-text); } +.tc-cred-snippet-head { display: flex; align-items: center; gap: 0.5rem; } +.tc-cred-request { margin: 0; padding: 0.625rem 0.875rem; border-radius: var(--radius-lg); border: 1px solid var(--hairline); background: var(--surface-sunken); + font: 400 0.75rem/1.25rem var(--font-mono); color: var(--fg-muted); white-space: pre-wrap; overflow-wrap: anywhere; } +.tc-cred-request b { font-weight: 600; color: var(--fg); } +.tc-cred-request u { text-decoration: none; color: var(--fg); } +.tc-cred-request em { font-style: normal; color: var(--accent-text); } +.tc-cred-skill { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.625rem; padding: 0.5625rem 0.875rem; font-size: 0.8125rem; line-height: 1.25rem; } +.tc-cred-skill + .tc-cred-skill { border-top: 1px solid var(--hairline); } +.tc-cred-skill .tc-tile { width: 1.5rem; height: 1.5rem; } +.tc-cred-skill .tc-tile .tc-icon { width: 0.8125rem; height: 0.8125rem; } +.tc-cred-skill strong { font-weight: 500; } +.tc-cred-skill small { display: block; font-size: 0.75rem; line-height: 1rem; color: var(--fg-muted); } +.tc-cred-skill code { font-size: 0.6875rem; color: var(--fg-muted); } + +/* ---------------------------------------------------------------- editor --- */ +/* Desktop size only: in compact mode main's rules make it a 94dvh bottom sheet. */ +:where(:root:not([data-compact])) .tc-wizard.tc-cred-editor { width: 56rem; height: 44rem; } +.tc-cred-editor .tc-wizard-grid { grid-template-columns: minmax(0, 1fr) 20.5rem; } +.tc-wizard-rail .tc-cred-cmds code { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tc-wizard-rail .tc-cred-fp small { display: block; margin: 2px 0 0; } +.tc-cred-editor .tc-wizard-main { gap: 1.125rem; } +.tc-cred-editor .tc-form { display: flex; flex-direction: column; gap: 1rem; } +.tc-cred-editor .tc-form-field { display: flex; flex-direction: column; gap: 0.375rem; } +.tc-cred-editor .tc-form-field > .tc-hint { margin: 0; } +.tc-cred-pair { display: grid; grid-template-columns: minmax(0, 1fr) 6.5rem; gap: 0.75rem; } +.tc-cred-pair--even { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.tc-cred-type .tc-seg { width: 100%; } +.tc-cred-type .tc-seg button { flex: 1 1 0; justify-content: center; display: inline-flex; align-items: center; gap: 0.375rem; height: 2rem; font-size: 0.8125rem; } +.tc-cred-type .tc-seg button .tc-icon { width: 0.875rem; height: 0.875rem; } +.tc-cred-auth .tc-seg { width: 100%; } +.tc-cred-auth .tc-seg button { flex: 1 1 0; justify-content: center; display: inline-flex; align-items: center; height: 1.75rem; font-size: 0.8125rem; } +.tc-cred-locked { display: flex; align-items: center; gap: 0.5rem; height: 2.25rem; padding: 0 0.75rem; border-radius: var(--radius-md); border: 1px dashed var(--border); + background: var(--surface-sunken); font: 400 0.8125rem/1 var(--font-mono); color: var(--fg); } +.tc-cred-locked > .tc-icon { width: 0.875rem; height: 0.875rem; color: var(--fg-muted); } +.tc-cred-locked > span:last-child { margin-left: auto; font-family: var(--font-sans); font-size: 0.75rem; color: var(--fg-muted); } +.tc-cred-secret { display: flex; align-items: center; gap: 0.5rem; min-height: 2.25rem; padding: 0.25rem 0.25rem 0.25rem 0.75rem; border-radius: var(--radius-md); border: 1px solid var(--border); + background: var(--surface); font-size: 0.8125rem; } +.tc-cred-secret > .tc-icon { width: 0.875rem; height: 0.875rem; color: var(--ok); flex: none; } +.tc-cred-secret > span { flex: 1; min-width: 0; color: var(--fg); } +.tc-cred-secret small { color: var(--fg-muted); font-size: 0.75rem; } + +/* The rail: what agents will run, and the test, as the person types. */ +.tc-cred-rail-block { display: flex; flex-direction: column; gap: 0.5rem; } +.tc-cred-rail-block .tc-insp-label { font-size: 0.8125rem; min-height: 0; } +.tc-cred-test { display: flex; flex-direction: column; gap: 0.625rem; padding: 0.75rem; border-radius: var(--radius-lg); border: 1px solid var(--border); background: var(--surface); } +.tc-cred-test-line { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; line-height: 1.25rem; font-weight: 500; } +.tc-cred-test-line > .tc-icon { width: 1rem; height: 1rem; color: var(--ok); flex: none; } +.tc-cred-test-line small { margin-left: auto; font-weight: 400; font-size: 0.75rem; color: var(--fg-muted); } +.tc-cred-hostkey { display: flex; flex-direction: column; gap: 0.375rem; padding-top: 0.625rem; border-top: 1px solid var(--hairline); font-size: 0.75rem; line-height: 1.125rem; color: var(--fg-muted); } +.tc-cred-hostkey strong { color: var(--fg); font-weight: 500; font-size: 0.75rem; } +.tc-cred-hostkey code { display: block; font: 400 0.6875rem/1rem var(--font-mono); color: var(--fg); background: var(--surface-sunken); border: 1px solid var(--hairline); border-radius: var(--radius-sm); padding: 0.25rem 0.375rem; overflow-wrap: anywhere; } +.tc-cred-testpath { display: flex; align-items: center; gap: 0.5rem; } +.tc-cred-testpath > span { font: 600 0.75rem/1 var(--font-mono); color: var(--fg); } +.tc-cred-testpath .tc-input { height: 2rem; flex: 1; min-width: 0; } +.tc-cred-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; color: var(--fg); } +.tc-cred-conflict .tc-notice-body { gap: 0.375rem; } +.tc-cred-conflict-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; } +.tc-cred-conflict-actions .tc-btn { margin: 0 !important; } +.tc-cred-diffline { font: 400 0.75rem/1.125rem var(--font-mono); } +.tc-cred-diffline del { color: var(--fg-muted); } +.tc-cred-diffline ins { text-decoration: none; color: var(--warn-text); font-weight: 600; } + +/* ---------------------------------------------------------- dialogs --- */ +:where(:root:not([data-compact])) .tc-dialog.tc-cred-dialog { width: min(30rem, calc(100% - 32px)); } +.tc-cred-keys { list-style: none; margin: 0.25rem 0 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } +.tc-cred-keys li { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr); gap: 0.625rem; padding: 0.625rem 0.75rem; font-size: 0.75rem; line-height: 1.125rem; align-items: baseline; } +.tc-cred-keys li + li { border-top: 1px solid var(--hairline); } +.tc-cred-keys li > span { color: var(--fg-muted); } +.tc-cred-keys li[data-tone="warn"] { background: var(--warn-soft); } +.tc-cred-keys li[data-tone="warn"] > span { color: var(--warn-text); font-weight: 500; } +.tc-cred-keys code { font-size: 0.75rem; color: var(--fg); overflow-wrap: anywhere; } + +/* ------------------------------------------------- compact: the list --- */ +/* Rows become tc-prow buttons (.tc-cred-prow), MCP's phone rows, with a tile. */ +:where(:root[data-compact]) .tc-cred-toolbar { display: flex; gap: 0.5rem; align-items: center; } +:where(:root[data-compact]) .tc-cred-toolbar .tc-sk-search { flex: 1; min-width: 0; } +:where(:root[data-compact]) .tc-cred-prow { grid-template-columns: auto minmax(0, 1fr) auto; column-gap: 0.75rem; } +:where(:root[data-compact]) .tc-cred-prow > .tc-tile { grid-row: 1 / span 3; align-self: start; margin-top: 2px; } +:where(:root[data-compact]) .tc-cred-prow > .tc-icon { grid-column: 3; } +:where(:root[data-compact]) .tc-cred-prow > .tc-prow-name, :where(:root[data-compact]) .tc-cred-prow > .tc-mcp-detail, :where(:root[data-compact]) .tc-cred-prow > .tc-cred-state-line { grid-column: 2; } +:where(:root[data-compact]) .tc-cred-prow .tc-prow-name { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9375rem; } +:where(:root[data-compact]) .tc-cred-prow .tc-cred-state-line { font-size: 0.75rem; line-height: 1.125rem; margin-top: 2px; } +:where(:root[data-compact]) .tc-cred-state-line small { font-weight: 400; color: var(--fg-muted); } + +/* --------------------------------------------------------------- compact --- */ +/* main's convention (#71). The details sheet and the editor are the console's + bottom sheets in compact mode already (tc-skill-inspector, tc-wizard); only + what is particular to this page is here. */ +:where(:root[data-compact]) .tc-cred-inspector .tc-insp-head > .tc-btn:not(.tc-btn--icon) { display: none; } +:where(:root[data-compact]) .tc-cred-banner { align-items: flex-start; } +:where(:root[data-compact]) .tc-pathlist.tc-cred-facts { grid-template-columns: 5.5rem minmax(0, 1fr); } +:where(:root[data-compact]) .tc-cred-saved small { display: none; } +.tc-root[data-density="touch"] .tc-cred-editor .tc-cred-type .tc-seg button { height: 2.5rem; font-size: 0.9375rem; } + +/* Credentials owns its lane, including when Settings gives it less room than + the browser viewport. Long names and metadata never steal action space. */ +.tc-credentials-library { margin-top: -0.75rem; display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; container: credentials / inline-size; } +.tc-cred-toolbar .tc-sk-search { min-width: 0; } +.tc-cred-toolbar .tc-btn[aria-pressed="true"] { background: var(--warn-soft); color: var(--warn-text); border-color: color-mix(in srgb, var(--warn) 40%, transparent); } +.tc-cred-toolbar .tc-btn > .tc-dot { margin-right: 0.125rem; } +.tc-cred-name > button { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } +.tc-cred-name > .tc-badge { flex: none; } +.tc-cred-main .tc-mcp-detail > code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: inherit; } +.tc-cred-meta > .tc-keys { max-width: 100%; min-width: 0; } +.tc-cred-meta .tc-key { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +.tc-cred-meta .tc-key .tc-icon { flex: none; } +.tc-cred-meta > .tc-cred-used { flex: 1 1 10rem; } +.tc-cred-row[aria-busy="true"] .tc-cred-main { opacity: 0.65; } +.tc-cred-storage { align-items: flex-start; line-height: 1.125rem; } +.tc-cred-storage > .tc-icon { flex: none; margin-top: 1px; } +.tc-cred-how { border-top: 1px solid var(--hairline); } +.tc-cred-skeleton-line { height: 0.875rem; width: min(18rem, 70%); } +.tc-cred-usage-warning { display: flex; align-items: flex-start; gap: 0.375rem; } +.tc-cred-banner > .tc-cred-banner-actions { flex: none; flex-direction: row; gap: 0.375rem; flex-wrap: wrap; justify-content: flex-end; } +.tc-cred-inspector .tc-insp-head > .tc-btn:not(.tc-btn--icon) { flex: none; } +.tc-cred-inspector .tc-insp-body { gap: 1.75rem; } +.tc-cred-inspector .tc-insp-label > .tc-hint { text-align: right; font-size: 0.6875rem; } +.tc-cred-inspector .tc-cred-saved { flex-wrap: wrap; } +.tc-cred-inspector .tc-cred-facts dd > code { min-width: 0; overflow-wrap: anywhere; } +.tc-cred-editor .tc-cred-reveal { position: relative; display: flex; align-items: center; } +.tc-cred-reveal > .tc-input { padding-right: 2.5rem; } +.tc-cred-reveal > .tc-reveal { position: absolute; right: 0.375rem; } +.tc-cred-keep { align-self: flex-start; padding: 0; min-height: 1.25rem; } +.tc-cred-editor .tc-cred-hostkey--first { border-top: 0; padding-top: 0; } +.tc-cred-test-line[data-tone="danger"], .tc-cred-test-line[data-tone="danger"] > .tc-icon { color: var(--danger-text); } +.tc-cred-test-line[data-tone="warn"], .tc-cred-test-line[data-tone="warn"] > .tc-icon { color: var(--warn-text); } +.tc-cred-editor-error { flex: none; padding: 0.625rem 1.25rem; border-top: 1px solid var(--hairline); } +.tc-cred-editor .tc-wizard-rail-toggle > code { font-size: 0.75rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.tc-cred-editor .tc-wizard-rail-toggle > .tc-icon { flex: none; } +.tc-cred-inspector .tc-cred-request, .tc-cred-editor .tc-cred-request { max-width: 100%; } +@container credentials (max-width: 780px) { + .tc-cred-list > .tc-row { grid-template-columns: auto minmax(0, 1fr) 12rem 7.25rem; column-gap: 0.625rem; } + .tc-cred-meta { gap: 0.25rem 0.75rem; } +} +@container credentials (max-width: 660px) { + .tc-cred-list > .tc-row { grid-template-columns: auto minmax(0, 1fr) auto; } + .tc-cred-list > .tc-row > .tc-cred-state { grid-column: 2; grid-row: 2; margin-top: 0.25rem; } + .tc-cred-list > .tc-row > .tc-row-actions { grid-column: 3; grid-row: 1 / span 2; } +} +:where(:root[data-compact]) .tc-cred-toolbar .tc-sk-search { flex: 1 1 calc(100% - 3.5rem); order: 1; } +:where(:root[data-compact]) .tc-cred-toolbar > .tc-spacer { display: none; } +:where(:root[data-compact]) .tc-cred-toolbar .tc-cred-add { order: 2; flex: none; width: var(--touch); height: var(--touch); padding: 0; } +:where(:root[data-compact]) .tc-cred-add > span { display: none; } +:where(:root[data-compact]) .tc-cred-toolbar .tc-sk-attention { order: 3; } +:where(:root[data-compact]) .tc-cred-prow { width: 100%; appearance: none; border: 0; background: transparent; font: inherit; text-align: left; } +:where(:root[data-compact]) .tc-cred-prow + .tc-cred-prow { border-top: 1px solid var(--hairline); } +:where(:root[data-compact]) .tc-cred-prow .tc-prow-name { min-width: 0; overflow-wrap: anywhere; flex-wrap: wrap; gap: 0.25rem 0.5rem; } +:where(:root[data-compact]) .tc-cred-prow .tc-mcp-detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } +:where(:root[data-compact]) .tc-cred-prow .tc-cred-state-line > span:last-child { white-space: normal; } +:where(:root[data-compact]) .tc-cred-how { grid-template-columns: minmax(0, 1fr); } +:where(:root[data-compact]) .tc-cred-how > div + div { border-left: 0; border-top: 1px solid var(--hairline); } +:where(:root[data-compact]) .tc-cred-banner { flex-wrap: wrap; } +:where(:root[data-compact]) .tc-cred-banner > .tc-cred-banner-actions { width: 100%; padding-left: 1.75rem; justify-content: flex-start; } +:where(:root[data-compact]) .tc-cred-editor .tc-wizard-foot > .tc-wizard-keyboard { display: none; } +:where(:root[data-compact]) .tc-cred-editor .tc-wizard-grid { grid-template-columns: minmax(0, 1fr); } +:where(:root[data-compact]) .tc-cred-test .tc-cred-check { min-height: var(--touch); } +.tc-root[data-density="touch"] :is(.tc-cred-inspector, .tc-cred-editor, .tc-cred-dialog) .tc-btn { min-height: var(--touch); } +.tc-cred-skel { grid-template-columns: auto minmax(0, 1fr); min-height: 4.5rem; height: auto; padding: 1rem; } +/* The handoff puts the toolbar close to the head and leaves a full row gap + before the list. Only this feature owns the adjustment. */ +.tc-credentials-library:has(> .tc-cred-toolbar) { gap: 1.25rem; } +.tc-cred-state-line[data-tone="muted"] > .tc-dot { background: var(--fg-subtle); } +.tc-cred-hostkey > .tc-cred-fp { word-break: break-all; } +.tc-cred-hostkey + .tc-cred-test-line { padding-top: 0.625rem; border-top: 1px solid var(--hairline); } +.tc-cred-compare > summary { color: var(--fg-muted); cursor: pointer; min-height: 2.75rem; align-content: center; } +.tc-cred-compare > code { margin-top: 0.25rem; } +:where(:root:not([data-compact])) .tc-layer--sheet > .tc-cred-inspector { width: min(32.5rem, calc(100dvw - 1rem)); } +.tc-cred-facts .tc-cred-fp { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; } +.tc-cred-key-type { color: var(--fg-muted); font-size: 0.6875rem; } +.tc-cred-fingerprint { display: block; font-size: 0.6875rem; line-height: 1rem; word-break: break-all; } +.tc-cred-facts .tc-cred-fp small { display: block; margin: 0; } +.tc-cred-snippet-detail > summary { font-size: 0.75rem; color: var(--fg-muted); cursor: pointer; } +.tc-cred-snippet-detail > .tc-cred-snippet { margin-top: 0.5rem; } +:where(:root[data-compact]) .tc-cred-snippet-detail > summary { min-height: 2.75rem; align-content: center; } + +.tc-cred-inspector .tc-insp-title h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tc-cred-skill > div { min-width: 0; overflow-wrap: anywhere; } +.tc-cred-dialog .tc-dialog-title, .tc-cred-editor .tc-sheet-title { overflow-wrap: anywhere; } +.tc-cred-result { display: flex; flex-direction: column; gap: 1.25rem; } +.tc-cred-result > .tc-cred-banner-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; } +:where(:root[data-compact]) .tc-layer--sheet > .tc-cred-inspector { max-height: 84dvh; } diff --git a/docker/setup/credentials.mjs b/docker/setup/credentials.mjs new file mode 100644 index 0000000..9e90d94 --- /dev/null +++ b/docker/setup/credentials.mjs @@ -0,0 +1,192 @@ +// Secrets stay in an owner-only store. Revisions are random, independent tokens; +// testing and trust metadata never change a credential's revision. +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { createCredentialIO } from './credentials/io.mjs'; +import { createCredentialState, emptyState, cleanResult } from './credentials/state.mjs'; +import { createHostKeys, publicKey, keyFile } from './credentials/hostkeys.mjs'; +import { normalize } from './credentials/model.mjs'; +import { CredentialError, validId, object } from './credentials/errors.mjs'; +export { CredentialError, credentialFailure } from './credentials/errors.mjs'; +export { apiTarget } from './credentials/model.mjs'; + +export const credentialDirectory = (env = process.env) => path.join(env.T3CODE_HOME || path.join(env.HOME || '/home/t3', '.t3'), 'server-credentials'); +const revision = () => randomBytes(8).toString('hex'); +const stamp = value => typeof value === 'string' && value.length < 40 && Number.isFinite(Date.parse(value)); +const serialize = profiles => JSON.stringify({ version: 2, profiles }, null, 2) + '\n'; +const unreadable = () => new CredentialError('Saved credentials could not be read. Restore the credential file before making changes.', 500, 'store'); +const requireId = id => { if (!validId(id)) throw new CredentialError('Choose a valid credential name.'); }; + +export function createCredentialStore({ directory = credentialDirectory(), readOnly = false, home = process.env.HOME || '/home/t3', io = createCredentialIO({ directory, readOnly }), hostKeys = createHostKeys({ io, home }), clock = Date.now } = {}) { + const state = createCredentialState({ io }); + const now = () => new Date(clock()).toISOString(); + const commit = async (data, profiles, metadata, keys = new Map(), writeStore = true) => { + if (io.readOnly) throw new CredentialError('Helpers cannot change saved credentials.', 500, 'read-only'); + // In-memory recovery only: never make another plaintext credential backup. + const previousState = await io.read('state.json').catch(() => null); + const previousKeys = new Map(); + for (const id of keys.keys()) previousKeys.set(id, await io.read(keyFile(id), { maxBytes: 64 * 1024 }).catch(() => null)); + let attemptedStore = false; + try { + for (const [id, key] of keys) { if (key) await hostKeys.write(id, key); else await hostKeys.forget(id); } + await state.write(metadata); + if (writeStore) { attemptedStore = true; await io.write('credentials.json', serialize(profiles)); } + } catch (error) { + let restored = true; + if (attemptedStore) { + try { if (data.raw === null) await io.remove('credentials.json'); else await io.write('credentials.json', data.raw); } + catch { + try { const current = await io.read('credentials.json'); restored = (current?.text ?? null) === data.raw; } + catch { restored = false; } + } + } + if (restored) { + try { + for (const [id, key] of previousKeys) { if (key) await io.write(keyFile(id), key.text, { maxBytes: 64 * 1024, recover: true }); else await hostKeys.forget(id); } + if (previousState) await io.write('state.json', previousState.text, { recover: true }); else await io.remove('state.json'); + } catch { restored = false; } + } + if (!restored) { + // A failed rollback must block SSH rather than silently trusting a key. + for (const id of keys.keys()) await hostKeys.forget(id).catch(() => {}); + throw new CredentialError('The credential change could not be recovered. Check the state volume before trying again.', 500, 'store'); + } + throw error; + } + }; + const migrate = async (data, file) => { + if (io.readOnly) throw new CredentialError('Open Setup once to finish upgrading saved credentials.', 409, 'migration'); + const at = file.mtime, profiles = data.profiles.map(input => ({ ...normalize(input), createdAt: at, updatedAt: at, secretChangedAt: null, revision: revision() })); + const metadata = emptyState(), keys = new Map(); + for (const profile of profiles.filter(row => row.type === 'ssh')) { + let key; + key = await hostKeys.legacy(profile); + keys.set(profile.id, key || null); + if (key) metadata.hostKeys[profile.id] = hostKeys.metadata(key, true); + } + await commit({ raw: file.text }, profiles, metadata, keys); + return { profiles, raw: serialize(profiles) }; + }; + const read = async () => { + const file = await io.read('credentials.json'); + if (!file) return { profiles: [], raw: null }; + let data; + try { + data = JSON.parse(file.text); + if (!object(data) || ![1, 2].includes(data.version) || !Array.isArray(data.profiles) || data.profiles.length > 64) throw unreadable(); + if (new Set(data.profiles.map(profile => profile?.id)).size !== data.profiles.length) throw unreadable(); + if (data.version === 2) data.profiles = data.profiles.map(input => { + const profile = normalize(input); + if (![input.createdAt, input.updatedAt].every(stamp) || !(input.secretChangedAt === null || stamp(input.secretChangedAt)) || !/^[a-f0-9]{16}$/.test(input.revision || '')) throw unreadable(); + return { ...profile, createdAt: input.createdAt, updatedAt: input.updatedAt, secretChangedAt: input.secretChangedAt, revision: input.revision }; + }); + else data.profiles.forEach(input => normalize(input)); + } catch { throw unreadable(); } + if (data.version === 1) { + try { return await migrate(data, file); } + catch (error) { if (error.code === 'migration') throw error; throw unreadable(); } + } + return { profiles: data.profiles, raw: file.text }; + }; + const publicProfile = async (profile, metadata) => { + const { secret, ...visible } = profile; + let key = null; + if (profile.type === 'ssh') { try { key = await hostKeys.read(profile.id); } catch { /* Unsafe files cannot confer trust. */ } } + const saved = metadata.hostKeys[profile.id], test = metadata.tests[profile.id]; + return { ...visible, hasSecret: true, test: test && (!test.revision || test.revision === profile.revision) ? cleanResult(test, secret) : null, + hostKey: profile.type === 'ssh' && key ? { ...publicKey(key), trustedAt: saved?.fingerprint === key.fingerprint ? saved.trustedAt : key.trustedAt, carried: saved?.fingerprint === key.fingerprint && saved.carried === true } : null }; + }; + const current = async (data, id, expected) => { + requireId(id); + const profile = data.profiles.find(row => row.id === id); + if (!profile) throw new CredentialError('This credential was deleted in another window.', 409, 'gone'); + if (expected !== profile.revision) throw new CredentialError('This credential changed in another window.', 409, 'changed', { current: await publicProfile(profile, await state.read(data.profiles)) }); + return profile; + }; + const privateProfile = (data, id) => { + requireId(id); + const profile = data.profiles.find(row => row.id === id); + if (!profile) throw new CredentialError('Saved credential not found. Add it in Setup → Credentials.', 404, 'not-found'); + return profile; + }; + const store = { + io, hostKeys, + initialize: () => io.transaction(async () => { await read(); }), + list: () => io.transaction(async () => { const data = await read(), metadata = await state.read(data.profiles); return { credentials: await Promise.all(data.profiles.map(profile => publicProfile(profile, metadata))) }; }), + public: id => io.transaction(async () => { const data = await read(); return publicProfile(privateProfile(data, id), await state.read(data.profiles)); }), + draft: ({ draft, id }) => io.transaction(async () => { + const data = await read(), previous = id === undefined ? undefined : privateProfile(data, id), profile = normalize(draft, previous); + if (profile.type === 'ssh' && previous && profile.host === previous.host && profile.port === previous.port) { + const key = await hostKeys.read(id).catch(() => null); + if (key) return { ...profile, knownHostsFile: key.file, hostKeyType: key.type }; + } + return profile; + }), + save: ({ credential, revision: expected, create = false }) => io.transaction(async () => { + const data = await read(); + requireId(credential?.id); + const previous = data.profiles.find(row => row.id === credential.id); + if (create && previous) throw new CredentialError('That name is already taken. Choose another credential name.', 409, 'taken'); + if (!create) await current(data, credential.id, expected); + if (!previous && data.profiles.length >= 64) throw new CredentialError('Save at most 64 credentials.'); + const normalized = normalize(credential, previous), at = new Date(Math.max(clock(), previous ? Date.parse(previous.updatedAt) + 1 : 0)).toISOString(); + const saved = { ...normalized, createdAt: previous?.createdAt || at, updatedAt: at, secretChangedAt: !previous || normalized.secret !== previous.secret ? at : previous.secretChangedAt, revision: revision() }; + const metadata = await state.read(data.profiles), keys = new Map(); + const changed = !previous || JSON.stringify(normalize(previous)) !== JSON.stringify(normalized); + if (changed) delete metadata.tests[saved.id]; + else if (metadata.tests[saved.id]) metadata.tests[saved.id].revision = saved.revision; + if (saved.type === 'ssh' && (!previous || saved.host !== previous.host || saved.port !== previous.port)) { keys.set(saved.id, null); delete metadata.hostKeys[saved.id]; } + const profiles = [...data.profiles.filter(row => row.id !== saved.id), saved]; + await commit(data, profiles, metadata, keys); + return { credential: await publicProfile(saved, metadata) }; + }), + remove: ({ id, revision: expected }) => io.transaction(async () => { + const data = await read(), profile = await current(data, id, expected), metadata = await state.read(data.profiles); + delete metadata.tests[id]; delete metadata.hostKeys[id]; + await commit(data, data.profiles.filter(row => row.id !== id), metadata, profile.type === 'ssh' ? new Map([[id, null]]) : new Map()); + return {}; + }), + forUse: id => io.transaction(async () => { + const profile = privateProfile(await read(), id); + if (profile.type !== 'ssh') return profile; + let key = null; + try { key = await hostKeys.read(id); } catch { /* Helpers must stop at missing or unsafe trust. */ } + return { ...profile, knownHostsFile: key?.file || null, hostKeyType: key?.type || null, hostFingerprint: key?.fingerprint || null }; + }), + trust: ({ id, revision: expected, key }) => io.transaction(async () => { + const data = await read(), profile = await current(data, id, expected); + if (profile.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + const metadata = await state.read(data.profiles); + metadata.hostKeys[id] = hostKeys.metadata(key); delete metadata.tests[id]; + await commit(data, data.profiles, metadata, new Map([[id, key]]), false); + return publicProfile(profile, metadata); + }), + forget: id => io.transaction(async () => { + const data = await read(), profile = privateProfile(data, id); + if (profile.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + const metadata = await state.read(data.profiles); + delete metadata.hostKeys[id]; delete metadata.tests[id]; + await commit(data, data.profiles, metadata, new Map([[id, null]]), false); + return publicProfile(profile, metadata); + }), + recordTest: ({ id, revision: expected, hostFingerprint, result }) => io.transaction(async () => { + const data = await read(), profile = data.profiles.find(row => row.id === id); + if (!profile || profile.revision !== expected) return false; + if (profile.type === 'ssh') { const key = await hostKeys.read(id).catch(() => null); if ((key?.fingerprint || null) !== hostFingerprint) return false; } + const metadata = await state.read(data.profiles), previous = metadata.tests[id], cleaned = cleanResult(result, profile.secret); + if (!cleaned) throw new CredentialError('The connection test returned an invalid result.', 500); + metadata.tests[id] = { ...cleaned, at: now(), revision: expected, consecutiveTimeouts: result.code === 'timeout' ? (previous?.consecutiveTimeouts || 0) + 1 : 0 }; + await state.write(metadata); return true; + }), + summary: () => io.transaction(async () => { + const data = await read(), metadata = await state.read(data.profiles), credentials = await Promise.all(data.profiles.map(profile => publicProfile(profile, metadata))); + return { count: credentials.length, attention: credentials.flatMap(profile => { + let code = profile.test?.ok === false ? profile.test.code : null; + if (code === 'timeout' && (metadata.tests[profile.id]?.consecutiveTimeouts || 0) < 2) code = null; + if (profile.type === 'ssh' && !profile.hostKey) code = 'host-key-untrusted'; + return code ? [{ id: profile.id, code }] : []; + }) }; + }), + }; + return store; +} diff --git a/docker/setup/credentials/askpass.sh b/docker/setup/credentials/askpass.sh new file mode 100755 index 0000000..047633e --- /dev/null +++ b/docker/setup/credentials/askpass.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Only ssh reads this output; it never passes through the helper's stdout. +printf '%s\n' "$T3_CREDENTIAL_PASSWORD" diff --git a/docker/setup/credentials/cli.mjs b/docker/setup/credentials/cli.mjs new file mode 100644 index 0000000..0f57e4c --- /dev/null +++ b/docker/setup/credentials/cli.mjs @@ -0,0 +1,47 @@ +import { createCredentialStore, credentialFailure, CredentialError } from '../credentials.mjs'; +import { sshRequest, apiRequest } from './run.mjs'; + +const USAGE = `Use saved credentials without putting their values in commands: + t3-ssh CONNECTION [--] COMMAND [ARGUMENT...] + t3-api CONNECTION METHOD RELATIVE_PATH [--stdin] + +Manage connections in Setup → Credentials. +For JSON request bodies, use --stdin and pipe the body into t3-api. +Examples: + t3-ssh build-server -- uptime + t3-api project-api GET projects/demo-project + printf '%s' '{"name":"demo-project"}' | t3-api project-api POST projects --stdin +`; + +async function main() { + const [kind, ...args] = process.argv.slice(2); + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { process.stdout.write(USAGE); return; } + const id = args.shift(), store = createCredentialStore({ readOnly: true }); + if (!['ssh', 'api'].includes(kind)) throw new CredentialError('Use t3-ssh or t3-api.'); + if (kind === 'ssh') { + if (args[0] === '--') args.shift(); + if (!args.length) throw new CredentialError('Supply a remote command after the connection ID.'); + process.exitCode = await sshRequest(await store.forUse(id), args); + } else { + const [method, relative, option, ...extra] = args; + if (!method || relative === undefined || extra.length || option !== undefined && option !== '--stdin') throw new CredentialError('Use t3-api CONNECTION METHOD RELATIVE_PATH [--stdin].'); + let body; + if (option === '--stdin') { + if (['GET', 'HEAD'].includes(method)) throw new CredentialError('GET and HEAD requests cannot have a body.'); + const chunks = []; let length = 0; + for await (const chunk of process.stdin) { + length += chunk.length; + if (length > 1024 * 1024) throw new CredentialError('The JSON request body exceeds 1 MiB.'); + chunks.push(chunk); + } + body = Buffer.concat(chunks).toString('utf8'); + try { JSON.parse(body); } catch { throw new CredentialError('Supply a valid JSON request body on stdin.'); } + } + process.exitCode = await apiRequest(await store.forUse(id), method, relative, { body }); + } +} + +main().catch(error => { + process.stderr.write(credentialFailure(error).body.error + '\n'); + process.exitCode = 1; +}); diff --git a/docker/setup/credentials/diagnostics.mjs b/docker/setup/credentials/diagnostics.mjs new file mode 100644 index 0000000..97b1400 --- /dev/null +++ b/docker/setup/credentials/diagnostics.mjs @@ -0,0 +1,41 @@ +// OpenSSH closes inherited descriptors. A private FIFO separates its own +// diagnostics from a remote command's stderr without retaining a raw log. +import * as fs from 'node:fs/promises'; +import { constants } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execute } from './hostkeys.mjs'; +import { outputFilter } from './redact.mjs'; + +export async function createSshDiagnostics(secret, write = () => {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 't3-ssh-test-')); + const file = path.join(directory, 'diagnostics'); + let handle, ended = false, text = ''; + try { + await fs.chmod(directory, 0o700); + const answer = await execute('/usr/bin/mkfifo', ['-m', '600', '--', file]); + if (answer.code !== 0) throw new Error('Could not prepare SSH diagnostics'); + handle = await fs.open(file, constants.O_RDWR | constants.O_NONBLOCK); + const filter = outputFilter(secret, value => { text = (text + value).slice(-65536); write(value); }); + const reading = (async () => { + const buffer = Buffer.alloc(8192), decoder = new TextDecoder(); + try { + while (true) { + let length = 0; + try { length = (await handle.read(buffer, 0, buffer.length, null)).bytesRead; } + catch (error) { if (error.code !== 'EAGAIN') throw error; } + if (length) filter.write(decoder.decode(buffer.subarray(0, length), { stream: true })); + else if (ended) break; + else await new Promise(resolve => setTimeout(resolve, 10)); + } + } finally { filter.write(decoder.decode()); filter.end(); } + })(); + // Prevent an unhandled rejection while the child is still running. + void reading.catch(() => {}); + return { file, async close() { + ended = true; + try { await reading; return text; } + finally { await handle.close(); await fs.rm(directory, { force: true, recursive: true }); } + } }; + } catch (error) { await handle?.close(); await fs.rm(directory, { force: true, recursive: true }); throw error; } +} diff --git a/docker/setup/credentials/errors.mjs b/docker/setup/credentials/errors.mjs new file mode 100644 index 0000000..7ba84d4 --- /dev/null +++ b/docker/setup/credentials/errors.mjs @@ -0,0 +1,11 @@ +export class CredentialError extends Error { + constructor(message, status = 400, code = 'invalid', extra = {}) { + super(message); this.status = status; this.code = code; this.extra = extra; + } +} +export const credentialFailure = error => error instanceof CredentialError + ? { status: error.status, body: { ok: false, error: error.message, code: error.code, ...error.extra } } + : { status: 500, body: { ok: false, code: 'store', error: 'Could not access saved credentials. Check the state volume and its permissions.' } }; +export const validId = value => typeof value === 'string' && value.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value); +export const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +export const validText = (value, max) => typeof value === 'string' && value.isWellFormed() && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value); diff --git a/docker/setup/credentials/hostkeys.mjs b/docker/setup/credentials/hostkeys.mjs new file mode 100644 index 0000000..19f6bfc --- /dev/null +++ b/docker/setup/credentials/hostkeys.mjs @@ -0,0 +1,92 @@ +import { execFile } from 'node:child_process'; +import { createConnection, isIP } from 'node:net'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { CredentialError, validId, validText } from './errors.mjs'; + +const TYPES = ['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-rsa']; +export const publicKey = key => key ? { type: key.type, fingerprint: key.fingerprint } : null; +export const keyFile = id => { if (!validId(id)) throw new CredentialError('Choose a valid credential name.'); return 'known_hosts/' + id; }; +export const execute = (program, args, { input, signal, timeout = 5000 } = {}) => new Promise((resolve, reject) => { + const child = execFile(program, args, { encoding: 'utf8', maxBuffer: 64 * 1024, timeout, signal, killSignal: 'SIGKILL' }, (error, stdout, stderr) => { + if (error?.code === 'ENOENT' || error?.code === 'ABORT_ERR' || error?.killed) return reject(error); + resolve({ code: error?.code || 0, stdout, stderr }); + }); + child.stdin?.on('error', error => { if (error.code !== 'EPIPE') reject(error); }); + child.stdin?.end(input || ''); +}); + +// These are public-key fingerprints, never revisions derived from passwords. +export function parseKey(line) { + const fields = String(line).trim().split(/\s+/); + if (fields[0]?.startsWith('#') || fields[0]?.startsWith('@') || fields.length < 3) return null; + const [alias, type, encoded] = fields; + if (!TYPES.includes(type) || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length > 16384) return null; + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.length < 8 || bytes.readUInt32BE(0) !== Buffer.byteLength(type) || bytes.subarray(4, 4 + Buffer.byteLength(type)).toString() !== type) return null; + return { alias, type, key: encoded, fingerprint: 'SHA256:' + createHash('sha256').update(bytes).digest('base64').replace(/=+$/, '') }; +} +const choose = keys => [...keys].sort((a, b) => TYPES.indexOf(a.type) - TYPES.indexOf(b.type))[0] || null; +const row = (id, key) => `${id} ${key.type} ${key.key}\n`; +const result = (code, detail = {}) => ({ ok: false, code, detail }); + +export const reachHost = ({ host, port }, { signal, connect = createConnection } = {}) => new Promise(resolve => { + if (signal?.aborted) return resolve(result('timeout')); + let socket, settled = false; + const finish = value => { if (settled) return; settled = true; signal?.removeEventListener('abort', abort); socket?.destroy(); resolve(value); }; + const abort = () => finish(result('timeout')); + try { + socket = connect({ host, port }); + socket.once('connect', () => finish({ ok: true })); + socket.once('error', error => finish(result(error.code === 'ETIMEDOUT' ? 'timeout' : 'unreachable'))); + socket.setTimeout(5000, () => finish(result('timeout'))); + signal?.addEventListener('abort', abort, { once: true }); + } catch { finish(result('unreachable')); } +}); + +export function createHostKeys({ io, run = execute, reach = reachHost, home = process.env.HOME || '/home/t3', clock = () => new Date().toISOString() }) { + const validate = profile => { + if (!validText(profile?.host, 253) || !(isIP(profile.host) || /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(profile.host)) || !Number.isInteger(profile.port) || profile.port < 1 || profile.port > 65535) throw new CredentialError('Enter a valid host and port.'); + }; + const read = async id => { + const data = await io.read(keyFile(id), { maxBytes: 64 * 1024 }); + if (!data) return null; + const keys = data.text.split(/\r?\n/).filter(line => line.trim() && !line.startsWith('#')).map(parseKey); + if (keys.length !== 1 || !keys[0] || keys[0].alias !== id) return null; + return { ...keys[0], trustedAt: data.mtime, file: io.file(keyFile(id)) }; + }; + const scan = async (profile, { signal = AbortSignal.timeout(10000) } = {}) => { + validate(profile); + const reachable = await reach(profile, { signal }); + if (!reachable.ok) return reachable; + try { + const answer = await run('/usr/bin/ssh-keyscan', ['-T', '5', '-p', String(profile.port), '-t', 'ed25519,ecdsa,rsa', '--', profile.host], { signal, timeout: 5000 }); + const keys = answer.stdout.split(/\r?\n/).map(parseKey).filter(Boolean); + const key = choose(keys); + if (!key) return result('scan-failed'); + const checked = await run('/usr/bin/ssh-keygen', ['-lf', '-', '-E', 'sha256'], { input: `${key.type} ${key.key}\n`, signal, timeout: 2000 }); + if (checked.code !== 0 || !checked.stdout.split(/\s+/).includes(key.fingerprint)) return result('scan-failed'); + return { ok: true, key }; + } catch (error) { return result(signal.aborted || error?.killed || error?.code === 'ABORT_ERR' ? 'timeout' : 'scan-failed'); } + }; + const inspect = async (profile, options) => { + const scanned = await scan(profile, options); + if (!scanned.ok) return scanned; + let trusted = null; + if (profile.id) { try { trusted = await read(profile.id); } catch { /* unsafe files never confer trust */ } } + return { ok: true, key: scanned.key, hostKey: { state: !trusted ? 'new' : trusted.fingerprint === scanned.key.fingerprint ? 'trusted' : 'changed', presented: publicKey(scanned.key), ...(trusted ? { trusted: publicKey(trusted) } : {}) } }; + }; + const legacy = async profile => { + const file = path.join(home, '.ssh/known_hosts'); + const target = profile.port === 22 ? profile.host : `[${profile.host}]:${profile.port}`; + const answer = await run('/usr/bin/ssh-keygen', ['-F', target, '-f', file], { timeout: 5000 }); + if (answer.code !== 0) return null; + const lines = answer.stdout.split(/\r?\n/), revoked = new Set(lines.filter(line => line.startsWith('@revoked ')).map(line => line.trim().split(/\s+/)[3])); + return choose(lines.map(parseKey).filter(key => key && !revoked.has(key.key))); + }; + return { read, scan, inspect, legacy, + write: (id, key) => io.write(keyFile(id), row(id, key), { maxBytes: 64 * 1024, recover: true }), + forget: id => io.remove(keyFile(id)), + metadata: (key, carried = false) => ({ ...publicKey(key), trustedAt: clock(), carried }), + }; +} diff --git a/docker/setup/credentials/io.mjs b/docker/setup/credentials/io.mjs new file mode 100644 index 0000000..231ad67 --- /dev/null +++ b/docker/setup/credentials/io.mjs @@ -0,0 +1,94 @@ +// One writer owns changes across the store, state and trust files. Helpers use +// readOnly mode: no mkdir, chmod, migration, lock or metadata write on a read. +import * as fs from 'node:fs/promises'; +import { constants, existsSync } from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { CredentialError } from './errors.mjs'; + +const localHarness = new URL('../../harness/', import.meta.url); +const harness = existsSync(new URL('lock.mjs', localHarness)) ? localHarness : new URL('file:///opt/t3-harness/'); +const [{ acquireLock }, { createFs }] = await Promise.all([import(new URL('lock.mjs', harness)), import(new URL('io.mjs', harness))]); +const queues = new Map(); +const unsafe = () => new CredentialError('A credential file is unsafe or exceeds its size limit. Repair it before continuing.', 500, 'store'); + +export function createCredentialIO({ directory, readOnly = false, effects = fs }) { + const root = path.resolve(directory); + const file = name => { + const target = path.resolve(root, name); + if (!name || target === root || !target.startsWith(root + path.sep)) throw unsafe(); + return target; + }; + const prepare = async (relative = '') => { + if (relative && relative !== 'known_hosts') throw unsafe(); + for (const target of relative ? [root, path.join(root, relative)] : [root]) { + if (!readOnly) await effects.mkdir(target, { recursive: true, mode: 0o700 }); + let stat; + try { stat = await effects.lstat(target); } catch (error) { if (readOnly && error.code === 'ENOENT') return false; throw error; } + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid()) throw unsafe(); + if (readOnly) { if ((stat.mode & 0o777) !== 0o700) throw unsafe(); } + else if ((stat.mode & 0o777) !== 0o700) await effects.chmod(target, 0o700); + } + return true; + }; + const read = async (name, { maxBytes = 512 * 1024, missing = null } = {}) => { + if (!await prepare(name.startsWith('known_hosts/') ? 'known_hosts' : '')) return missing; + let handle; + try { + handle = await effects.open(file(name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = await handle.stat(); + if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || stat.size > maxBytes) throw unsafe(); + if (readOnly) { if ((stat.mode & 0o777) !== 0o600) throw unsafe(); } + else if ((stat.mode & 0o777) !== 0o600) await handle.chmod(0o600); + const buffer = Buffer.allocUnsafe(maxBytes + 1); let at = 0; + while (at < buffer.length) { const { bytesRead } = await handle.read(buffer, at, buffer.length - at, at); if (!bytesRead) break; at += bytesRead; } + if (at > maxBytes) throw unsafe(); + let data; + try { data = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, at)); } catch { throw unsafe(); } + return { text: data, mtime: stat.mtime.toISOString() }; + } catch (error) { if (error.code === 'ENOENT') return missing; throw error; } + finally { await handle?.close(); } + }; + const write = async (name, text, { maxBytes = 512 * 1024, recover = false } = {}) => { + if (readOnly) throw new CredentialError('Helpers cannot change saved credentials.', 500, 'read-only'); + if (Buffer.byteLength(text) > maxBytes) throw new CredentialError('Saved credentials exceed their size limit.'); + await prepare(name.startsWith('known_hosts/') ? 'known_hosts' : ''); + if (!recover) await read(name, { maxBytes }); + const target = file(name), temporary = path.join(path.dirname(target), '.' + randomUUID() + '.tmp'); + try { + const handle = await effects.open(temporary, 'wx', 0o600); + try { await handle.writeFile(text); await handle.sync(); } finally { await handle.close(); } + await effects.rename(temporary, target); + const parent = await effects.open(path.dirname(target), 'r'); + try { await parent.sync(); } finally { await parent.close(); } + } finally { await effects.rm(temporary, { force: true }); } + }; + const remove = async name => { + if (readOnly) throw new CredentialError('Helpers cannot change saved credentials.', 500, 'read-only'); + await prepare(name.startsWith('known_hosts/') ? 'known_hosts' : ''); + // Unlink the directory entry, never follow a damaged metadata symlink. + await effects.rm(file(name), { force: true }); + }; + const transaction = action => { + if (readOnly) return Promise.resolve().then(action); + const previous = queues.get(root) || Promise.resolve(); + const result = previous.catch(() => {}).then(async () => { + await prepare(); + const lockFs = { ...createFs(), writeFile: (name, data, options) => effects.writeFile(name, data, { ...options, mode: 0o600 }) }; + const ctx = { fs: lockFs, lockPath: path.join(root, 'credentials.lock'), pid: process.pid, now: Date.now, lockStaleMs: 10 * 60_000 }; + const deadline = Date.now() + 5000; + let lock; + do { + lock = await acquireLock(ctx, { id: randomUUID(), operation: 'credentials' }); + if (lock.acquired) break; + if (lock.error || Date.now() >= deadline) throw new CredentialError('Credentials are being changed. Try again in a moment.', 409, 'busy'); + await new Promise(resolve => setTimeout(resolve, 25)); + } while (true); + try { return await action(); } finally { await lock.release(); } + }); + queues.set(root, result); + void result.finally(() => { if (queues.get(root) === result) queues.delete(root); }).catch(() => {}); + return result; + }; + return { directory: root, readOnly, file, prepare, read, write, remove, transaction }; +} diff --git a/docker/setup/credentials/model.mjs b/docker/setup/credentials/model.mjs new file mode 100644 index 0000000..4e84049 --- /dev/null +++ b/docker/setup/credentials/model.mjs @@ -0,0 +1,52 @@ +import { isIP } from 'node:net'; +import { CredentialError, object, validText as text } from './errors.mjs'; +const fail = message => { throw new CredentialError(message); }; + +export function apiTarget(profile, relative) { + if (profile.type !== 'api') throw new CredentialError('Choose an API connection.'); + if (typeof relative !== 'string' || relative.length > 2048 || !relative.isWellFormed() || /[\x00-\x1f\x7f]/.test(relative) || relative.startsWith('/') || relative.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(relative)) throw new CredentialError('Use a path relative to the saved API address, such as projects/123.'); + const base = new URL(profile.baseUrl), target = new URL(relative || '.', base); + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname) || target.username || target.password || target.hash) throw new CredentialError('The request must stay inside the saved API address.'); + return target; +} + +export function normalize(input, previous) { + if (!object(input)) fail('Enter connection details.'); + if (!text(input.id, 64) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.id)) fail('Use up to 64 lowercase letters, numbers and single hyphens for the credential name.'); + if (!['ssh', 'api'].includes(input.type)) fail('Choose SSH server or HTTP API.'); + if (previous && previous.type !== input.type) fail('Create a new connection to change its type.'); + const secret = input.secret === undefined || input.secret === '' ? previous?.secret : input.secret; + if (!text(secret, 8192)) fail('Enter a password or API key of up to 8192 characters, without line breaks.'); + if (previous && previous.id !== input.id) fail('The credential name cannot change.'); + const profile = { id: input.id, type: input.type, secret }; + if (input.type === 'ssh') { + if (!text(input.host, 253) || !(isIP(input.host) || /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(input.host))) fail('Enter a hostname or IP address, without a username or port.'); + if (!text(input.username, 64) || !/^[a-zA-Z_][a-zA-Z0-9_.-]*\$?$/.test(input.username)) fail('Enter a valid SSH username.'); + if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) fail('Enter an SSH port from 1 to 65535.'); + Object.assign(profile, { host: input.host, username: input.username, port: input.port }); + } else { + let url; + try { url = new URL(input.baseUrl); } catch { fail('Enter a complete http:// or https:// API address.'); } + if (!text(input.baseUrl, 2048) || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Use an HTTP or HTTPS API address without credentials, a query or a fragment.'); + if (!url.pathname.endsWith('/')) url.pathname += '/'; + profile.baseUrl = url.href; + profile.authType = input.authType ?? previous?.authType ?? 'bearer'; + if (!['bearer', 'header', 'query'].includes(profile.authType)) fail('Choose bearer token, custom header or query parameter authentication.'); + if (profile.authType !== 'bearer') { + const name = input.authName ?? previous?.authName; + if (!text(name, 128)) fail('Enter an authentication header or parameter name of up to 128 characters.'); + if (profile.authType === 'header') { + try { new Headers({ [name]: 'placeholder' }); } catch { fail('Enter a valid HTTP header name.'); } + if (['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'trailer', 'te', 'accept', 'content-type'].includes(name.toLowerCase())) fail('Choose an authentication header, not a request transport or content header.'); + const prefix = input.authPrefix ?? previous?.authPrefix ?? ''; + if (typeof prefix !== 'string' || prefix.length > 128 || /[^\x20-\x7e]/.test(prefix)) fail('Use up to 128 printable ASCII characters for the optional header prefix.'); + profile.authPrefix = prefix.trim(); + } + profile.authName = name; + } + profile.testPath = input.testPath ?? previous?.testPath ?? ''; + apiTarget(profile, profile.testPath); + } + return profile; +} + diff --git a/docker/setup/credentials/redact.mjs b/docker/setup/credentials/redact.mjs new file mode 100644 index 0000000..8145b22 --- /dev/null +++ b/docker/setup/credentials/redact.mjs @@ -0,0 +1,21 @@ +export function outputFilter(secret, write) { + const values = [...new Set([secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret), new URLSearchParams([['', secret]]).toString().slice(1), Buffer.from(secret).toString('base64')])].filter(Boolean).sort((a, b) => b.length - a.length); + const pattern = new RegExp(values.map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'); + const keep = Math.max(...values.map(value => value.length)) - 1; + let pending = ''; + const flush = final => { + let cut = final ? pending.length : Math.max(0, pending.length - keep); + for (const match of pending.matchAll(pattern)) { + if (match.index < cut && match.index + match[0].length > cut) cut = match.index + match[0].length; + } + if (cut) { write(pending.slice(0, cut).replace(pattern, '[redacted]')); pending = pending.slice(cut); } + }; + return { write(chunk) { pending += chunk; flush(false); }, end() { flush(true); } }; +} + + +export function redactText(text, secret) { + if (typeof secret !== 'string' || !secret) return text; + let result = ''; const filter = outputFilter(secret, value => { result += value; }); + filter.write(String(text)); filter.end(); return result; +} diff --git a/docker/setup/credentials/run.mjs b/docker/setup/credentials/run.mjs new file mode 100644 index 0000000..199a4e1 --- /dev/null +++ b/docker/setup/credentials/run.mjs @@ -0,0 +1,100 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { Readable } from 'node:stream'; +import { CredentialError } from './errors.mjs'; +import { apiTarget } from './model.mjs'; +import { outputFilter } from './redact.mjs'; +import { createSshDiagnostics } from './diagnostics.mjs'; +export { apiTarget } from './model.mjs'; +export { outputFilter } from './redact.mjs'; + +async function pump(stream, write, secret) { + if (!stream) return; + stream.setEncoding('utf8'); + const filter = outputFilter(secret, write); + try { for await (const chunk of stream) filter.write(chunk); } + finally { filter.end(); } +} +const failure = code => ({ ok: false, code, detail: {} }); +function sshResult(code, diagnostics, profile, aborted) { + if (aborted || /Connection timed out|Operation timed out/i.test(diagnostics)) return failure('timeout'); + if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|no matching host key type/i.test(diagnostics)) return failure('host-key-changed'); + if (/Could not resolve hostname|Connection refused|No route to host|Network is unreachable|Name or service not known/i.test(diagnostics)) return failure('unreachable'); + if (/Permission denied/i.test(diagnostics)) return failure('auth-rejected'); + if (code === 0) return { ok: true, code: 'ok', detail: { user: profile.username } }; + return { ok: false, code: 'failed', detail: { line: diagnostics.trim().split(/\r?\n/).at(-1)?.slice(-160) || 'SSH did not complete the connection.' } }; +} + +export async function sshRequest(profile, command, { stdout = process.stdout, stderr = process.stderr, stdin = 'inherit', probe = false, signal, spawnProcess = spawn, diagnosticsFactory = createSshDiagnostics } = {}) { + if (profile.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + if (!profile.knownHostsFile || !profile.hostKeyType) { + if (probe) return failure('host-key-untrusted'); + throw new CredentialError('Host key not trusted yet. Open Setup → Credentials and review this server’s fingerprint.', 409, 'host-key-untrusted'); + } + let diagnostics; + try { diagnostics = await diagnosticsFactory(profile.secret, probe ? () => {} : value => stderr.write(value)); } + catch { if (probe) return failure('failed'); throw new CredentialError('SSH diagnostics could not start.', 502); } + const algorithm = profile.hostKeyType === 'ssh-rsa' ? 'rsa-sha2-512,rsa-sha2-256' : profile.hostKeyType; + const args = ['-T', '-E', diagnostics.file, '-p', String(profile.port), '-l', profile.username, + '-o', 'LogLevel=ERROR', '-o', 'ConnectTimeout=10', '-o', 'ConnectionAttempts=1', '-o', 'NumberOfPasswordPrompts=1', + '-o', 'BatchMode=no', '-o', 'ControlPath=none', '-o', 'StrictHostKeyChecking=yes', + '-o', 'UserKnownHostsFile=' + profile.knownHostsFile, '-o', 'GlobalKnownHostsFile=/dev/null', + '-o', 'HostKeyAlias=' + profile.id, '-o', 'HostKeyAlgorithms=' + algorithm, + '-o', 'PreferredAuthentications=password,keyboard-interactive', '-o', 'PubkeyAuthentication=no', '--', profile.host, ...command]; + let child, code = 1, startFailed = false; + try { + child = spawnProcess('/usr/bin/ssh', args, { + env: { ...process.env, SSH_ASKPASS: fileURLToPath(new URL('./askpass.sh', import.meta.url)), + SSH_ASKPASS_REQUIRE: 'force', DISPLAY: process.env.DISPLAY || 't3-credentials', T3_CREDENTIAL_PASSWORD: profile.secret }, + stdio: [stdin, 'pipe', 'pipe'], signal, killSignal: 'SIGKILL', + }); + // Abort kills the child; waiting for close also drains both output streams. + const done = new Promise(resolve => { + child.once('error', () => { startFailed = true; }); + child.once('close', status => resolve(status ?? 1)); + }); + const discard = () => {}; + [, , code] = await Promise.all([ + pump(child.stdout, probe ? discard : value => stdout.write(value), profile.secret), + pump(child.stderr, probe ? discard : value => stderr.write(value), profile.secret), done, + ]); + } catch { startFailed = true; child?.kill('SIGKILL'); } + const diagnosticText = await diagnostics.close(); + if (probe) return sshResult(startFailed ? 1 : code, diagnosticText, profile, signal?.aborted); + if (startFailed) throw new CredentialError('SSH could not start or was interrupted.', 502); + return code; +} +function networkCode(error, signal) { + if (signal?.aborted || error?.name === 'TimeoutError' || error?.name === 'AbortError') return 'timeout'; + for (let at = error, depth = 0; at && depth < 8; at = at.cause, depth++) if (/CERT|TLS|SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/i.test(at.code || '')) return 'certificate'; + return 'unreachable'; +} +export async function apiRequest(profile, method, relative, { body, stdout = process.stdout, stderr = process.stderr, probe = false, signal = AbortSignal.timeout(30000), fetchRequest = fetch } = {}) { + if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) throw new CredentialError('Use GET, HEAD, POST, PUT, PATCH or DELETE.'); + const target = apiTarget(profile, relative); + try { + const headers = new Headers({ accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) }); + if (profile.authType === 'query') target.searchParams.set(profile.authName, profile.secret); + else if (profile.authType === 'header') headers.set(profile.authName, (profile.authPrefix ? profile.authPrefix + ' ' : '') + profile.secret); + else headers.set('authorization', 'Bearer ' + profile.secret); + const response = await fetchRequest(target, { method, redirect: 'manual', signal, headers, body }); + const redirected = response.status >= 300 && response.status < 400; + if (probe || redirected) { + await response.body?.cancel(); + const code = redirected ? 'redirect' : response.ok ? 'ok' : [401, 403].includes(response.status) ? 'rejected' : response.status === 404 ? 'not-found' : 'http-error'; + if (probe) return { ok: code === 'ok', code, detail: { status: response.status } }; + throw new CredentialError('API request refused a redirect. Use the final API address in Setup → Credentials.', 502, 'redirect'); + } + if (response.body) await pump(Readable.fromWeb(response.body), value => stdout.write(value), profile.secret); + if (!response.ok) stderr.write('HTTP ' + response.status + '\n'); + return response.ok ? 0 : 1; + } catch (error) { + if (error instanceof CredentialError) throw error; + const code = networkCode(error, signal); + if (probe) return failure(code); + throw new CredentialError('API request failed. Check the address, certificate and network; redirects are not followed.', 502, code); + } +} +export const testCredential = (profile, { signal = AbortSignal.timeout(15000), ...options } = {}) => profile.type === 'ssh' + ? sshRequest(profile, ['true'], { ...options, probe: true, stdin: 'ignore', signal }) + : apiRequest(profile, 'GET', profile.testPath || '', { ...options, probe: true, signal }); diff --git a/docker/setup/credentials/service.mjs b/docker/setup/credentials/service.mjs new file mode 100644 index 0000000..cce734e --- /dev/null +++ b/docker/setup/credentials/service.mjs @@ -0,0 +1,96 @@ +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createCredentialStore } from '../credentials.mjs'; +import { createCredentialUsage } from './usage.mjs'; +import { testCredential } from './run.mjs'; +import { CredentialError, object } from './errors.mjs'; +import { cleanResult } from './state.mjs'; + +export const CREDENTIAL_ROUTES = ['/credentials', '/credentials/create', '/credentials/save', '/credentials/remove', '/credentials/scan', '/credentials/test', '/credentials/trust', '/credentials/forget-host-key']; +const fingerprint = value => typeof value === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(value); +export function createCredentialsService({ store = createCredentialStore(), usage = createCredentialUsage(), test = testCredential, clock = Date.now } = {}) { + let running = 0; + const shutdown = new AbortController(), keys = store.hostKeys; + const network = async work => { + if (shutdown.signal.aborted) throw new CredentialError('Setup is restarting. Try again in a moment.', 503, 'busy'); + if (running >= 3) throw new CredentialError('Three connection checks are already running. Try again in a moment.', 409, 'busy'); + running++; + try { return await work(shutdown.signal); } finally { running--; } + }; + const scan = async (profile, signal) => keys.inspect(profile, { signal: AbortSignal.any([AbortSignal.timeout(10000), signal]) }); + const validTrust = trust => { + if (!object(trust) || !fingerprint(trust.fingerprint)) throw new CredentialError('Review and choose a host-key fingerprint before trusting it.'); + return trust.fingerprint; + }; + const trustSaved = async (id, expected, signal, revision) => { + const profile = await store.public(id); + if (profile.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + if (revision !== undefined && revision !== profile.revision) throw new CredentialError('This credential changed before its key could be reviewed. Read the current details first.', 409, 'changed', { current: profile }); + const scanned = await scan(profile, signal); + if (!scanned.ok) return { ok: false, code: scanned.code }; + if (scanned.key.fingerprint !== expected) throw new CredentialError('The server presented another key. Review its new fingerprint before trusting it.', 409, 'key-changed', { presented: scanned.hostKey.presented }); + const saved = await store.trust({ id, revision: profile.revision, key: scanned.key }); + return { ok: true, hostKey: saved.hostKey }; + }; + const testInput = async (input, signal) => { + const started = clock(), draft = input.draft !== undefined; + let profile = draft ? await store.draft(input) : await store.forUse(input.id), hostFingerprint = profile.hostFingerprint || null; + let result, temporary = null; + try { + if (profile.type === 'ssh') { + const scanned = await scan(draft && !profile.knownHostsFile ? { ...profile, id: undefined } : profile, signal); + if (!scanned.ok) result = scanned; + else { + const presented = scanned.hostKey.presented, trusted = scanned.hostKey.trusted; + // Explicit trust may replace a previous key for this draft only. + const agreed = draft && input.trust !== undefined ? validTrust(input.trust) : null; + if (agreed && presented.fingerprint !== agreed) result = { ok: false, code: 'host-key-changed', detail: { trusted: { type: presented.type, fingerprint: agreed }, presented } }; + else if (!agreed && scanned.hostKey.state !== 'trusted') result = { ok: false, code: trusted ? 'host-key-changed' : 'host-key-untrusted', detail: trusted ? { trusted, presented } : {}, hostKey: scanned.hostKey }; + else if (draft) { + temporary = await fs.mkdtemp(path.join(os.tmpdir(), 't3-credential-key-')); await fs.chmod(temporary, 0o700); + const file = path.join(temporary, 'known_hosts'); + await fs.writeFile(file, `${profile.id} ${scanned.key.type} ${scanned.key.key}\n`, { mode: 0o600, flag: 'wx' }); + profile = { ...profile, knownHostsFile: file, hostKeyType: scanned.key.type }; + } + } + } + result ||= await test(profile, { signal: AbortSignal.any([AbortSignal.timeout(15000), signal]) }); + const cleaned = cleanResult({ ...result, ms: clock() - started }, profile.secret); + if (!cleaned) throw new CredentialError('The connection test could not be read.', 500); + if (!draft) await store.recordTest({ id: profile.id, revision: profile.revision, hostFingerprint, result: cleaned }); + return { ok: true, result: { ...cleaned, at: new Date(clock()).toISOString() } }; + } finally { if (temporary) await fs.rm(temporary, { recursive: true, force: true }); } + }; + return { + initialize: store.initialize, + summary: store.summary, + invalidateUsage: () => usage.invalidate(), + close: () => shutdown.abort(), + async handle(route, input = {}) { + if (route === '/credentials') return { ok: true, ...await usage.annotate((await store.list()).credentials) }; + if (route === '/credentials/create' || route === '/credentials/save') { + if (input.trust !== undefined) validTrust(input.trust); + const saved = await store.save({ ...input, create: route === '/credentials/create' }); + if (input.trust !== undefined) { + try { saved.trust = await network(signal => trustSaved(saved.credential.id, input.trust.fingerprint, signal, saved.credential.revision)); } + catch (error) { saved.trust = error instanceof CredentialError ? { ok: false, code: error.code, ...(error.extra.presented ? { presented: error.extra.presented } : {}) } : { ok: false, code: 'failed' }; } + saved.credential = await store.public(saved.credential.id); + } + return { ok: true, ...saved }; + } + if (route === '/credentials/remove') return { ok: true, ...await store.remove(input) }; + if (route === '/credentials/forget-host-key') { await store.forget(input.id); return { ok: true }; } + if (route === '/credentials/test') return network(signal => testInput(input, signal)); + if (route === '/credentials/trust') { const expected = validTrust({ fingerprint: input.fingerprint }); return network(signal => trustSaved(input.id, expected, signal, input.revision)); } + if (route === '/credentials/scan') return network(async signal => { + const profile = input.id === undefined ? { host: input.host, port: input.port } : await store.public(input.id); + if (input.id !== undefined && profile.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + if (input.revision !== undefined && input.revision !== profile.revision) throw new CredentialError('This credential changed before its key could be reviewed. Read the current details first.', 409, 'changed', { current: profile }); + const started = clock(), scanned = await scan(profile, signal); + return scanned.ok ? { ok: true, hostKey: scanned.hostKey } : { ok: true, result: { ...scanned, ms: clock() - started } }; + }); + throw new CredentialError('Credential action not found.', 404, 'not-found'); + }, + }; +} diff --git a/docker/setup/credentials/state.mjs b/docker/setup/credentials/state.mjs new file mode 100644 index 0000000..9233f4a --- /dev/null +++ b/docker/setup/credentials/state.mjs @@ -0,0 +1,38 @@ +import { object, validId } from './errors.mjs'; +import { redactText } from './redact.mjs'; +const CODES = new Set(['ok', 'unreachable', 'timeout', 'scan-failed', 'host-key-untrusted', 'host-key-changed', 'auth-rejected', 'failed', 'rejected', 'not-found', 'http-error', 'redirect', 'certificate']); +const stamp = value => typeof value === 'string' && value.length < 40 && Number.isFinite(Date.parse(value)); +const key = value => object(value) && typeof value.type === 'string' && /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(?:256|384|521))$/.test(value.type) && /^SHA256:[A-Za-z0-9+/]{43}$/.test(value.fingerprint || '') ? { type: value.type, fingerprint: value.fingerprint } : null; +export const emptyState = () => ({ version: 1, tests: Object.create(null), hostKeys: Object.create(null) }); + +export function cleanResult(value, secret) { + if (!object(value) || !CODES.has(value.code) || typeof value.ok !== 'boolean' || value.ok !== (value.code === 'ok')) return null; + const detail = {}, raw = object(value.detail) ? value.detail : {}; + if (Number.isInteger(raw.status) && raw.status >= 100 && raw.status <= 599) detail.status = raw.status; + if (typeof raw.user === 'string' && /^[a-zA-Z_][a-zA-Z0-9_.-]*\$?$/.test(raw.user) && raw.user.length <= 64) detail.user = raw.user; + if (typeof raw.line === 'string') detail.line = redactText(raw.line, secret).replace(/[\x00-\x1f\x7f]/g, ' ').slice(-160); + for (const name of ['trusted', 'presented']) if (key(raw[name])) detail[name] = key(raw[name]); + const result = { ok: value.ok, code: value.code, ms: Number.isFinite(value.ms) ? Math.min(120000, Math.max(0, Math.round(value.ms))) : 0, detail }; + if (stamp(value.at)) result.at = value.at; + if (value.hostKey && ['new', 'changed', 'trusted'].includes(value.hostKey.state) && key(value.hostKey.presented)) result.hostKey = { state: value.hostKey.state, presented: key(value.hostKey.presented), ...(key(value.hostKey.trusted) ? { trusted: key(value.hostKey.trusted) } : {}) }; + return result; +} + +export function createCredentialState({ io }) { + const read = async (profiles = []) => { + const state = emptyState(); + try { + const file = await io.read('state.json'); + if (!file) return state; + const data = JSON.parse(file.text); + if (!object(data) || data.version !== 1) return state; + for (const [id, value] of Object.entries(object(data.tests) ? data.tests : {})) { + const result = cleanResult(value, profiles.find(profile => profile.id === id)?.secret); + if (validId(id) && result && stamp(result.at)) state.tests[id] = { ...result, consecutiveTimeouts: Number.isSafeInteger(value.consecutiveTimeouts) ? Math.min(100000, Math.max(0, value.consecutiveTimeouts)) : 0, ...(typeof value.revision === 'string' && /^[a-f0-9]{16}$/.test(value.revision) ? { revision: value.revision } : {}) }; + } + for (const [id, value] of Object.entries(object(data.hostKeys) ? data.hostKeys : {})) if (validId(id) && key(value) && stamp(value.trustedAt)) state.hostKeys[id] = { ...key(value), trustedAt: value.trustedAt, carried: value.carried === true }; + } catch { /* Display metadata is disposable; damaged state cannot hide credentials. */ } + return state; + }; + return { read, write: state => io.write('state.json', JSON.stringify(state, null, 2) + '\n', { recover: true }) }; +} diff --git a/docker/setup/credentials/usage.mjs b/docker/setup/credentials/usage.mjs new file mode 100644 index 0000000..b919a8b --- /dev/null +++ b/docker/setup/credentials/usage.mjs @@ -0,0 +1,60 @@ +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { scanSkillFiles, readSkillFile, skillMetadata, skillProviders } from '../skills.mjs'; +import { readAgentsMd } from '../context.mjs'; + +export function credentialMentions(content) { + return new Set([...String(content).matchAll(/(?:^|[^\w-])t3-(?:ssh|api)\s+(['"]?)([a-z0-9]+(?:-[a-z0-9]+)*)\1(?![\w-])/g)].map(match => match[2])); +} +export function createCredentialUsage({ home = process.env.HOME || '/home/t3', baseDir = process.env.T3CODE_HOME || path.join(home, '.t3'), env = process.env, io = fs, scan = scanSkillFiles, readFile = readSkillFile, instructions = readAgentsMd, installed = async () => [], clock = Date.now, ttlMs = 15000, limits } = {}) { + let cache = null, pending = null, generation = 0; + const scanAll = async () => { + const providers = skillProviders(), warnings = new Set(), files = new Map(), usage = new Map(); + const installedIds = await installed().catch(() => []); + const scans = await Promise.all(providers.map(async provider => { + try { return { provider, data: await scan(provider.id, { home, env, io, limits }) }; } + catch { return { provider, data: { skills: [], warnings: ['scan-failed'] } }; } + })); + for (const { provider, data } of scans) { + if (data.warnings.length) warnings.add('Some ' + provider.name + ' skills could not be read.'); + for (const row of data.skills) { + if (row.error) { warnings.add('Some skills could not be read.'); continue; } + try { + const real = row.resolvedPath || await io.realpath(row.path); + if (!files.has(real)) files.set(real, { row, providers: new Set() }); + files.get(real).providers.add(provider.id); + } catch { warnings.add('Some skills could not be read.'); } + } + } + const add = (id, item) => { if (!usage.has(id)) usage.set(id, []); usage.get(id).push(item); }; + let bytes = 0, count = 0; + for (const [file, entry] of files) { + // A bounded second pass reads each real file once, even in shared roots. + if (++count > 512 || bytes + 65536 > 8 * 1024 * 1024) { warnings.add('The skill scan reached its safety limit; some mentions may be missing.'); break; } + try { + const read = await readFile(file, 65536, io); bytes += read.bytes; + const name = skillMetadata(read.content, path.basename(path.dirname(entry.row.path))).name; + const where = installedIds.length && installedIds.every(id => entry.providers.has(id)) ? 'All agents' : providers.filter(provider => entry.providers.has(provider.id)).map(provider => provider.name).join(', '); + for (const id of credentialMentions(read.content)) add(id, { kind: 'skill', name, where }); + } catch { warnings.add('Some skills could not be read.'); } + } + try { + const global = await instructions(io, baseDir); + if (global.error) warnings.add('Global instructions could not be read.'); + else if (global.content) for (const id of credentialMentions(global.content)) add(id, { kind: 'instructions', name: 'Global instructions' }); + } catch { warnings.add('Global instructions could not be read.'); } + return { usage, usageAt: new Date(clock()).toISOString(), usageWarnings: [...warnings] }; + }; + const read = async () => { + if (cache && clock() - cache.at < ttlMs) return cache.value; + if (pending) return pending; + const seen = generation; + const work = scanAll(); pending = work; + try { const value = await work; if (seen === generation) cache = { at: clock(), value }; return value; } + finally { if (pending === work) pending = null; } + }; + return { invalidate() { generation++; cache = null; pending = null; }, async annotate(credentials) { + const value = await read(); + return { credentials: credentials.map(credential => ({ ...credential, usedIn: value.usage.get(credential.id) || [] })), usageAt: value.usageAt, usageWarnings: value.usageWarnings }; + } }; +} diff --git a/docker/setup/files/paths.mjs b/docker/setup/files/paths.mjs index d0bddcb..d56f6a0 100644 --- a/docker/setup/files/paths.mjs +++ b/docker/setup/files/paths.mjs @@ -72,7 +72,7 @@ export function createPathPolicy({ home = os.homedir(), workspace = '/workspace' ['Cursor', path.join(home, '.cursor'), 'cli-config.json'], ['Muse Code', path.join(configHome, 'muse'), 'auth.json'], ].map(([name, root, file]) => ({ name, root, file: path.join(root, file) })); - const keyRoots = guarded.keys.paths.map(value => path.join(home, value.slice(2))); + const keyRoots = [...guarded.keys.paths.map(value => path.join(home, value.slice(2))), path.join(t3Root, 'server-credentials')]; const location = async value => { try { return await io.realpath(value); } catch { try { return await canonicalEntry(value, io); } catch { return path.resolve(value); } } diff --git a/docker/setup/page.mjs b/docker/setup/page.mjs index 001e09d..6e61446 100644 --- a/docker/setup/page.mjs +++ b/docker/setup/page.mjs @@ -46,6 +46,11 @@ export const CONSOLE_SCRIPTS = [ "client/mcp-inspector.js", "client/mcp-editor.js", "client/mcp.js", + "client/credential-model.js", + "client/credential-inspector.js", + "client/credential-editor.js", + "client/credential-dialogs.js", + "client/credentials.js", "client/files-model.js", "client/files-editor.js", "client/files-dialogs.js", diff --git a/docker/setup/server.mjs b/docker/setup/server.mjs index 416ea22..0e91d39 100644 --- a/docker/setup/server.mjs +++ b/docker/setup/server.mjs @@ -38,6 +38,9 @@ import { createSkillsService } from "./skills.mjs"; import { createMcpService } from "./mcp.mjs"; import { clientAddress } from "./client-address.mjs"; import { newKey, readKeyFile, writeKeyFile } from "./setup-key.mjs"; +import { createCredentialStore, credentialFailure, CredentialError } from "./credentials.mjs"; +import { createCredentialsService, CREDENTIAL_ROUTES } from "./credentials/service.mjs"; +import { createCredentialUsage } from "./credentials/usage.mjs"; import { connectState, createConnectPreferences, jsonFrom, parseLinkOutput } from "./connect.mjs"; import { checkReaches, clearSaved, parsePublicUrl, platformUrl, readSaved, resolvePublicUrl, writeSaved, @@ -67,6 +70,7 @@ const SINGLE_PORT = Number(process.env.T3_SINGLE_PORT) || null; const SINGLE_PREFIX = BASE_PATH ? (BASE_PATH.startsWith("/") ? BASE_PATH : `/${BASE_PATH}`) : "/__setup"; // Where T3 Code keeps its state; the volume a user mounts is the home around it. const STATE_DIR = process.env.T3CODE_HOME || `${process.env.HOME || "/home/t3"}/.t3`; +const credentialStore = createCredentialStore(); const VOLUME = process.env.HOME || STATE_DIR.replace(/\/\.t3\/?$/, "") || STATE_DIR; const WORKSPACE = process.env.T3_WORKSPACE || "/workspace"; // The setup key and where it came from (setup-key.mjs): "env" for @@ -531,6 +535,11 @@ const skillsService = createSkillsService({ home: CONTEXT_HOME, env: process.env const mcpService = createMcpService({ home: CONTEXT_HOME, env: process.env, baseDir: STATE_DIR, io: await contextFs(), }); +let credentialInstalled = null; +const credentials = createCredentialsService({ store: credentialStore, usage: createCredentialUsage({ + home: CONTEXT_HOME, baseDir: STATE_DIR, env: process.env, io: await contextFs(), + installed: async () => credentialInstalled || [...((await harnessCache.snapshotCheap()).harnesses || []).filter(row => row.installed).map(row => row.id), 'native-provider-not-yet-reported'], +}) }); let filesPromise, filesInstance, filesPayloads = 0; const loadFiles = () => filesPromise ??= loadStorage().then(storage => createFilesService({ home: CONTEXT_HOME, workspace: WORKSPACE, stateDir: STATE_DIR, env: process.env, storage, processSnapshot: harnessModule.processSnapshot, t3: t3Api, recordEvent, withStopped: action => filesHold.withStopped(action), @@ -839,6 +848,7 @@ const status = async () => { // console (the row says so); while T3 itself is down the page already says. const t3Agents = antigravityRow && (antigravityRow.available || (!antigravityRow.reachable && server?.ok)) ? [antigravityRow] : []; const harnesses = harnessSnap?.harnesses ?? []; + credentialInstalled = [...harnesses, ...t3Agents].filter(row => row.installed).map(row => row.id); const verdicts = new Map((scmSnap?.harnesses ?? []).map((row) => [row.id, row.auth])); const sourceControl = (toolchainSnap?.sourceControl ?? []).map((row) => ({ ...row, auth: verdicts.get(row.id) ?? null })); knownSourceControl.clear(); @@ -927,6 +937,8 @@ const status = async () => { updates: { refreshing: latestCache.refreshing(), manual: latestCache.manualPending(), errors: latestCache.errors(), checkedAt: latestCache.checkedAt() }, // Every other tool in the global mise config, added here or with `mise use -g`. packages: withLatest("package", packageRows), + // Local credential attention only. This never scans skills or connects. + credentials: await attemptTimed('credentials', () => credentials.summary(), 1200, { count: null, attention: [], error: 'Saved credentials could not be read.' }), // Global instruction metadata; the editor fetches contents on demand. // Shared MCP metadata rides alongside; server entries stay server-side. context: await attempt("context", async () => { @@ -2181,7 +2193,7 @@ const cancelLifecycle = (target, rawId) => { return { http: 404, body: { ok: false, code: "not-running", error: "Nothing is running or waiting for that row." } }; }; -const ROUTES = [...FILES_ROUTES, "/login", "/logout", "/hello", "/status", "/pair", "/revoke", "/devices/rename", "/ports", +const ROUTES = [...FILES_ROUTES, ...CREDENTIAL_ROUTES, "/login", "/logout", "/hello", "/status", "/pair", "/revoke", "/devices/rename", "/ports", "/storage/versions/prune", "/storage/versions", "/storage/refresh", "/storage/clean/preview", "/storage/clean", "/storage/policy", "/storage", "/public-url/clear", "/public-url", "/setup-key/reveal", "/setup-key/replace", "/connect/unlink", "/connect/preferences", "/t3/restart", "/t3/updates/check", "/t3/update", "/ports/expose", "/ports/unexpose", @@ -2525,6 +2537,18 @@ const server = createServer(async (req, res) => { // where locking would end something. if (route === "/status") return sendJson(res, 200, { ...(await status()), viewer: { via: viewer.via } }); + if (route === '/credentials' || route.startsWith('/credentials/')) { + if (req.method !== (route === '/credentials' ? 'GET' : 'POST')) return sendJson(res, 405, { ok: false, error: 'Use the supported method for this credential action.' }); + try { + if (route === '/credentials') return sendJson(res, 200, await credentials.handle(route)); + let input; + try { input = JSON.parse(await readBody(req)); } + catch (error) { return sendJson(res, error.message === 'body too large' ? 413 : 400, { ok: false, code: 'invalid', error: 'Send credential details as a JSON object of at most 64 KiB.' }); } + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CredentialError('Send credential details as a JSON object.'); + return sendJson(res, 200, await credentials.handle(route, input)); + } catch (error) { const failure = credentialFailure(error); return sendJson(res, failure.status, failure.body); } + } + if (route.startsWith("/files/")) { if (req.method !== (FILES_POST_ROUTES.includes(route) ? "POST" : "GET")) return sendJson(res, 405, { ok: false, code: "invalid", error: "Use the supported method for this Files action." }); let payloadLease = false; @@ -2779,6 +2803,7 @@ const server = createServer(async (req, res) => { : route === "/context/skills/save" ? await skillsService.save(input.provider, input.id, input.revision, input.content) : route === "/context/skills/state" ? await skillsService.setState(input.provider, input.id, input.stateRevision, input.enabled, input.state) : await skillsService.remove(input.provider, input.id, input.revision, input.removalRevision); + credentials.invalidateUsage(); if (!["/context/skills/check-update", "/context/skills/source", "/context/skills/update/preview", "/context/skills/plan", "/context/skills/discard"].includes(route)) recordEvent("context.saved", route === "/context/skills/update" ? "Updated a repository skill; review the installation result" : route === "/context/skills/hooks/state" ? "Changed a Codex plugin hook setting" : route === "/context/skills/install" ? "Imported local skills; review individual installation results" : route === "/context/skills/remove" ? "Removed a local skill installation" : route === "/context/skills/state" ? "Changed native skill availability" : "Saved a local skill"); return sendJson(res, 200, result); } catch (error) { @@ -2851,6 +2876,7 @@ const server = createServer(async (req, res) => { // Allow encoding overhead here; decoded instruction size is validated below. const input = JSON.parse((await readBody(req, 6 * 64 * 1024 + 4096)) || "{}"); const fs = await contextFs(); + credentials.invalidateUsage(); if (input.provider !== undefined) { const options = { home: CONTEXT_HOME, env: process.env }; const result = input.mode !== undefined @@ -3102,6 +3128,7 @@ const server = createServer(async (req, res) => { // tracking them - a sign-in nobody can finish, a URL nobody can stop - so // they go when the service does, however it goes. const stopChildren = () => { + credentials.close(); filesInstance?.stop(); for (const session of sessions.values()) { if (!TERMINAL_STATES.has(session.state)) { @@ -3117,6 +3144,9 @@ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) { process.on(signal, () => process.exit(0)); } +// The setup service alone migrates legacy stores before serving requests. +try { await credentials.initialize(); } +catch { console.warn('[setup] Saved credentials need attention; open Credentials to inspect the store.'); } server.listen(PORT, "0.0.0.0", () => { console.log(`[setup] listening on 0.0.0.0:${PORT}`); // Background facts: the storage check now, release lookups shortly after diff --git a/docker/t3-client/setup-bridge.js b/docker/t3-client/setup-bridge.js index 0c8fa45..70557e8 100644 --- a/docker/t3-client/setup-bridge.js +++ b/docker/t3-client/setup-bridge.js @@ -146,6 +146,7 @@ { id: 'agents', route: 'agents', title: 'Agents', meta: 'Setup · sign in, install and update', terms: [] }, { id: 'context', route: 'instructions', title: 'Instructions & skills', meta: 'Setup · global AGENTS.md and skills', terms: ['agents.md global instructions', 'context', 'skills'] }, { id: 'mcp', route: 'mcp', title: 'MCP servers', meta: 'Setup · shared definitions and provider configuration', terms: ['mcp tools servers'] }, + { id: 'credentials', route: 'credentials', title: 'Credentials', meta: 'Setup · SSH and API logins agents use by name', terms: ['ssh', 'api key', 'token', 'password', 'secret', 'server credentials'] }, { id: 'pair', route: 'access', title: 'Pair a device', meta: 'Setup · Access', terms: ['devices', 'pairing'] }, { id: 'toolchains', route: 'toolchains', title: 'Toolchains', meta: 'Setup · Go, Rust, Bun, Deno, uv and any mise tool', terms: [] }, { id: 'sourcecontrol', route: 'sourcecontrol', title: 'Source control', meta: 'Setup · sign in to GitHub, GitLab, Forgejo, Gitea and Azure DevOps', terms: ['codeberg', 'gh glab fj tea az'] }, diff --git a/docs/credentials.md b/docs/credentials.md new file mode 100644 index 0000000..f5ba928 --- /dev/null +++ b/docs/credentials.md @@ -0,0 +1,170 @@ +# Credentials + +**Setup → Credentials** saves SSH passwords and API keys on this server. Agents +use a credential’s name through `t3-ssh` or `t3-api`, keeping the secret out of +chats and skill files. The page works in T3 Code’s Settings and in the dedicated +Setup console, on desktop and phone. + +![Credentials showing targets, saved test results and skill usage](media/credentials/list-light.png) + +## Save and test a login + +Choose **Add credential…**, then **SSH server** or **HTTP API**. Give it a name +such as `build-server` or `project-api`. Names use up to 64 lowercase letters, +numbers and single hyphens. The name and type cannot change after creation: +skills depend on them. Create another credential to use a different name. + +- **SSH server:** enter the hostname or IP address, port, username and password. + Password and keyboard-interactive sign-in are supported; key-based sign-in, + multi-step challenges and interactive terminals are not. +- **HTTP API:** enter the complete base URL, including its version or base path, + authentication method and key. The request preview always masks the key. + +| Authentication | Settings | Request | +| --- | --- | --- | +| Bearer token | Saved token | `Authorization: Bearer ` | +| Header | Header name and optional value prefix | `X-API-Key: ` or `Authorization: Token ` | +| Query parameter | Parameter name | `?api_key=` | + +Use the service’s required authentication method. Prefer a header when it is +supported: query parameters can appear in the service’s URL logs. OAuth flows, +request signing and WebSocket protocols are outside the HTTP helper’s scope. + +**Test** works before saving. A draft test does not save the credential, replace +its password, record a result or change a saved host key. For an API, choose a +relative GET test path such as `account` that requires authentication. A blank +path tests the base URL. A public endpoint can succeed with an invalid key. +Tests report the HTTP status, without exposing the response body or headers. + +### Trust an SSH server + +The first SSH test reads the server’s public host key without sending the +password. Compare the complete SHA256 fingerprint with the server’s own key +using the command shown in the editor. Then select **Trust this host key** to +test the login. Saving records the login and its approved key separately. +If the final key check fails, the login is still saved, but agents remain +blocked until you review its host key. + +Trust belongs to this credential. Neither the test nor the helper silently +accepts a new key or changes your `~/.ssh/known_hosts`. If the server presents a +different key later, **Review…** shows both fingerprints. **Keep blocking** is +the safe default. A reinstall may explain a new key, but verify it through a +trusted channel before choosing **Trust new key**. If the key or saved address +changes during review, you must review the new details again. + +Changing an SSH host or port forgets its old trust. **Forget host key…** in the +details menu also blocks future connections until a key is reviewed and trusted. + +## Understand a saved credential + +The list shows each target, authentication method, last test result and the +skills that mention it. Open its name for connection details, its trusted SSH +fingerprint, commands and a ready-to-paste **Copy snippet** for `SKILL.md`. + +Saved tests retain their timestamp and result across reloads. A rejected +password or token offers **Replace…**; a changed key offers **Review…**; a network +failure offers another test. Failed tests and untrusted SSH keys appear in +**Needs attention**, Overview and Setup’s count in T3 Code. A single timeout +does not count as attention; two consecutive timeouts do. Untested API logins +are neutral. Tests only run when requested. Agent helper failures do not update +this history. + +**Used in** scans global skills for all seven providers and shared `AGENTS.md`. +It deduplicates shared skill files and looks for literal helper calls with this +credential’s name. It does not scan project-local skills, follow shell variables +or execute files. Results cache briefly and refresh after Setup changes skills +or instructions. If a folder is unreadable or a scan limit is reached, the page +marks usage incomplete rather than claiming the credential is unused. + +## Edit or delete + +Saved secrets are write-only. **Replace…** accepts a new value; **Keep saved** or +an empty replacement keeps the current value. Show/hide reveals only a value +you have just typed. Closing the editor clears it. Unsaved changes require a +discard confirmation, and a pending test or save blocks repeated actions. + +Changes conflict per credential. If another window edits the same login, the +editor shows changed fields and whether the saved secret was replaced. **Use +theirs** adopts the current details and clears your typed replacement if their +secret changed. **Keep mine** retries your draft against that revision. A +credential deleted elsewhere can be **Saved as new**, with a newly entered +secret. Editing a different credential never causes a conflict. + +**Delete…** names the skills that may break and explains what is erased. It +removes this server’s saved secret, test history and SSH trust. Skills are not +rewritten, and volume backups may still contain older copies. Deletion does +not rotate or revoke the credential at the remote service. + +## Use from a skill or terminal + +Both image variants install these helpers: + +```sh +t3-ssh build-server -- uptime +t3-ssh build-server -- 'systemctl status app' +t3-ssh build-server -- bash -s < script.sh + +t3-api project-api GET projects +printf '%s' '{"name":"demo-project"}' | t3-api project-api POST projects --stdin +``` + +Replace the credential names and operations with your own. Give a complete +remote shell command as one quoted argument when it contains shell syntax. +SSH stdin remains available for scripts. The helper refuses missing or changed +host trust and directs you to **Setup → Credentials**. + +`t3-api` accepts GET, HEAD, POST, PUT, PATCH and DELETE. Paths are relative to the +saved base URL, without a leading slash. `--stdin` supplies a JSON body of at +most 1 MiB. Requests remain under the base URL, verify HTTPS certificates and +never follow redirects. Responses go to stdout; HTTP failures exit nonzero. +The helper adds the saved secret using its configured authentication method. + +Helpers filter matching literal, JSON-escaped, URL-encoded, query-encoded and +Base64 secret values from output. This handles accidental echoes, not every +possible transformation. It cannot prevent an agent with shell access from +reading the store. Never put a command that prints credential files or child +environments into a skill. + +Adapt the [example skills](../examples/skills) to move existing logins out of +skills. Save, verify and test them before switching commands. Rotate secrets +that previously appeared in chats or skill history; removing the text does not +remove earlier copies. Home Assistant is a bearer-token API, using your own +instance’s base URL ending in `/api/`. + +## Storage, migration and recovery + +The page’s name changed, but its directory stays +`$T3CODE_HOME/server-credentials/`, normally `/home/t3/.t3/server-credentials/`. +It is owner-only (0700); files are owner-only (0600). + +| File | Purpose | +| --- | --- | +| `credentials.json` | Version 2 login records, plaintext secrets, random per-credential revisions and timestamps | +| `state.json` | Disposable saved test results and host-key display metadata, without secrets | +| `known_hosts/` | The single explicitly pinned SSH host key for that credential | + +The default home volume preserves these files across restarts, upgrades and +recreations. Mounting only `T3CODE_HOME` preserves them there too. Protect the +volume and its backups: this feature does not encrypt secrets or isolate them +from other processes running as the container user. SSH passes its password to +its askpass child through a child environment. + +Setup automatically migrates PR #66’s version 1 store, dropping the separate +label and adding revisions and timestamps. Existing SSH keys from the user’s +`~/.ssh/known_hosts` are carried into each credential’s file and labelled +**carried over**. Missing keys require explicit trust. Migration uses atomic +replacement and makes no plaintext backup; failure leaves the original store +intact. The command-line helpers are read-only and never migrate the store. + +The credential’s known-hosts file decides trust. `state.json` is only metadata: +corrupt or missing state does not erase a saved login or grant trust. Tests can +be repeated. An unreadable credential store instead shows an error and stops +writes; it is never treated as an empty list. Restore it privately from your +own valid backup, preserving ownership and permissions. Do not paste it into +an issue, chat or terminal output. + +The API uses the console’s existing authentication and same-origin protections. +Serve Setup over HTTPS or a trusted private connection. Secrets and +secret-derived hashes never appear in list responses, `/status`, snippets or +browser storage. The Files page treats the store as a managed path containing +sign-in data. diff --git a/docs/media/credentials/api-details-light.png b/docs/media/credentials/api-details-light.png new file mode 100644 index 0000000..2185c06 Binary files /dev/null and b/docs/media/credentials/api-details-light.png differ diff --git a/docs/media/credentials/api-editor-dark.png b/docs/media/credentials/api-editor-dark.png new file mode 100644 index 0000000..4a4f4d7 Binary files /dev/null and b/docs/media/credentials/api-editor-dark.png differ diff --git a/docs/media/credentials/delete-dark.png b/docs/media/credentials/delete-dark.png new file mode 100644 index 0000000..50e8749 Binary files /dev/null and b/docs/media/credentials/delete-dark.png differ diff --git a/docs/media/credentials/edit-conflict-light.png b/docs/media/credentials/edit-conflict-light.png new file mode 100644 index 0000000..e0ddbc9 Binary files /dev/null and b/docs/media/credentials/edit-conflict-light.png differ diff --git a/docs/media/credentials/empty-light.png b/docs/media/credentials/empty-light.png new file mode 100644 index 0000000..e5e89fe Binary files /dev/null and b/docs/media/credentials/empty-light.png differ diff --git a/docs/media/credentials/host-key-light.png b/docs/media/credentials/host-key-light.png new file mode 100644 index 0000000..4e9ef21 Binary files /dev/null and b/docs/media/credentials/host-key-light.png differ diff --git a/docs/media/credentials/list-dark.png b/docs/media/credentials/list-dark.png new file mode 100644 index 0000000..d450779 Binary files /dev/null and b/docs/media/credentials/list-dark.png differ diff --git a/docs/media/credentials/list-light.png b/docs/media/credentials/list-light.png new file mode 100644 index 0000000..fc73fdb Binary files /dev/null and b/docs/media/credentials/list-light.png differ diff --git a/docs/media/credentials/phone-details-light.png b/docs/media/credentials/phone-details-light.png new file mode 100644 index 0000000..9d07941 Binary files /dev/null and b/docs/media/credentials/phone-details-light.png differ diff --git a/docs/media/credentials/phone-editor-light.png b/docs/media/credentials/phone-editor-light.png new file mode 100644 index 0000000..836ac23 Binary files /dev/null and b/docs/media/credentials/phone-editor-light.png differ diff --git a/docs/media/credentials/phone-list-dark.png b/docs/media/credentials/phone-list-dark.png new file mode 100644 index 0000000..e7ea016 Binary files /dev/null and b/docs/media/credentials/phone-list-dark.png differ diff --git a/docs/media/credentials/ssh-details-light.png b/docs/media/credentials/ssh-details-light.png new file mode 100644 index 0000000..273dee1 Binary files /dev/null and b/docs/media/credentials/ssh-details-light.png differ diff --git a/docs/media/credentials/ssh-editor-dark.png b/docs/media/credentials/ssh-editor-dark.png new file mode 100644 index 0000000..568ca76 Binary files /dev/null and b/docs/media/credentials/ssh-editor-dark.png differ diff --git a/examples/skills/server-ssh/SKILL.md b/examples/skills/server-ssh/SKILL.md new file mode 100644 index 0000000..cd777e0 --- /dev/null +++ b/examples/skills/server-ssh/SKILL.md @@ -0,0 +1,16 @@ +--- +name: server-ssh +description: Use the saved build-server SSH connection when asked to administer that server. +--- + +Use the saved `build-server` connection for remote commands: + +```sh +t3-ssh build-server -- '' +``` + +The host, username, port and password are managed in Setup → Credentials. +Change the credential name in this example to match your server. If authentication +fails, check the saved login. If the host key is untrusted or changed, stop and +ask the user to verify its fingerprint in Credentials; never bypass host checks. Do not read or print credential files or the +helper's child environment. diff --git a/examples/skills/service-api/SKILL.md b/examples/skills/service-api/SKILL.md new file mode 100644 index 0000000..0ad5d7b --- /dev/null +++ b/examples/skills/service-api/SKILL.md @@ -0,0 +1,16 @@ +--- +name: service-api +description: Use the saved project-api connection when asked to work with that service's API. +--- + +Use the saved `project-api` connection for HTTP API requests: + +```sh +t3-api project-api GET projects +printf '%s' '{"name":"demo-project"}' | t3-api project-api POST projects --stdin +``` + +Change the credential name and paths in this example to match your service. Paths +are relative to its saved base URL, without a leading slash. Authentication, +including the header or query parameter name, is configured in Setup → +Credentials. Do not read or print credential files. Use `--stdin` for JSON bodies. diff --git a/scripts/console-preview.mjs b/scripts/console-preview.mjs index 361f02f..179966b 100644 --- a/scripts/console-preview.mjs +++ b/scripts/console-preview.mjs @@ -11,6 +11,8 @@ const { createSkillsFixtures } = require('./lib/skills-fixtures.cjs'); const { createStorageFixtures } = require('./lib/storage-fixtures.cjs'); const { createMcpFixtures } = require('./lib/mcp-fixtures.cjs'); const { createFilesFixtures } = require('./lib/files-fixtures.cjs'); +const { createCredentialsFixtures } = require('./lib/credentials-fixtures.cjs'); +const credentials = createCredentialsFixtures(); const files = createFilesFixtures(); const mcp = createMcpFixtures(); const api = createSkillsFixtures(); @@ -30,7 +32,7 @@ http.createServer(async (req, res) => { let input; try { input = url.pathname.endsWith('/files/upload/chunk')?Buffer.concat(chunks):chunks.length ? JSON.parse(Buffer.concat(chunks)) : {}; } catch { res.writeHead(400); res.end(); return; } - const result = await files.respond(req.method, url, input) || await mcp.respond(req.method, url, input) || storage.respond(req.method, url, input) || api.respond(req.method, url, input) || (url.pathname.endsWith('/status') ? { body: { ...statusFor('skills'), storage: storage.status(), context: { ...statusFor('skills').context, mcp: mcp.status() } } } + const result = await credentials.respond(req.method, url, input) || await files.respond(req.method, url, input) || await mcp.respond(req.method, url, input) || storage.respond(req.method, url, input) || api.respond(req.method, url, input) || (url.pathname.endsWith('/status') ? { body: { ...statusFor('skills'), credentials: credentials.status(), storage: storage.status(), context: { ...statusFor('skills').context, mcp: mcp.status() } } } : url.pathname.endsWith('/ports') ? { body: portsFor('ready') } : url.pathname.endsWith('/providers') ? { body: fixture('providers.json') } : { status: 404, body: { ok: false, error: 'This is a read-only fixture preview. No live API is connected.' } }); diff --git a/scripts/credentials-smoke.mjs b/scripts/credentials-smoke.mjs new file mode 100644 index 0000000..0b5e2fe --- /dev/null +++ b/scripts/credentials-smoke.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Runs inside either built image, using a disposable connection and local API. +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { access, readFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +const run = promisify(execFile), secret = 'fixture-image-credential-' + process.pid; +const state = process.env.T3CODE_HOME || '/home/t3/.t3'; +const key = process.env.T3_SETUP_KEY || (await readFile(state + '/setup-key', 'utf8')).trim(); +const port = process.env.T3_SETUP_PORT || '3774'; +const request = async (route, body) => { + const response = await fetch('http://127.0.0.1:' + port + route, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-t3-setup-key': key, 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }); + const text = await response.text(); assert.equal(text.includes(secret), false); assert.equal(response.status, 200, text); return JSON.parse(text); +}; +const server = createServer((req, res) => { + assert.equal(req.headers['x-api-key'], secret); + res.end(JSON.stringify({ status: 'ok', echo: secret })); +}); +server.listen(0, '127.0.0.1'); await once(server, 'listening'); +const id = 'smoke-credential-' + process.pid; +let created = false; +try { + await access('/opt/t3-setup/credentials/askpass.sh', constants.X_OK); + for (const cli of ['t3-ssh', 't3-api']) assert.match((await run('/usr/local/bin/' + cli, ['--help'])).stdout, /Manage connections/); + let snapshot = await request('/credentials'); + await request('/credentials/create', { credential: { id, label: 'Disposable image check', type: 'api', baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/', authType: 'header', authName: 'X-API-Key', testPath: 'account', secret } }); + created = true; snapshot = await request('/credentials'); + assert.equal(snapshot.credentials.find(profile => profile.id === id).hasSecret, true); + assert.equal((await request('/credentials/test', { id })).result.code, 'ok'); + const { stdout, stderr } = await run('/usr/local/bin/t3-api', [id, 'GET', 'projects']); + assert.deepEqual(JSON.parse(stdout), { status: 'ok', echo: '[redacted]' }); assert.equal(stderr, ''); + console.log('PASS saved API credentials and installed SSH/API helpers'); +} finally { + try { if (created) { const snapshot = await request('/credentials'); await request('/credentials/remove', { id, revision: snapshot.credentials.find(row => row.id === id).revision }); } } + finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } +} diff --git a/scripts/credentials-ui-audit.mjs b/scripts/credentials-ui-audit.mjs new file mode 100644 index 0000000..65ed63f --- /dev/null +++ b/scripts/credentials-ui-audit.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +// Real Setup API, private disposable home and loopback API. SSH handshakes in +// the trust-dialog cases are intercepted; no external service is contacted. +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { credentialsFixture } from '../tests/fixtures/credentials.mjs'; +import { createCredentialStore } from '../docker/setup/credentials.mjs'; +import { credentialKey } from '../tests/fixtures/credential-keys.mjs'; +const require = createRequire(import.meta.url); +let chromium; +try { ({ chromium } = require('playwright')); } catch { ({ chromium } = require('playwright-core')); } +const { statusFor, portsFor, fixture, framed } = require('./lib/console-fixtures.cjs'); +const output = process.env.T3_CREDENTIALS_UI_OUTPUT || '/tmp/t3-credentials-ui'; +await fs.mkdir(output, { recursive: true }); +const f = await credentialsFixture(), directory = path.join(f.env.T3CODE_HOME, 'server-credentials'); +let clock = Date.now(); const store = createCredentialStore({ directory, clock: () => clock }); +const secret = 'fixture-browser-password-123', token = 'fixture-browser-token-456', replacement = 'fixture-replacement-private-789'; +const loopback = createServer((req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); }); +loopback.listen(0, '127.0.0.1'); await once(loopback, 'listening'); +const localApi = 'http://127.0.0.1:' + loopback.address().port + '/v1/'; +const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/usr/bin/chromium', args: ['--no-sandbox'] }); +let checks = 0; +const check = (condition, message) => { assert.ok(condition, message); checks++; }; +const variants = [[1280, 900, 'standalone'], [1024, 768, 'standalone'], [744, 900, 'standalone'], [390, 844, 'standalone'], [360, 720, 'standalone'], [1198, 878, 'embedded'], [480, 760, 'tab'], [639, 800, 'tab'], [640, 800, 'tab'], [704, 800, 'tab'], [1040, 820, 'tab']]; +const themes = (process.env.T3_CREDENTIALS_UI_THEMES || 'light,dark').split(','); +const requestedWidths = (process.env.T3_CREDENTIALS_UI_WIDTHS || process.env.T3_CREDENTIALS_UI_WIDTH || '').split(',').filter(Boolean).map(Number); +const selected = variants.filter(([width]) => !requestedWidths.length || requestedWidths.includes(width)); +async function seed() { + for (const row of (await store.list()).credentials) await store.remove({ id: row.id, revision: row.revision }); + const key = credentialKey(), next = credentialKey(); + for (const profile of fixture('credentials.json').credentials) { + clock = Date.parse('2026-10-26T18:04:11Z'); + const saved = (await store.save({ create: true, credential: { ...profile, secret: profile.type === 'ssh' ? secret : token } })).credential; + if (profile.type === 'ssh') await store.trust({ id: profile.id, revision: saved.revision, key }); + if (profile.test) { + clock = Date.now() - (profile.id === 'build-server' ? 120000 : profile.id === 'home-assistant' ? 86400000 : 3600000); + const result = profile.test.code === 'host-key-changed' ? { ...profile.test, detail: { trusted: { type: key.type, fingerprint: key.fingerprint }, presented: { type: next.type, fingerprint: next.fingerprint } } } : profile.test; + await store.recordTest({ id: profile.id, revision: saved.revision, hostFingerprint: profile.type === 'ssh' ? key.fingerprint : undefined, result }); + } + } + clock = Date.now(); return { key, next }; +} +async function geometry(target, width, height, label) { + const problems = await target.evaluate(({ width, height }) => { + const errors = [], rect = el => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; + if (document.documentElement.scrollWidth > innerWidth + 1) errors.push('document overflows'); + for (const panel of document.querySelectorAll('.tc-cred-editor,.tc-cred-inspector,.tc-cred-dialog')) { + const r = rect(panel); if (r.x < -1 || r.y < -1 || r.right > width + 1 || r.bottom > height + 1) errors.push('panel outside viewport: ' + JSON.stringify(r)); + const foot = panel.querySelector('.tc-wizard-foot'); if (foot && rect(foot).bottom > height + 1) errors.push('save footer outside viewport'); + if (panel.scrollWidth > panel.clientWidth + 2) errors.push('panel content overflows'); + } + for (const row of document.querySelectorAll('.tc-cred-row')) { + const parts = [...row.children].map(rect).filter(r => r.width > 0 && r.height > 0); + for (let i = 0; i < parts.length; i++) for (let j = i + 1; j < parts.length; j++) if (Math.min(parts[i].right, parts[j].right) - Math.max(parts[i].x, parts[j].x) > 1 && Math.min(parts[i].bottom, parts[j].bottom) - Math.max(parts[i].y, parts[j].y) > 1) errors.push('row columns overlap'); + } + return errors; + }, { width, height }); + assert.deepEqual(problems, [], label); checks++; +} +try { + for (const theme of themes) for (const [width, height, mode] of selected) { + console.log('Checking ' + theme + ' ' + width + ' ' + mode); + const keys = await seed(); + const page = await browser.newPage({ viewport: { width, height }, colorScheme: theme, reducedMotion: 'reduce' }); + const pageErrors = [], responseBodies = []; + page.on('pageerror', error => pageErrors.push(error.message)); + page.on('response', response => { if (/\/credentials(?:\/|$)/.test(new URL(response.url()).pathname)) responseBodies.push(response.text().catch(() => '')); }); + await page.addInitScript(value => localStorage.setItem('t3-console-theme', value), theme); + await page.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async value => { window.__credentialCopy = value; } } })); + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.pathname.endsWith('/status')) return route.fulfill({ json: { ...statusFor('ready'), credentials: await store.summary() } }); + if (url.pathname.endsWith('/providers')) return route.fulfill({ json: fixture('providers.json') }); + if (url.pathname.endsWith('/ports')) return route.fulfill({ json: portsFor('ready') }); + return route.continue(); + }); + const embedded = mode !== 'standalone', url = f.base + '/' + (embedded ? '?embed=t3&theme=' + theme + (mode === 'tab' ? '&host=tab&layout=wide' : '') : '') + '#credentials'; + const target = embedded ? await framed(page, url) : (await page.goto(url), page); + const capture = name => page.screenshot({ path: path.join(output, `${theme}-${width}-${mode}-${name}.png`), fullPage: true }); + await target.getByLabel('Setup key', { exact: true }).fill('credential-fixture-key'); await target.getByRole('button', { name: 'Unlock', exact: true }).click(); + await target.locator('[data-cred-action="inspect"][data-id="build-server"]').waitFor(); + await geometry(target, width, height, 'list'); await capture('list'); + const search = target.getByRole('searchbox', { name: 'Search credentials' }); + await search.fill('X-API-Key'); check(await target.locator('.tc-cred-row,.tc-cred-prow').count() === 1, 'search matches secret kind'); await search.fill(''); + await target.getByRole('button', { name: 'Needs attention 2', exact: true }).click(); check(await target.locator('.tc-cred-row,.tc-cred-prow').count() === 2, 'attention filter'); + await target.getByRole('button', { name: 'Needs attention 2', exact: true }).click(); + await target.locator('[data-cred-action="inspect"][data-id="project-api"]').click(); + const inspector = target.locator('.tc-cred-inspector'); await inspector.waitFor(); await geometry(target, width, height, 'inspector'); await capture('inspect-api'); + check((await inspector.locator('.tc-cred-request').textContent()).includes('••••••••'), 'request has mask'); + await inspector.getByRole('button', { name: 'Copy snippet', exact: true }).click(); + check((await target.evaluate(() => window.__credentialCopy)).includes('t3-api project-api'), 'snippet uses name'); + await inspector.getByRole('button', { name: 'Close credential details' }).click(); + const edit = async id => { + await target.locator('[data-cred-action="inspect"][data-id="' + id + '"]').click(); + const details = target.locator('.tc-cred-inspector'); + if (await details.locator('[data-cred-inspect="edit"]').isVisible()) await details.locator('[data-cred-inspect="edit"]').click(); + else { await details.locator('[data-cred-inspect="menu"]').click(); await target.getByRole('menuitem', { name: 'Edit…', exact: true }).click(); } + await target.locator('.tc-cred-editor').waitFor(); + }; + await edit('build-server'); + let editor = target.locator('.tc-cred-editor'); + check(await editor.locator('#cred-id').count() === 0, 'saved name is locked'); + await editor.getByRole('button', { name: 'Replace…', exact: true }).click(); + check(await editor.locator('#cred-secret').inputValue() === '', 'replacement starts empty'); + await editor.locator('#cred-secret').fill(replacement); await editor.getByRole('button', { name: 'Show newly typed password' }).click(); + check(await editor.locator('#cred-secret').getAttribute('type') === 'text', 'reveal only typed password'); + check(!(await target.content()).includes(replacement), 'typed secret absent from markup'); + await geometry(target, width, height, 'editor'); await capture('edit-ssh'); + const current = await store.public('build-server'); + await store.save({ credential: { ...current, username: 'deploy-next' }, revision: current.revision }); + await editor.locator('[data-cred-editor="save"]').click(); await editor.locator('#cred-conflict').waitFor(); + check((await editor.locator('#cred-conflict').innerText()).includes('deploy-next'), 'conflict explains field change'); + check(await editor.locator('#cred-secret').inputValue() === replacement, 'conflict keeps draft secret'); await capture('conflict'); + await editor.getByRole('button', { name: 'Use theirs', exact: true }).click(); + check(await editor.locator('#cred-secret').inputValue() === replacement, 'use theirs keeps typed secret when saved secret unchanged'); + const newer = await store.public('build-server'); + await store.save({ credential: { ...newer, secret: 'external-replacement-synthetic' }, revision: newer.revision }); + await editor.locator('[data-cred-editor="save"]').click(); await editor.locator('#cred-conflict').waitFor(); + await editor.getByRole('button', { name: 'Use theirs', exact: true }).click(); + check(await editor.locator('#cred-secret').count() === 0, 'use theirs clears typed secret if theirs replaced it'); + await editor.getByRole('button', { name: 'Close credential editor' }).click(); await editor.waitFor({ state: 'hidden' }); + await edit('build-server'); editor = target.locator('.tc-cred-editor'); + await editor.getByLabel('Username', { exact: true }).fill('draft-user'); + const latest = await store.public('build-server'); + await store.save({ credential: { ...latest, username: 'edited-elsewhere' }, revision: latest.revision }); + await editor.locator('[data-cred-editor="save"]').click(); await editor.locator('#cred-conflict').waitFor(); + await editor.getByRole('button', { name: 'Keep mine', exact: true }).click(); await editor.waitFor({ state: 'hidden' }); + check((await store.forUse('build-server')).username === 'draft-user', 'Keep mine saves the draft against the reviewed revision'); + check((await store.forUse('build-server')).secret === 'external-replacement-synthetic', 'Keep mine without a replacement keeps the current saved secret'); + await target.getByRole('button', { name: 'Add credential', exact: true }).click(); editor = target.locator('.tc-cred-editor'); + await editor.getByLabel('Name', { exact: true }).fill('Fixture Login'); + check(await editor.getByLabel('Name', { exact: true }).inputValue() === 'fixture-login', 'name normalized as typed'); + await editor.getByLabel('Host', { exact: true }).fill('192.0.2.30'); await editor.getByLabel('Username', { exact: true }).fill('deploy'); await editor.locator('#cred-secret').fill(secret); + await editor.getByRole('button', { name: 'HTTP API', exact: true }).click(); + check(await editor.getByLabel('Name', { exact: true }).inputValue() === 'fixture-login', 'type switch retains name'); check(await editor.locator('#cred-secret').inputValue() === '', 'type switch clears password'); + await editor.getByLabel('Base URL', { exact: true }).fill(localApi); await editor.getByRole('button', { name: 'Header', exact: true }).click(); + await editor.getByLabel('Header name', { exact: true }).fill('X-API-Key'); await editor.locator('#cred-secret').fill(token); + await editor.getByRole('button', { name: 'Query parameter', exact: true }).click(); check(await editor.locator('#cred-secret').inputValue() === token, 'auth switch retains newly typed key'); + await editor.getByRole('button', { name: 'Header', exact: true }).click(); await editor.getByLabel('GET test path (optional)', { exact: true }).fill('account'); + const beforeTest = await store.list(); + await editor.locator('[data-cred-editor="test"]').click(); await editor.getByText('Connected · HTTP 200', { exact: true }).waitFor(); + check(JSON.stringify(await store.list()) === JSON.stringify(beforeTest), 'draft testing writes nothing'); + if (width < (mode === 'tab' ? 640 : 1024)) { + const feedback = await editor.locator('.tc-cred-test-line').evaluate(el => { const r = el.getBoundingClientRect(), foot = document.querySelector('.tc-cred-editor .tc-wizard-foot').getBoundingClientRect(); return { top: r.top, bottom: r.bottom, actions: foot.top }; }); + await capture('draft-tested'); + check(feedback.top >= -1 && feedback.bottom <= feedback.actions + 1, 'phone test feedback is visible above pinned actions: ' + JSON.stringify(feedback)); + } + // Delay the actual write. Enter/Escape and a second submit cannot duplicate it. + let release, arrived, saves = 0; const gate = new Promise(resolve => { release = resolve; }), arrival = new Promise(resolve => { arrived = resolve; }); + const hold = async route => { saves++; arrived(); await gate; await route.continue(); }; + await page.route('**/credentials/create', hold); await editor.locator('[data-cred-editor="save"]').click(); await arrival; + await editor.dispatchEvent('submit'); await page.keyboard.press('Escape'); + check(await editor.locator('#cred-secret').isDisabled(), 'fields disabled while saving'); check(await editor.locator('[data-cred-editor="close"]').first().isDisabled(), 'cannot close an unsettled save'); + release(); await editor.waitFor({ state: 'hidden' }); await page.unroute('**/credentials/create', hold); check(saves === 1, 'save submitted exactly once'); + await target.locator('[data-cred-action="inspect"][data-id="fixture-login"]').waitFor(); + check((await store.forUse('fixture-login')).secret === token, 'real API saved the new key'); + await edit('fixture-login'); editor = target.locator('.tc-cred-editor'); + await editor.getByLabel('Base URL', { exact: true }).fill(localApi + 'changed/'); + await editor.getByRole('button', { name: 'Close credential editor' }).click(); + await target.getByRole('alertdialog').getByRole('button', { name: 'Cancel', exact: true }).click(); check(await editor.isVisible(), 'cancel discard keeps draft'); + await editor.getByRole('button', { name: 'Close credential editor' }).click(); await target.getByRole('alertdialog').getByRole('button', { name: 'Discard changes', exact: true }).click(); await editor.waitFor({ state: 'hidden' }); + // Review a key that changes again while the person decides. No SSH auth. + await target.locator('[data-cred-action="inspect"][data-id="staging-server"]').click(); + const presented = { type: keys.next.type, fingerprint: keys.next.fingerprint }, third = credentialKey(); + await page.route('**/credentials/scan', route => route.fulfill({ json: { ok: true, hostKey: { state: 'changed', trusted: { type: keys.key.type, fingerprint: keys.key.fingerprint }, presented } } })); + let trustCalls = 0; + await page.route('**/credentials/trust', async route => { + trustCalls++; + if (trustCalls === 1) return route.fulfill({ status: 409, json: { ok: false, code: 'key-changed', error: 'The key changed again.', presented: { type: third.type, fingerprint: third.fingerprint } } }); + check(route.request().postDataJSON().fingerprint === third.fingerprint, 'trust uses the new displayed fingerprint'); + const saved = await store.public('staging-server'); + if (trustCalls === 2) { + const current = (await store.save({ credential: { ...saved, host: '192.0.2.31' }, revision: saved.revision })).credential; + return route.fulfill({ status: 409, json: { ok: false, code: 'changed', error: 'The host was edited elsewhere.', current } }); + } + check(route.request().postDataJSON().revision === saved.revision, 'trust binds the refreshed connection revision'); + await store.trust({ id: saved.id, revision: saved.revision, key: third }); + return route.fulfill({ json: { ok: true, hostKey: (await store.public(saved.id)).hostKey } }); + }); + await target.locator('.tc-cred-inspector').getByRole('button', { name: 'Review…', exact: true }).click(); + const trustDialog = target.locator('.tc-cred-dialog'); await trustDialog.getByText(presented.fingerprint, { exact: false }).waitFor(); + await trustDialog.getByRole('button', { name: 'Trust new key', exact: true }).waitFor({ state: 'visible' }); + check(await trustDialog.getByRole('button', { name: 'Keep blocking', exact: true }).evaluate(el => document.activeElement === el), 'host-key review focuses keep blocking'); + await geometry(target, width, height, 'host-key review'); await capture('host-key'); + await trustDialog.getByRole('button', { name: 'Trust new key', exact: true }).click(); await trustDialog.getByText('The key changed again.', { exact: false }).waitFor(); + check((await trustDialog.innerText()).includes(third.fingerprint), 'new fingerprint shown after a trust conflict'); + await trustDialog.getByRole('button', { name: 'Trust new key', exact: true }).click(); + await trustDialog.getByRole('button', { name: 'Review current host', exact: true }).waitFor(); + check(await trustDialog.locator('[data-cred-key="trust"]').count() === 0, 'stale host cannot be trusted'); + await page.route('**/credentials/scan', async route => { + const current = await store.public('staging-server'); + check(route.request().postDataJSON().revision === current.revision, 'review scans the current revision'); + return route.fulfill({ json: { ok: true, hostKey: { state: 'new', presented: { type: third.type, fingerprint: third.fingerprint } } } }); + }); + await trustDialog.getByRole('button', { name: 'Review current host', exact: true }).click(); + await trustDialog.getByRole('button', { name: 'Trust key', exact: true }).click(); await trustDialog.waitFor({ state: 'hidden' }); + await target.locator('.tc-cred-inspector').getByRole('button', { name: 'Close credential details' }).click(); + await edit('fixture-login'); editor = target.locator('.tc-cred-editor'); + await editor.getByRole('button', { name: 'Replace…', exact: true }).click(); await editor.locator('#cred-secret').fill(replacement); + const deleted = await store.public('fixture-login'); await store.remove({ id: deleted.id, revision: deleted.revision }); + await editor.locator('[data-cred-editor="save"]').click(); await editor.getByText('Deleted in another window', { exact: true }).waitFor(); + await editor.getByRole('button', { name: 'Save as new', exact: true }).click(); await editor.waitFor({ state: 'hidden' }); + check((await store.forUse('fixture-login')).secret === replacement, 'gone conflict can save as new with the typed key'); + const failRead = route => route.fulfill({ status: 500, json: { ok: false, code: 'store', error: 'Fixture read failed.' } }); + await page.route('**/credentials', failRead); await target.evaluate(() => window.dispatchEvent(new Event('focus'))); + await target.getByRole('alert').filter({ hasText: 'Fixture read failed.' }).waitFor(); + check(await target.locator('.tc-cred-row,.tc-cred-prow').count() === 0, 'read failure does not expose stale actionable rows'); await capture('read-error'); + await page.unroute('**/credentials', failRead); await target.getByRole('button', { name: 'Try again', exact: true }).click(); + await target.locator('[data-cred-action="inspect"][data-id="fixture-login"]').waitFor(); + await target.locator('[data-cred-action="inspect"][data-id="fixture-login"]').click(); + await target.locator('.tc-cred-inspector').getByRole('button', { name: 'Credential actions', exact: true }).click(); + await target.getByRole('menuitem', { name: 'Delete…', exact: true }).click(); + const deletion = target.getByRole('alertdialog'); await deletion.getByText('Backups of this volume still hold it', { exact: true }).waitFor(); await capture('delete'); + await deletion.getByRole('button', { name: 'Delete credential', exact: true }).click(); + await target.locator('[data-cred-action="inspect"][data-id="fixture-login"]').waitFor({ state: 'hidden' }); + await geometry(target, width, height, 'after delete'); + await target.waitForFunction(() => document.activeElement?.matches('#credential-list [data-cred-action="inspect"], #credential-list [data-cred-action="add"]')); + check(true, 'deletion restores focus to a remaining credential or Add'); + // Long metadata and a partial scan keep the list honest and usable. + const longId = 'long-' + 'a'.repeat(59); + const partialRead = async route => { + if (route.request().method() !== 'GET') return route.continue(); + const response = await route.fetch(), data = await response.json(); + data.usageWarnings = ['Fixture partial scan']; + data.credentials.push({ ...data.credentials.find(row => row.id === 'project-api'), id: longId, baseUrl: 'https://' + 'api.'.repeat(25) + 'example.com/' + 'long-path/'.repeat(18), test: null, usedIn: [{ name: 'a-very-long-skill-name-'.repeat(4), where: 'All agents', kind: 'skill' }, { name: 'AGENTS.md', kind: 'instructions' }] }); + return route.fulfill({ response, json: data }); + }; + await page.route('**/credentials', partialRead); await target.evaluate(() => window.dispatchEvent(new Event('focus'))); + await target.getByText('Some skills couldn’t be read. Usage may be incomplete.', { exact: true }).waitFor(); + await geometry(target, width, height, 'long metadata list'); + await target.evaluate(id => { location.hash = 'credentials/' + id; }, longId); + await target.locator('.tc-cred-inspector').getByRole('heading', { name: longId, exact: true }).waitFor(); + check((await target.locator('.tc-cred-inspector').innerText()).includes('At least 2 sources'), 'partial usage does not claim completeness'); + await geometry(target, width, height, 'long metadata inspector'); await capture('long-partial'); + await target.locator('.tc-cred-inspector').getByRole('button', { name: 'Close credential details' }).click(); + await page.unroute('**/credentials', partialRead); + + check(!(await target.content()).includes(secret) && !(await target.content()).includes(token) && !(await target.content()).includes(replacement), 'no saved or typed secrets remain in markup'); + for (const body of await Promise.all(responseBodies)) for (const value of [secret, token, replacement]) check(!body.includes(value), 'no credential response echoes a secret'); + assert.deepEqual(pageErrors, []); checks++; await page.close(); + console.log(`PASS credentials ${theme} ${width} ${mode} (${checks} checks so far)`); + } + check(!f.logs().includes(secret) && !f.logs().includes(token), 'service logs do not expose fixture credentials'); + console.log('PASS ' + checks + ' Credentials browser checks'); +} finally { await browser.close(); loopback.closeAllConnections(); await new Promise(resolve => loopback.close(resolve)); await f.close(); } diff --git a/scripts/credentials-visual-audit.mjs b/scripts/credentials-visual-audit.mjs new file mode 100644 index 0000000..2b9ec05 --- /dev/null +++ b/scripts/credentials-visual-audit.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Captures the real console with explicitly synthetic in-memory fixture data. +// Run console-preview.mjs first. Never point this at a production Setup URL. +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +let chromium; try { ({ chromium } = require('playwright')); } catch { ({ chromium } = require('playwright-core')); } +const { framed } = require('./lib/console-fixtures.cjs'); +const base = process.argv[2] || 'http://127.0.0.1:23966/__setup/'; +assert.ok(['127.0.0.1', 'localhost'].includes(new URL(base).hostname), 'Visual fixtures must run on loopback.'); +const output = process.env.T3_CREDENTIALS_VISUAL_OUTPUT || '/tmp/t3-credentials-visual'; await fs.mkdir(output, { recursive: true }); +const fixture = await (await fetch(new URL('credentials', base))).json(); +assert.equal(fixture.credentials.length, 5, 'Use the five-row console fixture.'); +const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/usr/bin/chromium', args: ['--no-sandbox'] }); +const themes = (process.env.T3_CREDENTIALS_VISUAL_THEMES || 'light,dark').split(','); +const cases = ['list', 'empty', 'inspect-ssh', 'inspect-api', 'add-ssh', 'add-api', 'conflict', 'host-key', 'delete']; +let captures = 0; +try { + for (const theme of themes) for (const mode of ['tab', 'phone']) for (const state of mode === 'phone' ? ['list', 'inspect-api', 'add-ssh'] : cases) { + if (process.env.T3_CREDENTIALS_VISUAL_STATES && !process.env.T3_CREDENTIALS_VISUAL_STATES.split(',').includes(state)) continue; + const width = mode === 'tab' ? 1040 : 390, height = mode === 'tab' ? 820 : 844; + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: mode === 'phone' ? 2 : 1, reducedMotion: 'reduce', colorScheme: theme }); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(value => localStorage.setItem('t3-console-theme', value), theme); + await page.addInitScript(value => { const NativeDate = Date; window.Date = class extends NativeDate { constructor(...args) { super(...(args.length ? args : [value])); } static now() { return value; } }; }, Date.parse(fixture.credentials.find(row => row.id === 'build-server').test.at) + 120000); + await page.route('**/credentials', async route => { + if (route.request().method() !== 'GET') return route.continue(); + const credentials = state === 'empty' ? [] : fixture.credentials.filter(row => !(state === 'add-ssh' && row.id === 'build-server') && !(state === 'add-api' && row.id === 'project-api')); + return route.fulfill({ json: { ...fixture, credentials } }); + }); + const url = new URL(base); url.search = 'embed=t3&host=tab&layout=' + (mode === 'phone' ? 'compact' : 'wide') + '&theme=' + theme; url.hash = 'credentials'; + const target = await framed(page, url.href); + await target.locator('#page-credentials:not([hidden]) .tc-page-title').waitFor(); + await target.getByText(state === 'empty' ? 'Keep secrets out of chats and skills' : 'Search credentials', { exact: true }).count(); + if (state === 'empty') await target.getByRole('button', { name: 'Add credential…', exact: true }).waitFor(); + else await target.locator('[data-cred-action="inspect"]').first().waitFor(); + await target.locator('#page-credentials .tc-page-title').click(); + if (state.startsWith('inspect') || state === 'host-key' || state === 'delete' || state === 'conflict') { + const id = state === 'inspect-ssh' || state === 'conflict' ? 'build-server' : state === 'host-key' ? 'staging-server' : mode === 'phone' ? 'home-assistant' : 'project-api'; + await target.locator('[data-cred-action="inspect"][data-id="' + id + '"]').click(); await target.locator('.tc-cred-inspector').waitFor(); + } + if (state === 'host-key') { await target.locator('.tc-cred-inspector [data-cred-inspect="review"]').click(); await target.locator('.tc-cred-dialog [data-cred-key="trust"]:enabled').waitFor(); } + if (state === 'delete') { await target.locator('.tc-cred-inspector [data-cred-inspect="menu"]').click(); await target.getByRole('menuitem', { name: 'Delete…', exact: true }).click(); await target.getByRole('alertdialog').waitFor(); } + if (state.startsWith('add')) { + await target.getByRole('button', { name: 'Add credential', exact: true }).click(); const editor = target.locator('.tc-cred-editor'); + await editor.getByLabel('Name', { exact: true }).fill(state === 'add-ssh' ? 'build-server' : 'project-api'); + if (state === 'add-api') { + await editor.getByRole('button', { name: 'HTTP API', exact: true }).click(); await editor.getByLabel('Base URL', { exact: true }).fill('https://api.example.com/v1/'); + await editor.getByRole('button', { name: 'Header', exact: true }).click(); await editor.getByLabel('Header name', { exact: true }).fill('X-API-Key'); await editor.getByLabel('GET test path (optional)', { exact: true }).fill('account'); + } else { await editor.getByLabel('Host', { exact: true }).fill('192.0.2.10'); await editor.getByLabel('Username', { exact: true }).fill('deploy'); } + await editor.locator('#cred-secret').fill('synthetic-visual-fixture'); + await editor.locator('[data-cred-editor="test"]').click(); + if (state === 'add-ssh') { await editor.getByLabel('Trust this host key', { exact: true }).check(); await editor.getByText('Signed in as deploy', { exact: true }).waitFor(); } + else await editor.getByText('Connected · HTTP 200', { exact: true }).waitFor(); + } + if (state === 'conflict') { + await target.locator('.tc-cred-inspector [data-cred-inspect="edit"]').click(); const editor = target.locator('.tc-cred-editor'); + await editor.getByRole('button', { name: 'Replace…', exact: true }).click(); await editor.locator('#cred-secret').fill('synthetic-replacement'); + const original = fixture.credentials.find(row => row.id === 'build-server'); + await page.route('**/credentials/save', route => route.fulfill({ status: 409, json: { ok: false, code: 'changed', error: 'Changed elsewhere.', current: { ...original, host: '192.0.2.11', updatedAt: new Date().toISOString(), secretChangedAt: new Date().toISOString(), revision: '0123456789abcdef' } } })); + await editor.locator('[data-cred-editor="save"]').click(); await editor.locator('#cred-conflict').waitFor(); + } + await target.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); + const geometry = await target.evaluate(() => ({ overflow: document.documentElement.scrollWidth > innerWidth + 1, panels: [...document.querySelectorAll('.tc-cred-editor,.tc-cred-dialog,.tc-cred-inspector')].map(el => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom }; }) })); + assert.equal(geometry.overflow, false); for (const r of geometry.panels) assert.ok(r.x >= -1 && r.y >= -1 && r.right <= width + 1 && r.bottom <= height + 1, JSON.stringify(r)); + await page.locator('iframe').screenshot({ path: path.join(output, `${theme}-${mode}-${state}.png`) }); captures++; + assert.deepEqual(errors, []); await page.close(); console.log('PASS visual ' + theme + ' ' + mode + ' ' + state); + } + console.log('PASS ' + captures + ' aligned Credentials visual captures'); +} finally { await browser.close(); } diff --git a/scripts/fixtures/console/credentials.json b/scripts/fixtures/console/credentials.json new file mode 100644 index 0000000..60a8303 --- /dev/null +++ b/scripts/fixtures/console/credentials.json @@ -0,0 +1,163 @@ +{ + "ok": true, + "usageAt": "2026-10-30T22:00:05Z", + "usageWarnings": [], + "credentials": [ + { + "id": "build-server", + "type": "ssh", + "host": "192.0.2.10", + "username": "deploy", + "port": 22, + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "9c1f0e7a3b5d4c21", + "test": { + "at": "2026-10-30T21:58:00Z", + "ok": true, + "code": "ok", + "ms": 1180, + "detail": { + "user": "deploy" + } + }, + "hostKey": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:p2QkL0n8sT4vYc1bZr7eWq9uKx3mHf6dJg2aNo5Xf7cA", + "trustedAt": "2026-10-26T18:04:30Z", + "carried": false + }, + "usedIn": [ + { + "kind": "skill", + "name": "deploy", + "where": "Claude Code, Codex" + }, + { + "kind": "skill", + "name": "server-health", + "where": "All agents" + } + ] + }, + { + "id": "home-assistant", + "type": "api", + "baseUrl": "https://ha.example.net/api/", + "authType": "bearer", + "testPath": "", + "hasSecret": true, + "createdAt": "2026-10-20T20:02:55Z", + "updatedAt": "2026-10-20T20:02:55Z", + "secretChangedAt": "2026-10-20T20:02:55Z", + "revision": "7b3c19e05d2a4f61", + "test": { + "at": "2026-10-29T18:40:03Z", + "ok": false, + "code": "rejected", + "ms": 312, + "detail": { + "status": 401 + } + }, + "hostKey": null, + "usedIn": [ + { + "kind": "skill", + "name": "lights", + "where": "All agents" + } + ] + }, + { + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "e6a4c2b0f8d6e4c2", + "test": { + "at": "2026-10-30T21:00:00Z", + "ok": true, + "code": "ok", + "ms": 184, + "detail": { + "status": 200 + } + }, + "hostKey": null, + "usedIn": [ + { + "kind": "skill", + "name": "project-sync", + "where": "Claude Code" + } + ], + "id": "project-api", + "type": "api", + "baseUrl": "https://api.example.com/v1/", + "authType": "header", + "authName": "X-API-Key", + "authPrefix": "", + "testPath": "account" + }, + { + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "e6a4c2b0f8d6e4c2", + "test": { + "at": "2026-10-30T21:45:00Z", + "ok": false, + "code": "host-key-changed", + "ms": 350, + "detail": { + "trusted": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:p2QkL0n8sT4vYc1bZr7eWq9uKx3mHf6dJg2aNo5Xf7cA" + }, + "presented": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:d1HvN3Qx0rZ8fBc5kW9sTa6mL7uE4oG2jYvIhPnScUs" + } + } + }, + "hostKey": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:p2QkL0n8sT4vYc1bZr7eWq9uKx3mHf6dJg2aNo5Xf7cA", + "trustedAt": "2026-10-26T18:04:11Z", + "carried": false + }, + "usedIn": [ + { + "kind": "skill", + "name": "deploy", + "where": "Claude Code" + } + ], + "id": "staging-server", + "type": "ssh", + "host": "192.0.2.20", + "port": 2222, + "username": "root" + }, + { + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "e6a4c2b0f8d6e4c2", + "test": null, + "hostKey": null, + "usedIn": [], + "id": "weather", + "type": "api", + "baseUrl": "https://api.weather.example/v3/", + "authType": "query", + "authName": "apikey", + "testPath": "" + } + ] +} diff --git a/scripts/lib/console-fixtures.cjs b/scripts/lib/console-fixtures.cjs index 4e28e28..d03399c 100644 --- a/scripts/lib/console-fixtures.cjs +++ b/scripts/lib/console-fixtures.cjs @@ -127,6 +127,7 @@ const routeConsole = async (page, { status, ports, skillOptions, downloadHTTP = if (at("/status")) return route.fulfill({ json: status() }); if (at("/ports")) return route.fulfill({ json: ports() }); if (at("/providers")) return route.fulfill({ json: fixture("providers.json") }); + if (at("/credentials")) return route.fulfill({ json: fixture("credentials.json") }); // Allow a native-shell substitution at the context level to run too. return route.fallback(); }); diff --git a/scripts/lib/credentials-fixtures.cjs b/scripts/lib/credentials-fixtures.cjs new file mode 100644 index 0000000..8d9ab06 --- /dev/null +++ b/scripts/lib/credentials-fixtures.cjs @@ -0,0 +1,62 @@ +// Synthetic, in-memory credentials. No connection or secret leaves this fixture. +const { randomBytes } = require('node:crypto'); +const { fixture, shiftTimes } = require('./console-fixtures.cjs'); +const clone = value => JSON.parse(JSON.stringify(value)); +const revision = () => randomBytes(8).toString('hex'); +function createCredentialsFixtures() { + const data = shiftTimes(fixture('credentials.json'), Date.now() - Date.parse('2026-10-30T22:00:00Z')); + const key = { type: 'ssh-ed25519', fingerprint: data.credentials.find(row => row.id === 'build-server').hostKey.fingerprint }; + const now = () => new Date().toISOString(); + const find = id => data.credentials.find(row => row.id === id); + const status = () => ({ count: data.credentials.length, attention: data.credentials.filter(row => row.type === 'ssh' && !row.hostKey || row.test?.ok === false).map(row => ({ id: row.id, code: row.type === 'ssh' && !row.hostKey ? 'host-key-untrusted' : row.test.code })) }); + const body = value => ({ body: { ok: true, ...clone(value) } }); + const fail = (code, error, extra = {}) => ({ status: 409, body: { ok: false, code, error, ...clone(extra) } }); + const scan = profile => { + const presented = profile.id === 'staging-server' && profile.test?.code === 'host-key-changed' ? profile.test.detail.presented : key; + return { state: !profile.hostKey ? 'new' : profile.hostKey.fingerprint === presented.fingerprint ? 'trusted' : 'changed', presented, ...(profile.hostKey ? { trusted: { type: profile.hostKey.type, fingerprint: profile.hostKey.fingerprint } } : {}) }; + }; + const respond = async (method, url, input = {}) => { + const route = url.pathname.replace(/^\/__setup/, ''); + if (!/^\/credentials(?:\/|$)/.test(route)) return null; + if (method === 'GET' && route === '/credentials') return body({ ...data, usageAt: now() }); + const profile = find(input.id || input.credential?.id); + if (route.endsWith('/create') || route.endsWith('/save')) { + if (route.endsWith('/create') && profile) return fail('taken', 'That name is already taken.'); + if (route.endsWith('/save') && !profile) return fail('gone', 'This credential was deleted elsewhere.'); + if (profile && input.revision !== profile.revision) return fail('changed', 'This credential changed elsewhere.', { current: profile }); + const { secret: _secret, ...visible } = input.credential; + const next = { ...profile, ...visible, revision: revision(), hasSecret: true, createdAt: profile?.createdAt || now(), updatedAt: now(), secretChangedAt: input.credential.secret ? now() : profile?.secretChangedAt || now(), test: null, usedIn: profile?.usedIn || [], hostKey: profile?.host === visible.host && profile?.port === visible.port ? profile.hostKey : null }; + if (input.trust?.fingerprint === key.fingerprint) next.hostKey = { ...key, trustedAt: now(), carried: false }; + data.credentials = [...data.credentials.filter(row => row.id !== next.id), next]; return body({ credential: next }); + } + if ((route.endsWith('/scan') || route.endsWith('/trust')) && input.id) { + if (!profile) return fail('gone', 'This credential is no longer saved.'); + if (input.revision !== undefined && input.revision !== profile.revision) return fail('changed', 'This credential changed elsewhere.', { current: profile }); + } + if (route.endsWith('/scan')) return body({ hostKey: scan(profile || input) }); + if (route.endsWith('/test')) { + const target = input.draft ? { ...profile, ...input.draft } : profile; + if (!target) return fail('gone', 'This credential is no longer saved.'); + const hostKey = target.type === 'ssh' ? scan(target) : null; + const result = hostKey && hostKey.state !== 'trusted' && input.trust?.fingerprint !== hostKey.presented.fingerprint + ? { ok: false, code: hostKey.state === 'changed' ? 'host-key-changed' : 'host-key-untrusted', detail: hostKey.trusted ? { trusted: hostKey.trusted, presented: hostKey.presented } : {}, hostKey } + : target.id === 'home-assistant' ? { ok: false, code: 'rejected', detail: { status: 401 } } : { ok: true, code: 'ok', detail: target.type === 'ssh' ? { user: target.username } : { status: 200 } }; + Object.assign(result, { ms: 184, at: now() }); if (!input.draft) profile.test = result; + return body({ result }); + } + if (!profile) return fail('gone', 'This credential is no longer saved.'); + if (route.endsWith('/trust')) { + const scanned = scan(profile); + if (input.fingerprint !== scanned.presented.fingerprint) return fail('key-changed', 'The server presented a different key.', { presented: scanned.presented }); + profile.hostKey = { ...scanned.presented, trustedAt: now(), carried: false }; profile.test = null; return body({ hostKey: profile.hostKey }); + } + if (route.endsWith('/forget-host-key')) { profile.hostKey = null; profile.test = null; return body({}); } + if (route.endsWith('/remove')) { + if (input.revision !== profile.revision) return fail('changed', 'This credential changed elsewhere.', { current: profile }); + data.credentials = data.credentials.filter(row => row !== profile); return body({}); + } + return { status: 404, body: { ok: false, error: 'Unknown fixture credential action.' } }; + }; + return { respond, status }; +} +module.exports = { createCredentialsFixtures }; diff --git a/scripts/setup-tab-audit.js b/scripts/setup-tab-audit.js index 987d886..78075f6 100644 --- a/scripts/setup-tab-audit.js +++ b/scripts/setup-tab-audit.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require('playwright'); -const { framed, routeConsole, visit, statusFor, portsFor } = require('./lib/console-fixtures.cjs'); +const { framed, routeConsole, visit, statusFor, portsFor, routesTable } = require('./lib/console-fixtures.cjs'); const BASE = process.argv[2] || 'http://127.0.0.1:23920/__setup/'; const OUT = process.env.T3_TAB_UI_OUTPUT; (async () => { @@ -27,7 +27,7 @@ const OUT = process.env.T3_TAB_UI_OUTPUT; assert.equal(await menu.getAttribute('role'),'menu'); assert.equal(await menu.locator('[aria-current=page]').textContent().then(t=>t.trim().startsWith('Agents')),true); assert.equal(await menu.locator('[data-menu-cancel]').count(),0); - assert.equal(await menu.locator('[role=menuitemradio]').count(),12); + assert.equal(await menu.locator('[role=menuitemradio]').count(),routesTable().PAGES.length); assert.equal(await menu.locator('[aria-current=page] > svg').count(),2); const box=await menu.boundingBox();assert(box.x>=0&&box.x+box.width<=width+1&&box.y>=0&&box.y+box.height<=900); const row=await menu.locator('[role=menuitemradio]').first().boundingBox();assert.equal(row.height,width<640?44:32); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index a519df0..43e2704 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -161,6 +161,13 @@ check "no harness executable is baked, only the root-owned dispatcher" only_the_ check "mise ships" "docker exec $NAME mise --version" check "harness installer ships" "docker exec $NAME t3-harness --help" check "storage CLI ships" "docker exec $NAME t3-storage --help" +check "SSH credential helper ships" "docker exec $NAME t3-ssh --help" +check "API credential helper ships" "docker exec $NAME t3-api --help" +credentials_roundtrip() { + docker cp "$SCRIPT_DIR/credentials-smoke.mjs" "$NAME:/tmp/t3-credentials-smoke.mjs" >/dev/null + docker exec -u t3 "$NAME" node /tmp/t3-credentials-smoke.mjs +} +check "saved credentials stay private through the setup API and installed helpers" credentials_roundtrip storage_cli_has_disk() { local output output="$(docker exec "$NAME" t3-storage status --json)" || return 1 @@ -824,6 +831,22 @@ mcp_editor_works() { } check "MCP controls refresh immediately and preserve editor revisions and values" mcp_editor_works +credentials_browser_works() { + [ "$HAS_BROWSER" -eq 1 ] || return 0 + local audit=/tmp/t3-credentials-audit + docker exec "$NAME" mkdir -p "$audit/scripts/lib" "$audit/scripts/fixtures" "$audit/tests/fixtures" "$audit/docker" || return 1 + docker exec "$NAME" ln -sfn /opt/t3-setup "$audit/docker/setup" || return 1 + docker cp scripts/credentials-ui-audit.mjs "$NAME:$audit/scripts/credentials-ui-audit.mjs" >/dev/null 2>&1 || return 1 + docker cp scripts/lib/console-fixtures.cjs "$NAME:$audit/scripts/lib/console-fixtures.cjs" >/dev/null 2>&1 || return 1 + docker cp scripts/fixtures/console "$NAME:$audit/scripts/fixtures/console" >/dev/null 2>&1 || return 1 + for helper in credentials.mjs credential-keys.mjs; do + docker cp "tests/fixtures/$helper" "$NAME:$audit/tests/fixtures/$helper" >/dev/null 2>&1 || return 1 + done + docker exec -e CHROME_PATH=/usr/bin/chromium -e NODE_PATH=/opt/t3-mcp/lib/node_modules/@playwright/mcp/node_modules -e T3_CREDENTIALS_UI_WIDTHS=390,640,1280 \ + "$NAME" node "$audit/scripts/credentials-ui-audit.mjs" >>"$UI_AUDIT_LOG" 2>&1 +} +check "Credentials drafts, host trust, conflicts and recovery work on desktop, phone and Settings" credentials_browser_works + files_browser_works() { [ "$HAS_BROWSER" -eq 1 ] || return 0 docker exec "$NAME" mkdir -p /tmp/t3-files-audit/lib /tmp/t3-files-audit/fixtures || return 1 diff --git a/tests/console-model.test.mjs b/tests/console-model.test.mjs index 32e67b3..1697e56 100644 --- a/tests/console-model.test.mjs +++ b/tests/console-model.test.mjs @@ -1512,3 +1512,14 @@ test("update check failures can be read for every source at once", () => { assert.equal(M.updateCheckView(status, null).failures.length, 2); assert.equal(M.updateCheckView(status, "harness").failures.length, 1); }); + +test('Credentials attention feeds Overview, navigation and host badges without counting untested credentials', () => { + const base = { credentials: { count: 3, attention: [{ id: 'build-server', code: 'host-key-untrusted' }, { id: 'project-api', code: 'rejected' }] } }; + assert.deepEqual(plain(M.navBadges(base).credentials), { tone: 'warn', count: 2 }); + const items = plain(M.needsYou(base)).filter(row => row.target === 'credential'); + assert.equal(items.length, 2); assert.equal(items[0].id, 'project-api'); + assert.equal(items[1].action.route, 'credentials/build-server'); assert.equal(items[1].tone, 'warn'); + assert.equal(M.navBadges({ credentials: { count: 3, attention: [] } }).credentials, null); + assert.equal(M.credentialState({ type: 'ssh', hostKey: null, test: { code: 'ok' } }).code, 'host-key-untrusted'); + assert.equal(M.credentialState({ type: 'api', test: { code: 'rejected', detail: { status: 401 } } }).label, 'Rejected · HTTP 401'); +}); diff --git a/tests/credential-model.test.mjs b/tests/credential-model.test.mjs new file mode 100644 index 0000000..274b6ae --- /dev/null +++ b/tests/credential-model.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import { readFileSync } from 'node:fs'; +const ctx = vm.createContext({ URL }); +for (const file of ['routes', 'model', 'mcp-command', 'credential-model']) vm.runInContext(readFileSync(new URL('../docker/setup/client/' + file + '.js', import.meta.url), 'utf8'), ctx); +const S = vm.runInContext('T3CredentialModel', ctx), plain = value => JSON.parse(JSON.stringify(value)); +const api = { id: 'project-api', type: 'api', baseUrl: 'https://api.example.test/v1', authType: 'header', authName: 'X-Key', authPrefix: 'Token', testPath: 'account' }; +test('drafts only copy public editable fields and previews always mask the authentication value', () => { + const draft = S.draftOf({ ...api, secret: 'never-copy-me', test: { detail: { secret: 'never-copy-me' } }, revision: 'never-copy-me' }); + assert.equal(JSON.stringify(draft).includes('never-copy-me'), false); + assert.equal(S.request(draft, true), 'GET /v1/account\nHost: api.example.test\nX-Key: Token ••••••••\naccept: application/json'); + assert.match(S.request({ ...draft, authType: 'query', authName: 'api key' }), /api\+key=••••••••/); + assert.equal(S.commands(draft)[0], 't3-api project-api GET account'); + assert.match(S.snippet(draft), /Don’t read or print credential files/); +}); +test('draft validation mirrors saved path and authentication restrictions with field errors', () => { + const valid = { ...S.draftOf(api), baseUrl: api.baseUrl }; + assert.deepEqual(plain(S.validate(valid, { secret: 'fixture' })), {}); + for (const [field, value] of [['id', 'Bad ID'], ['baseUrl', 'https://user:pass@example.com/v1/'], ['authName', 'Host'], ['authPrefix', 'Token\r\n'], ['testPath', '../escape'], ['testPath', '%2e%2e/escape']]) { + assert.ok(S.validate({ ...valid, [field]: value }, { secret: 'fixture' })[field], field); + } + assert.match(S.validate(valid, { secret: 'fixture', names: ['project-api'] }).id, /taken/); + assert.equal(S.validate(valid, { hasSecret: true, editing: true }).secret, undefined); + assert.ok(S.validate(valid).secret); +}); +test('result precedence, attention filtering and metadata searches agree with the server', () => { + const profile = { ...api, hasSecret: true, test: { ok: false, code: 'timeout' } }; + assert.equal(Boolean(S.needsAttention(profile, [])), false); + assert.equal(Boolean(S.needsAttention(profile, [{ id: profile.id }])), true); + assert.equal(S.state({ id: 'server', type: 'ssh', hostKey: null, test: { code: 'ok' } }).code, 'host-key-untrusted'); + assert.equal(S.state(profile).action, 'Test'); + assert.equal(S.matches(profile, 'x-key'), true); assert.equal(S.matches(profile, 'api.example'), true); assert.equal(S.matches(profile, 'secret'), false); + assert.deepEqual(plain(S.changes(api, { ...api, authName: 'X-Other', secretChangedAt: 'changed' })), ['authName']); +}); diff --git a/tests/credentials-api.test.mjs b/tests/credentials-api.test.mjs new file mode 100644 index 0000000..2797e79 --- /dev/null +++ b/tests/credentials-api.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { credentialsFixture } from './fixtures/credentials.mjs'; + +test('credential HTTP boundary is authenticated, write-only and revision checked', { timeout: 20000 }, async t => { + const f = await credentialsFixture(); t.after(f.close); + for (const route of ['/credentials', '/credentials/create', '/credentials/save', '/credentials/test', '/credentials/remove', '/credentials/scan', '/credentials/trust', '/credentials/forget-host-key']) { + const method = route === '/credentials' ? 'GET' : 'POST'; + assert.equal((await fetch(f.base + route, { method })).status, 401); + } + assert.equal((await f.request('/credentials/create', {}, { 'sec-fetch-site': 'cross-site' })).status, 403); + assert.equal((await f.request('/credentials', {})).status, 405); + assert.equal((await f.request('/credentials/create')).status, 405); + const initial = await (await f.request('/credentials')).json(); + assert.deepEqual(initial.credentials, []); + const secret = 'fixture-http-credential-456'; + const profile = { id: 'staging-server', label: 'Staging server', type: 'ssh', host: '192.0.2.2', username: 'root', port: 22, secret }; + const created = await (await f.request('/credentials/create', { credential: profile })).json(); + assert.equal(created.ok, true); assert.equal(JSON.stringify(created).includes(secret), false); + const snapshotResponse = await f.request('/credentials'); + assert.equal(snapshotResponse.headers.get('cache-control'), 'no-store'); + const snapshot = await snapshotResponse.json(); assert.equal(JSON.stringify(snapshot).includes(secret), false); + assert.equal(snapshot.credentials[0].hasSecret, true); + assert.equal((await f.request('/credentials/save', { credential: profile, revision: 'stale' })).status, 409); + assert.equal((await f.request('/credentials/create', { credential: profile })).status, 409); + assert.equal((await f.request('/credentials/save', { credential: { ...profile, secret: '' }, revision: created.credential.revision })).status, 200); + const malformed = await fetch(f.base + '/credentials/create', { method: 'POST', headers: { 'x-t3-setup-key': 'credential-fixture-key' }, body: '{"secret":"' + secret }); + assert.equal(malformed.status, 400); assert.equal((await malformed.text()).includes(secret), false); + const oversized = await fetch(f.base + '/credentials/create', { method: 'POST', headers: { 'x-t3-setup-key': 'credential-fixture-key' }, body: JSON.stringify({ secret: secret.repeat(4000) }) }); + assert.equal(oversized.status, 413); assert.equal((await oversized.text()).includes(secret), false); + assert.equal((await f.request('/credentials/create', null)).status, 400); + const latest = await (await f.request('/credentials')).json(); + assert.equal((await f.request('/credentials/test', { id: profile.id, draft: { ...profile, id: 'different', secret: '' } })).status, 400); + assert.equal((await f.request('/credentials/remove', { id: profile.id, revision: latest.credentials[0].revision })).status, 200); + assert.deepEqual((await (await f.request('/credentials')).json()).credentials, []); + assert.equal(f.logs().includes(secret), false); +}); + +test('saved API token works through the CLI and connection test without returning the token', { timeout: 20000 }, async t => { + const f = await credentialsFixture(); t.after(f.close); + const secret = 'fixture-api-token-789'; let target, failAuth = false; + const server = createServer(async (req, res) => { + assert.equal(req.headers.authorization, 'Bearer ' + secret); + const chunks = []; for await (const chunk of req) chunks.push(chunk); + target = { url: req.url, method: req.method, body: Buffer.concat(chunks).toString() }; + if (failAuth) { res.writeHead(401); res.end(secret); return; } + res.end(JSON.stringify({ message: secret, ok: true })); + }); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); })); + const initial = await (await f.request('/credentials')).json(); + const saved = await (await f.request('/credentials/create', { credential: { id: 'project-api', label: 'Project API', type: 'api', baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/', secret } })).json(); + const probe = await f.request('/credentials/test', { id: 'project-api', revision: saved.revision }); + assert.equal(probe.status, 200); assert.equal((await probe.text()).includes(secret), false); + failAuth = true; + const rejected = await f.request('/credentials/test', { id: 'project-api', revision: saved.revision }); + assert.equal(rejected.status, 200); assert.equal((await rejected.clone().json()).result.code, 'rejected'); assert.equal((await rejected.text()).includes(secret), false); + failAuth = false; + const helperEnv = { ...f.env, T3_INFRA_NODE: process.execPath, T3_CREDENTIAL_CLI: fileURLToPath(new URL('../docker/setup/credentials/cli.mjs', import.meta.url)) }; + const child = spawn('/bin/bash', [fileURLToPath(new URL('../docker/bin/t3-api', import.meta.url)), 'project-api', 'POST', 'projects', '--stdin'], { env: helperEnv, stdio: ['pipe', 'pipe', 'pipe'] }); + let out = '', err = ''; child.stdout.on('data', chunk => { out += chunk; }); child.stderr.on('data', chunk => { err += chunk; }); + child.stdin.end('{"name":"demo-project"}'); + const [code] = await once(child, 'close'); + assert.equal(code, 0, err); assert.equal(err, ''); + assert.deepEqual(JSON.parse(out), { message: '[redacted]', ok: true }); + assert.deepEqual(target, { url: '/api/projects', method: 'POST', body: '{"name":"demo-project"}' }); + const help = spawn('/bin/bash', [fileURLToPath(new URL('../docker/bin/t3-ssh', import.meta.url)), '--help'], { env: helperEnv, stdio: ['ignore', 'pipe', 'pipe'] }); + let helpText = ''; help.stdout.on('data', chunk => { helpText += chunk; }); + assert.equal((await once(help, 'close'))[0], 0); assert.match(helpText, /t3-ssh CONNECTION/); + assert.equal(f.logs().includes(secret), false); +}); diff --git a/tests/credentials-service.test.mjs b/tests/credentials-service.test.mjs new file mode 100644 index 0000000..c7b3ac0 --- /dev/null +++ b/tests/credentials-service.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createCredentialStore } from '../docker/setup/credentials.mjs'; +import { createCredentialIO } from '../docker/setup/credentials/io.mjs'; +import { createHostKeys, parseKey, reachHost } from '../docker/setup/credentials/hostkeys.mjs'; +import { createCredentialsService } from '../docker/setup/credentials/service.mjs'; +import { apiRequest, sshRequest } from '../docker/setup/credentials/run.mjs'; +import { credentialKey } from './fixtures/credential-keys.mjs'; +const ssh = { id: 'build-server', type: 'ssh', host: '192.0.2.10', port: 22, username: 'deploy', secret: 'synthetic-pass' }; +const api = { id: 'project-api', type: 'api', baseUrl: 'https://api.example.test/v1/', secret: 'synthetic-token' }; +const usage = { invalidate() {}, async annotate(credentials) { return { credentials: credentials.map(row => ({ ...row, usedIn: [] })), usageAt: new Date().toISOString(), usageWarnings: [] }; } }; +async function fixture(t, options = {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'credential-service-')), io = createCredentialIO({ directory }); + const key = credentialKey(); let offered = key, reachable = { ok: true }, signins = 0, scans = 0; + const keys = createHostKeys({ io, reach: async () => reachable, run: async (program, args) => { + if (program.endsWith('keyscan')) { scans++; if (!offered) return { code: 1, stdout: '' }; return { code: 0, stdout: `host ${offered.type} ${offered.key}\n` }; } + return { code: 0, stdout: offered ? '256 ' + offered.fingerprint + ' (ED25519)' : '' }; + } }); + const store = createCredentialStore({ directory, io, hostKeys: keys }); + const service = createCredentialsService({ store, usage, test: async (profile, options) => { signins++; return options.test ? options.test(profile) : { ok: true, code: 'ok', detail: { user: profile.username } }; }, ...options }); + t.after(() => { service.close(); return fs.rm(directory, { recursive: true, force: true }); }); + return { directory, io, key, keys, store, service, create: credential => store.save({ create: true, credential }), offered(value) { offered = value; }, reachable(value) { reachable = value; }, get signins() { return signins; }, get scans() { return scans; } }; +} + +test('scan only reads host details and never signs in, persists state or reads a secret', async t => { + const f = await fixture(t); await f.create(ssh); + const before = await fs.readFile(path.join(f.directory, 'state.json'), 'utf8'); + const result = await f.service.handle('/credentials/scan', { id: ssh.id }); + assert.equal(result.hostKey.state, 'new'); assert.equal(result.hostKey.presented.fingerprint, f.key.fingerprint); assert.equal(f.signins, 0); + assert.equal(await fs.readFile(path.join(f.directory, 'state.json'), 'utf8'), before); + assert.equal(JSON.stringify(result).includes(ssh.secret), false); +}); + +test('saved tests block before authentication until explicit fresh fingerprint trust', async t => { + const f = await fixture(t), saved = (await f.create(ssh)).credential; + let result = await f.service.handle('/credentials/test', { id: ssh.id }); + assert.equal(result.ok, true); assert.equal(result.result.code, 'host-key-untrusted'); assert.equal(f.signins, 0); + await f.service.handle('/credentials/trust', { id: ssh.id, fingerprint: f.key.fingerprint }); + result = await f.service.handle('/credentials/test', { id: ssh.id }); + assert.equal(result.result.code, 'ok'); assert.equal(f.signins, 1); + const next = credentialKey(); f.offered(next); + result = await f.service.handle('/credentials/test', { id: ssh.id }); + assert.equal(result.result.code, 'host-key-changed'); assert.equal(f.signins, 1); + assert.equal(result.result.detail.trusted.fingerprint, f.key.fingerprint); + assert.equal(result.result.detail.presented.fingerprint, next.fingerprint); + assert.equal((await f.store.public(ssh.id)).revision, saved.revision); + await assert.rejects(f.service.handle('/credentials/trust', { id: ssh.id, fingerprint: f.key.fingerprint }), { status: 409, code: 'key-changed' }); + assert.equal((await f.store.public(ssh.id)).hostKey.fingerprint, f.key.fingerprint); +}); + +test('draft tests pin only the fingerprint explicitly approved and leave no saved traces', async t => { + let calls = 0, temporary; + const f = await fixture(t, { test: async profile => { + calls++; temporary = path.dirname(profile.knownHostsFile); + assert.equal((await fs.stat(profile.knownHostsFile)).mode & 0o777, 0o600); + assert.equal(await fs.readFile(profile.knownHostsFile, 'utf8'), `${ssh.id} ${f.key.type} ${f.key.key}\n`); + return { ok: true, code: 'ok', detail: { user: profile.username } }; + } }); + const before = await fs.readdir(f.directory); + let result = await f.service.handle('/credentials/test', { draft: ssh }); assert.equal(result.result.code, 'host-key-untrusted'); + result = await f.service.handle('/credentials/test', { draft: ssh, trust: { fingerprint: f.key.fingerprint } }); + assert.equal(result.result.code, 'ok'); assert.equal(calls, 1); + await assert.rejects(fs.stat(temporary), { code: 'ENOENT' }); + assert.deepEqual(await fs.readdir(f.directory), before); + f.offered(credentialKey()); + result = await f.service.handle('/credentials/test', { draft: ssh, trust: { fingerprint: f.key.fingerprint } }); + assert.equal(result.result.code, 'host-key-changed'); assert.equal(calls, 1); +}); + +test('a changed draft target must get fresh trust even when the host offers the same key', async t => { + const f = await fixture(t); const saved = (await f.create(ssh)).credential; + await f.store.trust({ id: ssh.id, revision: saved.revision, key: f.key }); + let answer = await f.service.handle('/credentials/test', { id: ssh.id, draft: { ...ssh, secret: '' } }); + assert.equal(answer.result.code, 'ok'); + answer = await f.service.handle('/credentials/test', { id: ssh.id, draft: { ...ssh, host: 'other-host', secret: '' } }); + assert.equal(answer.result.code, 'host-key-untrusted'); + for (const draft of [{ ...ssh, id: 'other', secret: '' }, { ...api, id: ssh.id, secret: '' }]) await assert.rejects(f.service.handle('/credentials/test', { id: ssh.id, draft }), { status: 400 }); +}); + +test('saving comes before optional trust and exposes partial trust failure honestly', async t => { + const f = await fixture(t), oldKey = f.key; f.offered(credentialKey()); + const answer = await f.service.handle('/credentials/create', { credential: ssh, trust: { fingerprint: oldKey.fingerprint } }); + assert.equal(answer.ok, true); assert.equal(answer.credential.id, ssh.id); assert.equal(answer.trust.code, 'key-changed'); + assert.equal(answer.credential.hostKey, null); assert.equal((await f.store.list()).credentials.length, 1); + const saved = await f.service.handle('/credentials/save', { credential: { ...ssh, secret: '' }, revision: answer.credential.revision, trust: { fingerprint: answer.trust.presented.fingerprint } }); + assert.equal(saved.trust.ok, true); assert.equal(saved.credential.hostKey.fingerprint, answer.trust.presented.fingerprint); +}); + +test('a credential edited during trust scanning cannot acquire trust for an obsolete target', async t => { + const f = await fixture(t); const saved = (await f.create(ssh)).credential; + const oldInspect = f.keys.inspect; + f.keys.inspect = async (...args) => { const answer = await oldInspect(...args); await f.store.save({ credential: { ...ssh, host: 'other-host' }, revision: saved.revision }); return answer; }; + await assert.rejects(f.service.handle('/credentials/trust', { id: ssh.id, fingerprint: f.key.fingerprint }), { code: 'changed' }); + assert.equal((await f.store.public(ssh.id)).hostKey, null); +}); + +test('unreachable, timeout and scans without keys never attempt authentication', async t => { + const f = await fixture(t); await f.create(ssh); + for (const code of ['unreachable', 'timeout']) { f.reachable({ ok: false, code, detail: {} }); assert.equal((await f.service.handle('/credentials/test', { id: ssh.id })).result.code, code); } + f.reachable({ ok: true }); f.offered(null); + assert.equal((await f.service.handle('/credentials/test', { id: ssh.id })).result.code, 'scan-failed'); assert.equal(f.signins, 0); +}); + +test('three in-flight tests are allowed; excess checks are refused and shutdown aborts all', async t => { + const waiting = [], f = await fixture(t, { test: async (_profile, { signal }) => new Promise(resolve => { waiting.push(() => resolve({ ok: true, code: 'ok' })); signal.addEventListener('abort', () => resolve({ ok: false, code: 'timeout' }), { once: true }); }) }); + await f.create(api); + const jobs = [1, 2, 3].map(() => f.service.handle('/credentials/test', { id: api.id })); + // All three reserve their lease synchronously before any store read. + await assert.rejects(f.service.handle('/credentials/test', { id: api.id }), { status: 409, code: 'busy' }); + while (waiting.length < 3) await new Promise(resolve => setImmediate(resolve)); + f.service.close(); const results = await Promise.all(jobs); assert.ok(results.every(answer => answer.result.code === 'timeout')); + await assert.rejects(f.service.handle('/credentials/test', { id: api.id }), { status: 503 }); +}); + +for (const [status, code] of [[200, 'ok'], [204, 'ok'], [401, 'rejected'], [403, 'rejected'], [404, 'not-found'], [500, 'http-error'], [302, 'redirect']]) test('API test classifies HTTP ' + status + ' without a response body or header', async () => { + let cancelled = 0; + const result = await apiRequest(api, 'GET', '', { probe: true, fetchRequest: async (_url, options) => { + assert.equal(options.redirect, 'manual'); return { status, ok: status < 300, body: { cancel() { cancelled++; } }, headers: { secret: api.secret } }; + } }); + assert.equal(result.code, code); assert.deepEqual(result.detail, { status }); assert.equal(cancelled, 1); assert.equal(JSON.stringify(result).includes(api.secret), false); +}); +for (const [error, code] of [[{ cause: { code: 'CERT_HAS_EXPIRED' } }, 'certificate'], [{ cause: { code: 'ECONNREFUSED' } }, 'unreachable'], [{ name: 'AbortError' }, 'timeout']]) test('API test classifies ' + code + ' without returning diagnostics', async () => { + const result = await apiRequest(api, 'GET', '', { probe: true, fetchRequest: async () => { throw { ...error, message: api.secret }; } }); assert.equal(result.code, code); assert.deepEqual(result.detail, {}); +}); + +test('SSH helper refuses an untrusted credential before starting a process', async () => { + let spawned = false; + await assert.rejects(sshRequest(ssh, ['true'], { spawnProcess() { spawned = true; } }), { code: 'host-key-untrusted' }); + assert.equal(spawned, false); +}); + +test('host key parser ignores markers and fingerprints a real public key', () => { + const key = credentialKey(); assert.equal(parseKey('host ' + key.type + ' ' + key.key).fingerprint, key.fingerprint); + for (const line of ['# comment', '@revoked host ' + key.type + ' ' + key.key, 'host bad key', 'host ssh-ed25519 bad']) assert.equal(parseKey(line), null); +}); + +for (const [clientLine, remoteLine, status, code] of [ + ['', 'Permission denied\n', 0, 'ok'], ['', 'Permission denied\n', 1, 'failed'], + ['Permission denied (password).', '', 255, 'auth-rejected'], ['Host key verification failed.', '', 255, 'host-key-changed'], + ['ssh: connect to host: Connection refused', '', 255, 'unreachable'], ['Connection timed out', '', 255, 'timeout'], + ['unexpected synthetic-pass message', '', 255, 'failed'], +]) test('SSH client diagnostics classify ' + code + ' independently of remote stderr', async () => { + const { spawn } = await import('node:child_process'); + const result = await sshRequest({ ...ssh, knownHostsFile: '/synthetic/pinned-key', hostKeyType: 'ssh-ed25519' }, ['true'], { probe: true, stdin: 'ignore', spawnProcess(_program, args, options) { + const log = args[args.indexOf('-E') + 1]; + return spawn(process.execPath, ['-e', "require('node:fs').writeFileSync(process.env.CLIENT_LOG,process.env.CLIENT_LINE);process.stderr.write(process.env.REMOTE_LINE);process.exitCode=Number(process.env.RESULT_CODE)"], { ...options, env: { ...options.env, CLIENT_LOG: log, CLIENT_LINE: clientLine, REMOTE_LINE: remoteLine, RESULT_CODE: String(status) } }); + } }); + assert.equal(result.code, code); assert.equal(JSON.stringify(result).includes(ssh.secret), false); +}); + +test('a review opened before an address change cannot show or trust keys for a new target', async t => { + const f = await fixture(t), original = (await f.create(ssh)).credential; + await f.store.save({ credential: { ...ssh, host: 'other-host' }, revision: original.revision }); + for (const route of ['/credentials/scan', '/credentials/trust']) { + await assert.rejects(f.service.handle(route, { id: original.id, revision: original.revision, fingerprint: f.key.fingerprint }), { status: 409, code: 'changed' }); + } + assert.equal(f.scans, 0); assert.equal(f.signins, 0); assert.equal((await f.store.public(ssh.id)).hostKey, null); +}); diff --git a/tests/credentials-store.test.mjs b/tests/credentials-store.test.mjs new file mode 100644 index 0000000..066db68 --- /dev/null +++ b/tests/credentials-store.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createCredentialStore, credentialFailure } from '../docker/setup/credentials.mjs'; +import { createCredentialIO } from '../docker/setup/credentials/io.mjs'; +import { createHostKeys } from '../docker/setup/credentials/hostkeys.mjs'; +import { credentialKey } from './fixtures/credential-keys.mjs'; +const ssh = { id: 'build-server', label: 'No longer stored', type: 'ssh', host: '192.0.2.10', port: 22, username: 'deploy', secret: 'synthetic-private-password' }; +const api = { id: 'project-api', type: 'api', baseUrl: 'https://api.example.test/v1/', secret: 'synthetic-private-token' }; +async function fixture(t, options = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'credential-store-')), directory = path.join(root, 'credentials'); + const io = createCredentialIO({ directory, effects: options.effects || fs }), key = credentialKey(); + const hostKeys = createHostKeys({ io, home: root, ...options.keys }); + const store = createCredentialStore({ directory, io, hostKeys, ...options.store }); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return { root, directory, io, hostKeys, key, store, create: credential => store.save({ credential, create: true }) }; +} + +test('v2 stores immutable names, independent random revisions and server-owned dates', async t => { + let ms = Date.parse('2026-10-30T10:00:00Z'); + const { store, create, directory } = await fixture(t, { store: { clock: () => ms } }); + const a = (await create({ ...ssh, revision: 'bad', createdAt: 'bad' })).credential; + const b = (await create(api)).credential; + assert.match(a.revision, /^[a-f0-9]{16}$/); assert.notEqual(a.revision, b.revision); + assert.equal(a.createdAt, '2026-10-30T10:00:00.000Z'); assert.equal(a.secretChangedAt, a.createdAt); + ms += 1000; + const edited = (await store.save({ credential: { ...ssh, secret: '', username: 'other' }, revision: a.revision })).credential; + assert.equal(edited.createdAt, a.createdAt); assert.notEqual(edited.updatedAt, a.updatedAt); assert.equal(edited.secretChangedAt, a.secretChangedAt); + await store.save({ credential: { ...api, secret: '' }, revision: b.revision }); + const raw = JSON.parse(await fs.readFile(path.join(directory, 'credentials.json'), 'utf8')); + assert.equal(raw.version, 2); assert.equal(Object.hasOwn(raw.profiles[0], 'label'), false); + assert.equal(JSON.stringify(await store.list()).includes(ssh.secret), false); + await assert.rejects(store.save({ credential: { ...ssh, type: 'api', baseUrl: api.baseUrl }, revision: edited.revision }), /change its type/); + await assert.rejects(store.draft({ id: ssh.id, draft: { ...ssh, id: 'different', secret: '' } }), /name cannot change/); + await assert.rejects(store.draft({ id: ssh.id, draft: { ...api, id: ssh.id, secret: '' } }), /change its type/); + await assert.rejects(store.remove({ id: '../escape', revision: '' }), { code: 'invalid' }); +}); + +test('migration drops labels, sets mtime dates and carries a single existing key without changing user known_hosts', async t => { + const carried = credentialKey(), { directory, root, io, store } = await fixture(t, { keys: { run: async (program, args) => { + assert.equal(program, '/usr/bin/ssh-keygen'); assert.deepEqual(args.slice(0, 2), ['-F', '[192.0.2.10]:2222']); + return { code: 0, stdout: '# comment\n|1|hash|hash ' + carried.type + ' ' + carried.key + '\n' }; + } } }); + await io.prepare(); await fs.mkdir(path.join(root, '.ssh')); const userFile = path.join(root, '.ssh/known_hosts'); await fs.writeFile(userFile, 'person-owned'); + const file = path.join(directory, 'credentials.json'); + await fs.writeFile(file, JSON.stringify({ version: 1, profiles: [{ ...ssh, port: 2222 }, api] }), { mode: 0o600 }); + const before = await fs.stat(file), listed = (await store.list()).credentials; + assert.equal(listed[0].createdAt, before.mtime.toISOString()); assert.equal(listed[0].secretChangedAt, null); + assert.equal(listed[0].hostKey.fingerprint, carried.fingerprint); assert.equal(listed[0].hostKey.carried, true); + assert.equal(await fs.readFile(userFile, 'utf8'), 'person-owned'); + assert.equal(await fs.readFile(path.join(directory, 'known_hosts', ssh.id), 'utf8'), `${ssh.id} ${carried.type} ${carried.key}\n`); + assert.equal((await fs.stat(path.join(directory, 'known_hosts', ssh.id))).mode & 0o777, 0o600); + assert.deepEqual((await fs.readdir(directory)).sort(), ['credentials.json', 'known_hosts', 'state.json']); +}); + +test('migration failure leaves the exact v1 bytes and does not leave carried trust', async t => { + const key = credentialKey(); let breakStore = false; + const { store, io, directory } = await fixture(t, { effects: { ...fs, rename: async (a, b) => { if (breakStore && b.endsWith('/credentials.json')) { breakStore = false; throw new Error('injected failure'); } return fs.rename(a, b); } }, keys: { legacy: undefined, run: async () => ({ code: 0, stdout: 'old ' + key.type + ' ' + key.key }) } }); + await io.prepare(); const raw = JSON.stringify({ version: 1, profiles: [ssh] }); + await fs.writeFile(path.join(directory, 'credentials.json'), raw, { mode: 0o600 }); breakStore = true; + await assert.rejects(store.initialize(), { code: 'store' }); + assert.equal(await fs.readFile(path.join(directory, 'credentials.json'), 'utf8'), raw); + await assert.rejects(fs.stat(path.join(directory, 'known_hosts', ssh.id)), { code: 'ENOENT' }); + assert.equal((await store.list()).credentials[0].hostKey.carried, true, 'a repaired service can retry migration'); +}); + +test('helpers only read: no chmod, creation, migration, lock or state access', async t => { + const { directory, create, store, io } = await fixture(t); + const saved = (await create(api)).credential; + const before = await fs.readdir(directory); + const readOnly = createCredentialStore({ directory, readOnly: true }); + const modeBefore = (await fs.stat(path.join(directory, 'credentials.json'))).mode; + await fs.writeFile(path.join(directory, 'state.json'), '{invalid'); + assert.equal((await readOnly.forUse(api.id)).secret, api.secret); + assert.equal((await fs.stat(path.join(directory, 'credentials.json'))).mode, modeBefore); + assert.deepEqual((await fs.readdir(directory)).sort(), before.sort()); + await assert.rejects(readOnly.save({ credential: api, revision: saved.revision }), { code: 'read-only' }); + await fs.writeFile(path.join(directory, 'credentials.json'), JSON.stringify({ version: 1, profiles: [api] })); + await assert.rejects(readOnly.forUse(api.id), /Open Setup once/); + assert.equal(JSON.parse(await fs.readFile(path.join(directory, 'credentials.json'))).version, 1); + await store.initialize(); + await fs.chmod(path.join(directory, 'credentials.json'), 0o644); + await assert.rejects(readOnly.forUse(api.id)); + await store.list(); assert.equal((await fs.stat(path.join(directory, 'credentials.json'))).mode & 0o777, 0o600); + const missing = path.join(directory, 'absent'); + await assert.rejects(createCredentialStore({ directory: missing, readOnly: true }).forUse(api.id), { code: 'not-found' }); + await assert.rejects(fs.stat(missing), { code: 'ENOENT' }); +}); + +test('state damage is disposable and cannot confer or erase file-based host trust', async t => { + const { store, create, key, directory } = await fixture(t); + const a = (await create(ssh)).credential; + await store.trust({ id: a.id, revision: a.revision, key }); + const file = path.join(directory, 'state.json'); + await fs.writeFile(file, '{broken'); + const row = await store.public(a.id); + assert.equal(row.hostKey.fingerprint, key.fingerprint); assert.equal(row.hostKey.carried, false); assert.equal(row.test, null); + await store.recordTest({ id: a.id, revision: a.revision, hostFingerprint: key.fingerprint, result: { ok: true, code: 'ok', ms: 3, detail: { user: 'deploy', secret: ssh.secret } } }); + assert.equal((await store.public(a.id)).test.code, 'ok'); assert.equal(JSON.stringify(JSON.parse(await fs.readFile(file))).includes(ssh.secret), false); + await fs.rm(path.join(directory, 'known_hosts', a.id)); + assert.equal((await store.public(a.id)).hostKey, null); assert.equal((await store.forUse(a.id)).knownHostsFile, null); + assert.deepEqual((await store.summary()).attention, [{ id: a.id, code: 'host-key-untrusted' }]); +}); + +test('tests never bump revisions; late tests and removed trust never overwrite newer state', async t => { + const { store, create, key, directory } = await fixture(t); + let a = (await create(ssh)).credential; + await store.trust({ id: a.id, revision: a.revision, key }); + const before = await fs.readFile(path.join(directory, 'credentials.json'), 'utf8'); + const record = result => store.recordTest({ id: a.id, revision: a.revision, hostFingerprint: key.fingerprint, result: { ms: 3, detail: {}, ...result } }); + await record({ ok: false, code: 'timeout' }); assert.deepEqual((await store.summary()).attention, []); + await record({ ok: false, code: 'timeout' }); assert.equal((await store.summary()).attention[0].code, 'timeout'); + await record({ ok: true, code: 'ok' }); assert.deepEqual((await store.summary()).attention, []); + assert.equal(await fs.readFile(path.join(directory, 'credentials.json'), 'utf8'), before); + const old = a; a = (await store.save({ credential: { ...ssh, username: 'other', secret: '' }, revision: a.revision })).credential; + assert.equal(a.test, null); assert.equal(a.hostKey.fingerprint, key.fingerprint); + assert.equal(await store.recordTest({ id: a.id, revision: old.revision, hostFingerprint: key.fingerprint, result: { ok: true, code: 'ok' } }), false); + await store.forget(a.id); assert.equal(await record({ ok: true, code: 'ok' }), false); +}); + +test('host/port changes and deletion remove both metadata and the credential-specific key', async t => { + const { store, create, key, directory } = await fixture(t); + let a = (await create(ssh)).credential; + await store.trust({ id: a.id, revision: a.revision, key }); + const changed = await store.save({ credential: { ...ssh, port: 2222, secret: '' }, revision: a.revision }); + assert.equal(changed.credential.hostKey, null); + await assert.rejects(fs.stat(path.join(directory, 'known_hosts', a.id)), { code: 'ENOENT' }); + a = changed.credential; await store.trust({ id: a.id, revision: a.revision, key }); + const missingSecret = { ...a }; delete missingSecret.secret; + assert.equal((await store.draft({ id: a.id, draft: missingSecret })).knownHostsFile.endsWith('/' + a.id), true); + assert.equal((await store.draft({ id: a.id, draft: { ...missingSecret, host: 'other-host' } })).knownHostsFile, undefined); + await store.remove({ id: a.id, revision: a.revision }); + assert.equal(await store.recordTest({ id: a.id, revision: a.revision, result: { ok: true, code: 'ok' } }), false); + const metadata = JSON.parse(await fs.readFile(path.join(directory, 'state.json'))); + assert.deepEqual(metadata.tests, {}); assert.deepEqual(metadata.hostKeys, {}); + await assert.rejects(store.save({ credential: ssh, revision: a.revision }), { code: 'gone' }); +}); + +for (const damage of ['hardlink', 'symlink', 'oversize', 'fifo']) test('credential reads reject ' + damage + ' without touching its target', async t => { + const { store, create, directory, root } = await fixture(t); await create(api); + const file = path.join(directory, 'credentials.json'), external = path.join(root, 'external'); + await fs.rename(file, external); + if (damage === 'hardlink') await fs.link(external, file); + if (damage === 'symlink') await fs.symlink(external, file); + if (damage === 'oversize') await fs.writeFile(file, ' '.repeat(512 * 1024 + 1), { mode: 0o600 }); + if (damage === 'fifo') { const { execute } = await import('../docker/setup/credentials/hostkeys.mjs'); await execute('/usr/bin/mkfifo', [file]); } + const before = await fs.readFile(external, 'utf8'); + await assert.rejects(store.list()); + assert.equal(await fs.readFile(external, 'utf8'), before); +}); + +test('state recovery replaces its directory entry without following a symlink', async t => { + const { store, create, directory, root } = await fixture(t); const a = (await create(api)).credential; + const external = path.join(root, 'external'), file = path.join(directory, 'state.json'); + await fs.writeFile(external, 'keep this'); await fs.rm(file); await fs.symlink(external, file); + await store.recordTest({ id: a.id, revision: a.revision, result: { ok: false, code: 'failed', detail: { line: api.secret + ' and ' + Buffer.from(api.secret).toString('base64') } } }); + assert.equal(await fs.readFile(external, 'utf8'), 'keep this'); + assert.equal((await fs.lstat(file)).isSymbolicLink(), false); + assert.equal(JSON.stringify(await store.list()).includes(api.secret), false); +}); + +test('replacement timestamps advance even when two writes share a clock tick', async t => { + const { store, create } = await fixture(t, { store: { clock: () => Date.parse('2026-10-30T10:00:00Z') } }); + const a = (await create(api)).credential; + const b = (await store.save({ credential: { ...api, secret: 'a-different-synthetic-token' }, revision: a.revision })).credential; + assert.notEqual(a.secretChangedAt, b.secretChangedAt); assert.ok(Date.parse(b.updatedAt) > Date.parse(a.updatedAt)); +}); diff --git a/tests/credentials-usage.test.mjs b/tests/credentials-usage.test.mjs new file mode 100644 index 0000000..b94da5c --- /dev/null +++ b/tests/credentials-usage.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { credentialMentions, createCredentialUsage } from '../docker/setup/credentials/usage.mjs'; + +test('mentions honor quoted and unquoted names with exact command and name boundaries', () => { + assert.deepEqual([...credentialMentions(`t3-ssh build-server -- uptime\nt3-api 'project-api' GET me\n t3-api "another-key" GET me\n`) ], ['build-server', 'project-api', 'another-key']); + assert.deepEqual([...credentialMentions('not3-api nope\nx-t3-api nope\nt3-api Bad\nt3-api "quoted-mismatch\nt3-api missing-\nt3-api missing_\nt3-api foo-bar-baz2')], ['foo-bar-baz2']); +}); + +test('seven-provider scans deduplicate shared real files, include instructions and cache with invalidation', async t => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'credential-usage-')); t.after(() => fs.rm(home, { recursive: true, force: true })); + const real = path.join(home, 'shared', 'deploy', 'SKILL.md'); await fs.mkdir(path.dirname(real), { recursive: true }); + await fs.writeFile(real, '---\nname: deploy\ndescription: Deploy from a shared skill.\n---\nt3-ssh build-server -- uptime'); + let scans = 0, reads = 0, at = 1000; + const usage = createCredentialUsage({ home, clock: () => at, installed: async () => ['claude', 'codex'], + scan: async provider => { scans++; return { skills: [{ path: real, resolvedPath: real }], warnings: provider === 'muse' ? ['limited'] : [] }; }, + readFile: async (file, limit, io) => { reads++; assert.equal(limit, 65536); return { content: await io.readFile(file, 'utf8'), bytes: 100 }; }, + instructions: async () => ({ content: 't3-api project-api GET me' }), + }); + const one = await usage.annotate([{ id: 'build-server' }, { id: 'project-api' }]); + assert.deepEqual(one.credentials[0].usedIn, [{ kind: 'skill', name: 'deploy', where: 'All agents' }]); + assert.deepEqual(one.credentials[1].usedIn, [{ kind: 'instructions', name: 'Global instructions' }]); + assert.equal(scans, 7); assert.equal(reads, 1); assert.equal(one.usageWarnings.length, 1); + await usage.annotate([]); assert.equal(scans, 7); + at += 16000; await usage.annotate([]); assert.equal(scans, 14); + usage.invalidate(); await usage.annotate([]); assert.equal(scans, 21); +}); + +test('failed and incomplete scans are visible instead of claiming a credential is unused', async () => { + const usage = createCredentialUsage({ scan: async provider => { if (provider === 'claude') throw new Error('unreadable'); return { skills: [{ path: '/unreadable', error: 'denied' }], warnings: [] }; }, instructions: async () => ({ error: 'denied' }) }); + const result = await usage.annotate([{ id: 'build-server' }]); assert.deepEqual(result.credentials[0].usedIn, []); assert.ok(result.usageWarnings.length >= 2); +}); diff --git a/tests/credentials.test.mjs b/tests/credentials.test.mjs new file mode 100644 index 0000000..d623a30 --- /dev/null +++ b/tests/credentials.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import { createCredentialStore, credentialFailure } from '../docker/setup/credentials.mjs'; +import { outputFilter, apiRequest, apiTarget, sshRequest, testCredential } from '../docker/setup/credentials/run.mjs'; + +const secret = 'fixture-private-credential-123'; +const ssh = { id: 'build-server', label: 'Build server', type: 'ssh', host: '192.0.2.1', username: 'deploy', port: 22, secret }; +async function fixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-credentials-')); + const directory = path.join(root, 'credentials'); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return { root, directory, store: createCredentialStore({ directory }) }; +} +const create = async (store, profile) => store.save({ credential: profile, create: true }); + +test('credentials persist privately and public reads never include saved values', async t => { + const { directory, store } = await fixture(t); + await create(store, ssh); + await create(store, { id: 'project-api', label: 'Project API', type: 'api', baseUrl: 'http://192.0.2.2/api', secret: secret + '-token' }); + const snapshot = await createCredentialStore({ directory }).list(); + assert.equal(snapshot.credentials.length, 2); + assert.equal(snapshot.credentials[1].baseUrl, 'http://192.0.2.2/api/'); + assert.equal(JSON.stringify(snapshot).includes(secret), false); + assert.equal((await store.forUse('build-server')).secret, secret); + assert.equal((await fs.stat(directory)).mode & 0o777, 0o700); + assert.equal((await fs.stat(path.join(directory, 'credentials.json'))).mode & 0o777, 0o600); + const before = snapshot.credentials.find(row => row.id === 'build-server'); + const saved = await store.save({ credential: { ...ssh, secret: '' }, revision: before.revision }); + assert.equal((await store.forUse('build-server')).secret, secret); + assert.equal(JSON.stringify(saved).includes(secret), false); + const next = await store.save({ credential: { ...ssh, secret: 'replacement-password' }, revision: saved.credential.revision }); + assert.equal((await store.forUse('build-server')).secret, 'replacement-password'); + await store.remove({ id: 'build-server', revision: next.credential.revision }); + await assert.rejects(store.forUse('build-server'), /not found/); + assert.equal((await store.list()).credentials.length, 1); +}); + +test('conflicting, duplicate and overlapping writes cannot overwrite another editor', async t => { + const { store, directory } = await fixture(t); + const initial = (await create(store, ssh)).credential; + await assert.rejects(create(store, ssh), { status: 409, code: 'taken' }); + const other = createCredentialStore({ directory }); + const outcomes = await Promise.allSettled(['first-host', 'second-host'].map((host, index) => (index ? other : store).save({ credential: { ...ssh, host }, revision: initial.revision }))); + assert.deepEqual(outcomes.map(outcome => outcome.status), ['fulfilled', 'rejected']); + assert.equal(outcomes[1].reason.status, 409); + assert.equal(outcomes[1].reason.code, 'changed'); + assert.equal(JSON.stringify(credentialFailure(outcomes[1].reason)).includes(secret), false); + await assert.rejects(store.remove({ id: 'build-server', revision: initial.revision }), { status: 409, code: 'changed' }); + assert.equal((await store.forUse('build-server')).host, 'first-host'); +}); + +test('invalid input and damaged stores fail without echoing or replacing credentials', async t => { + const { directory, root, store } = await fixture(t); + for (const change of [{ id: '../escape' }, { host: '-oProxyCommand=bad' }, { username: 'user;bad' }, { port: 0 }, { secret: 'line\nbreak' }, { secret: '\ud800' }, { secret: undefined }, { type: 'unknown' }]) { + await assert.rejects(create(store, { ...ssh, ...change })); + } + for (const baseUrl of ['file:///etc/passwd', 'https://user:password@example.com', 'http://example.com?secret=hello', 'http://example.com/#secret']) { + await assert.rejects(create(store, { id: 'api', label: 'API', type: 'api', baseUrl, secret })); + } + await create(store, ssh); + const file = path.join(directory, 'credentials.json'); + await fs.writeFile(file, '{"secret":"' + secret + '"'); + let failure; + try { await store.list(); } catch (error) { failure = credentialFailure(error); } + assert.equal(failure.status, 500); assert.equal(JSON.stringify(failure).includes(secret), false); + await assert.rejects(create(store, ssh)); + assert.equal(await fs.readFile(file, 'utf8'), '{"secret":"' + secret + '"'); + await fs.rm(file); + const external = path.join(root, 'external'); await fs.writeFile(external, 'do not touch'); + await fs.symlink(external, file); await assert.rejects(store.list()); + assert.equal(await fs.readFile(external, 'utf8'), 'do not touch'); + await fs.rm(directory, { recursive: true }); await fs.symlink(root, directory); + await assert.rejects(store.list()); +}); + +test('output filtering masks literal and encoded values across every chunk boundary', () => { + for (const value of [secret, 'password-with-"quotes"&slashes\\and-more', 'key +/~']) { + const variants = [value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value), new URLSearchParams([['', value]]).toString().slice(1), Buffer.from(value).toString('base64')]; + const source = 'before ' + variants.join(' middle ') + ' after'; + for (let size = 1; size <= source.length; size++) { + let output = ''; const filter = outputFilter(value, text => { output += text; }); + for (let i = 0; i < source.length; i += size) filter.write(source.slice(i, i + size)); + filter.end(); + assert.equal(output, 'before ' + variants.map(() => '[redacted]').join(' middle ') + ' after', 'chunk size ' + size); + } + } +}); + +test('legacy bearer connections remain readable and changing auth settings retains the saved key', async t => { + const { directory, store } = await fixture(t); + await store.list(); + const file = path.join(directory, 'credentials.json'); + const legacy = JSON.stringify({ version: 1, profiles: [{ id: 'service', label: 'Service API', type: 'api', baseUrl: 'https://api.example.com/v1/', secret }] }); + await fs.writeFile(file, legacy, { mode: 0o600 }); + assert.equal((await store.forUse('service')).authType, 'bearer'); + assert.equal(JSON.parse(await fs.readFile(file, 'utf8')).version, 2); + const before = await store.list(); + const saved = await store.save({ credential: { ...before.credentials[0], authType: 'header', authName: 'X-API-Key', authPrefix: '', testPath: 'account', secret: '' }, revision: before.credentials[0].revision }); + assert.equal(JSON.stringify(saved).includes(secret), false); + assert.equal((await store.forUse('service')).secret, secret); + assert.equal((await store.list()).credentials[0].authName, 'X-API-Key'); +}); + +test('API authentication and test-path validation rejects unsafe configuration', async t => { + const { store } = await fixture(t); + const profile = { id: 'service', label: 'Service API', type: 'api', baseUrl: 'https://api.example.com/v1/', secret }; + for (const change of [{ authType: 'unknown' }, { authType: 'header', authName: 'bad:name' }, { authType: 'header', authName: 'Host' }, + { authType: 'header', authName: 'Content-Length' }, { authType: 'header', authName: 'X-Key', authPrefix: 'Token\r\ninjected' }, + { authType: 'query', authName: 'key\ninjected' }, { testPath: '/outside' }, { testPath: '../outside' }, { testPath: 'https://other.test/' }]) { + await assert.rejects(create(store, { ...profile, ...change }), error => error.status === 400 && !error.message.includes(secret)); + } +}); + +for (const [authType, authName, authPrefix] of [['bearer', undefined, undefined], ['header', 'X-API-Key', ''], ['header', 'Authorization', 'Token'], ['query', 'api_key', undefined], ['query', 'api[key]', undefined]]) { + test(`API ${authType} ${authName || ''} ${authPrefix || ''} authentication uses the saved key and configured test path`, async t => { + const { store } = await fixture(t); let received; + const key = authType === 'query' ? secret + '/+ ~' : secret; + const server = createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); received = { headers: req.headers, url }; + res.end(JSON.stringify({ echo: authType === 'query' ? url.search : req.headers[(authName || 'authorization').toLowerCase()] })); + }); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); })); + await create(store, { id: 'service', label: 'Service API', type: 'api', baseUrl: 'http://127.0.0.1:' + server.address().port + '/v1/', secret: key, + authType, authName, authPrefix, testPath: 'account' }); + const profile = await store.forUse('service'); + assert.equal((await testCredential(profile)).code, 'ok'); + assert.equal(received.url.pathname, '/v1/account'); + if (authType === 'query') { assert.equal(received.url.searchParams.get(authName), key); assert.equal(received.headers.authorization, undefined); } + else assert.equal(received.headers[(authName || 'authorization').toLowerCase()], (authType === 'bearer' ? 'Bearer ' : authPrefix ? authPrefix + ' ' : '') + key); + let output = ''; + assert.equal(await apiRequest(profile, 'GET', 'projects?limit=2', { stdout: { write: value => { output += value; } } }), 0); + assert.equal(received.url.searchParams.get('limit'), '2'); + assert.equal(output.includes(secret), false); assert.match(output, /\[redacted\]/); + }); +} + +test('API helper authenticates internally, filters output and never follows redirects', async t => { + let calls = 0, received; + const server = createServer(async (req, res) => { + calls++; assert.equal(req.headers.authorization, 'Bearer ' + secret); + const chunks = []; for await (const chunk of req) chunks.push(chunk); + received = { url: req.url, method: req.method, body: Buffer.concat(chunks).toString() }; + if (req.url === '/api/redirect') { res.writeHead(302, { location: '/elsewhere' }); res.end(); return; } + if (req.url === '/api/unauthorized') { res.writeHead(401); res.end(secret); return; } + res.write('hello ' + secret.slice(0, 8)); setTimeout(() => res.end(secret.slice(8) + ' goodbye'), 5); + }); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); })); + const profile = { id: 'project-api', type: 'api', baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/', secret }; + let output = ''; + assert.equal(await apiRequest(profile, 'POST', 'projects', { body: '{"name":"demo-project"}', stdout: { write: text => { output += text; } } }), 0); + assert.equal(output, 'hello [redacted] goodbye'); + assert.deepEqual(received, { url: '/api/projects', method: 'POST', body: '{"name":"demo-project"}' }); + await assert.rejects(apiRequest(profile, 'GET', 'redirect'), /redirect/); + assert.equal(calls, 2, 'redirect destination was never requested'); + assert.deepEqual(await apiRequest(profile, 'GET', 'unauthorized', { probe: true }), { ok: false, code: 'rejected', detail: { status: 401 } }); + assert.equal((await testCredential(profile)).code, 'ok'); + const cancelled = new AbortController(); cancelled.abort(); + assert.equal((await testCredential(profile, { signal: cancelled.signal })).code, 'timeout'); + for (const target of ['https://other.test/api', '//other.test', '/api/states', '../outside', '%2e%2e/outside', 'foo\\outside', '#fragment']) assert.throws(() => apiTarget(profile, target)); +}); + +test('SSH helper supplies askpass privately and filters both output streams', async t => { + const { root } = await fixture(t); + const fixtureFile = path.join(root, 'ssh.cjs'); + await fs.writeFile(fixtureFile, `const {execFileSync}=require('node:child_process'); +const secret=execFileSync(process.env.SSH_ASKPASS,[],{encoding:'utf8'}).trimEnd(); +if(secret!==process.env.T3_CREDENTIAL_PASSWORD)process.exit(2); +process.stdout.write('output '+secret.slice(0,5)); +process.stderr.write('error '+secret.slice(0,5)); +setTimeout(()=>{process.stdout.write(secret.slice(5)+' done');process.stderr.write(secret.slice(5)+' done');},10);`); + let out = '', err = ''; + const code = await sshRequest({ ...ssh, knownHostsFile: '/synthetic/known_hosts/build-server', hostKeyType: 'ssh-ed25519' }, ['uptime'], { stdin: 'ignore', stdout: { write: value => { out += value; } }, stderr: { write: value => { err += value; } }, + spawnProcess: (executable, args, options) => { + assert.equal(executable, '/usr/bin/ssh'); assert.ok(args.includes('StrictHostKeyChecking=yes')); + assert.equal(JSON.stringify(args).includes(secret), false); + assert.deepEqual(args.slice(-2), ['192.0.2.1', 'uptime']); + return spawn(process.execPath, [fixtureFile], options); + }, + }); + assert.equal(code, 0); assert.equal(out, 'output [redacted] done'); assert.equal(err, 'error [redacted] done'); + const controller = new AbortController(); let interrupted; + t.after(() => interrupted?.kill()); + await assert.rejects(sshRequest({ ...ssh, knownHostsFile: '/synthetic/known_hosts/build-server', hostKeyType: 'ssh-ed25519' }, ['true'], { stdin: 'ignore', signal: controller.signal, + spawnProcess: (_executable, _args, options) => { + interrupted = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options); + interrupted.once('spawn', () => controller.abort()); + return interrupted; + }, + }), /SSH could not start or was interrupted/); + if (interrupted.signalCode === null && interrupted.exitCode === null) await once(interrupted, 'close'); + assert.equal(interrupted.signalCode, 'SIGKILL'); +}); + +test('SSH hosts accept IPv6 addresses including leading ::', async t => { + const { store } = await fixture(t); + for (const host of ['::1', '::', '2001:db8::1']) { + await create(store, { ...ssh, id: 'v6-' + host.length, host }); + } + await assert.rejects(create(store, { ...ssh, id: 'bad-host', host: '-oProxyCommand=bad' })); +}); + +test('API helper reports HTTP status on stderr for non-probe failures without the secret', async t => { + const server = createServer((req, res) => { res.writeHead(401); res.end(secret); }); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); })); + const profile = { id: 'project-api', type: 'api', baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/', secret }; + let output = '', error = ''; + assert.equal(await apiRequest(profile, 'GET', 'projects', { stdout: { write: value => { output += value; } }, stderr: { write: value => { error += value; } } }), 1); + assert.equal(output, '[redacted]'); + assert.match(error, /HTTP 401/); + assert.equal((output + error).includes(secret), false); +}); diff --git a/tests/files-paths.test.mjs b/tests/files-paths.test.mjs index a5c644e..d8fad02 100644 --- a/tests/files-paths.test.mjs +++ b/tests/files-paths.test.mjs @@ -67,3 +67,13 @@ test('managed ancestors are explained without locking unrelated sibling content' const caches = createPathPolicy({ home: f.home, workspace: f.workspace, env: {}, storage: { snapshot: () => ({ categories: [{ kind: 'cache', path: path.join(f.root, 'custom-cache') }] }) } }); assert.equal((await caches.classify(path.join(f.root, 'custom-cache/entry'))).managed.tag, 'Caches'); }); + +test('saved server credentials carry key and secret warnings in Files, including a custom state volume', async t => { + const f = await fixture(t); + const policy = createPathPolicy({ home: f.home, workspace: f.workspace, env: { T3CODE_HOME: path.join(f.root, 'custom-t3') } }); + const folder = path.join(f.root, 'custom-t3/server-credentials'); + await fs.mkdir(folder, { recursive: true }); + assert.equal((await policy.classify(folder)).keys, true); + const file = await policy.classify(path.join(folder, 'credentials.json')); + assert.equal(file.keys, true); assert.equal(file.secret, true); +}); diff --git a/tests/fixtures/credential-keys.mjs b/tests/fixtures/credential-keys.mjs new file mode 100644 index 0000000..dd9cad2 --- /dev/null +++ b/tests/fixtures/credential-keys.mjs @@ -0,0 +1,7 @@ +import { randomBytes, createHash } from 'node:crypto'; +export function credentialKey() { + const type = 'ssh-ed25519', name = Buffer.from(type), length = Buffer.alloc(4), keyLength = Buffer.alloc(4); + length.writeUInt32BE(name.length); keyLength.writeUInt32BE(32); + const wire = Buffer.concat([length, name, keyLength, randomBytes(32)]); + return { type, key: wire.toString('base64'), fingerprint: 'SHA256:' + createHash('sha256').update(wire).digest('base64').replace(/=+$/, '') }; +} diff --git a/tests/fixtures/credentials.mjs b/tests/fixtures/credentials.mjs new file mode 100644 index 0000000..f86db57 --- /dev/null +++ b/tests/fixtures/credentials.mjs @@ -0,0 +1,37 @@ +// Real setup API in an empty disposable home, shared by HTTP and UI checks. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; +import { fileURLToPath } from 'node:url'; + +export async function credentialsFixture() { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 't3-server-credentials-')); + const reservation = createServer().listen(0, '127.0.0.1'); + await once(reservation, 'listening'); const port = reservation.address().port; + await new Promise(resolve => reservation.close(resolve)); + const env = { ...process.env, HOME: home, T3CODE_HOME: path.join(home, '.t3'), T3_WORKSPACE: path.join(home, 'workspace'), + CODEX_HOME: path.join(home, '.codex'), CLAUDE_CONFIG_DIR: path.join(home, '.claude'), XDG_CONFIG_HOME: path.join(home, '.config'), XDG_DATA_HOME: path.join(home, '.local/share'), + MISE_DATA_DIR: path.join(home, '.local/share/mise'), MISE_STATE_DIR: path.join(home, '.local/state/mise'), MISE_CONFIG_DIR: path.join(home, '.config/mise'), + T3_SETUP_PORT: String(port), T3_SETUP_KEY: 'credential-fixture-key', T3_SETUP_KEY_SOURCE: 'env', T3_INFRA_LAUNCHER: '/bin/false', T3CODE_PORT: '1', T3_SETUP_ACCEPT_T3_SESSIONS: '0' }; + const child = spawn(process.execPath, [fileURLToPath(new URL('../../docker/setup/server.mjs', import.meta.url))], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let logs = ''; child.stdout.on('data', chunk => { logs += chunk; }); child.stderr.on('data', chunk => { logs += chunk; }); + const closed = once(child, 'close'), base = 'http://127.0.0.1:' + port + '/__setup'; + const request = (route, body, headers = {}) => fetch(base + route, { + method: body === undefined ? 'GET' : 'POST', headers: { 'x-t3-setup-key': 'credential-fixture-key', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), + }); + const close = async () => { + if (child.exitCode === null && child.signalCode === null) child.kill(); + await closed; await fs.rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }; + try { + for (let i = 0; i < 200; i++) { + if (child.exitCode !== null || child.signalCode !== null) throw new Error(logs); + try { if ((await request('/hello')).ok) return { home, env, base, request, close, logs: () => logs }; } catch {} + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error('Credential fixture did not start.'); + } catch (error) { await close(); throw error; } +} diff --git a/tests/fixtures/standalone-shell.html b/tests/fixtures/standalone-shell.html index 784a072..bb2eb73 100644 --- a/tests/fixtures/standalone-shell.html +++ b/tests/fixtures/standalone-shell.html @@ -15,7 +15,7 @@