diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3847794..d33668f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,26 +172,35 @@ subscription, that Pi lists their providers' models, and removal, without a login or model request. The image smoke suite runs it against the installed release, then signs Pi in and out through the Setup API. -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 Vault changes, run `node --test tests/credential*.test.mjs` and +`node scripts/credentials-ui-audit.mjs`. The tests use the real `ssh`, `ssh-keygen` +and `git` with throwaway keys and folders. The store writes an `Include` line to +`~/.ssh/config` under the `home` it's given, so any new test or script must pass a +temporary `home`. The browser audit starts a real Setup API in a disposable home and +a loopback API server. It tests paste detection, 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 the installed +`t3-vault` and loopback API checks on each image variant. + +To add a credential type, add it to `docker/setup/credentials/types.mjs`. The store, +`t3-vault add` and the Setup form are all built from that table. Only a type that +needs more than environment variables and files needs code, in `deliver.mjs`. Update +`scripts/fixtures/console/credentials.json` too, because it carries a copy of the +table, and a test compares them. 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. +It captures the list, empty state, details, the add dialog, 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). 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 diff --git a/README.md b/README.md index a9c3bcf..377acb4 100644 --- a/README.md +++ b/README.md @@ -170,7 +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 | +| [Vault](docs/vault.md) | Saved keys, tokens and passwords that every agent can use by name, added in Setup or from chat | | [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-ssh b/docker/bin/t3-ssh deleted file mode 100755 index 296ca7c..0000000 --- a/docker/bin/t3-ssh +++ /dev/null @@ -1,10 +0,0 @@ -#!/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/bin/t3-api b/docker/bin/t3-vault similarity index 78% rename from docker/bin/t3-api rename to docker/bin/t3-vault index 2f2deed..1421e0f 100755 --- a/docker/bin/t3-api +++ b/docker/bin/t3-vault @@ -7,4 +7,4 @@ if [ "$EUID" -eq 0 ] && command -v gosu >/dev/null 2>&1 && id t3 >/dev/null 2>&1 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 "$@" +exec "${T3_INFRA_NODE:-/usr/local/bin/node}" "${T3_VAULT_CLI:-/opt/t3-setup/credentials/cli.mjs}" "$@" diff --git a/docker/setup/app.js b/docker/setup/app.js index 94123b6..5ef4685 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -240,7 +240,8 @@ checkHere(); if (state.route === 'instructions' && state.contextTab === 'skills') skillsUI.activate(); if (state.route === 'mcp') mcpUI.activate(); - if (state.route === 'credentials') credentialsUI.activate(); + // A link the page hasn't handled yet belongs to that navigation, which would close whatever this opened. + if (state.route === 'vault' && (location.hash === shownHash || !location.hash.includes('/'))) credentialsUI.activate(); if (state.route === 'files') filesUI.activate(); if (state.route === 'instructions' && state.contextTab === 'instructions') void loadInstructionsPreview(); } else if (!locked) { @@ -911,8 +912,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', credentials: 'Credentials' }; - const needsPage = (x) => x.target === 'credential' ? 'credentials' : 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', vault: 'Vault' }; + const needsPage = (x) => x.target === 'credential' ? 'vault' : 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'; @@ -1221,7 +1222,7 @@ ui.updateAwaiting = false; const failures = Object.keys(s.updates?.errors || {}); Kit.toast(failures.length ? 'Some update checks failed' : 'Update check complete', { - tone: 'info', + tone: failures.length ? 'warn' : 'info', detail: failures.length ? M.plural(failures.length, 'source') + ' could not be verified. Last known releases are kept.' : 'Available releases are shown beside each tool.', ...(failures.length ? { action: { label: 'Review', run: () => openUpdateChecks(null, null) } } : {}), }); @@ -1922,7 +1923,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()}`, + vault: () => html`${pageHead('vault')}${credentialsUI.content()}`, toolchains: toolchainsPage, sourcecontrol: sourceControlPage, ports: portsPage, updates: updatesPage, storage: storagePage, server: serverPage, more: morePage, }; @@ -2076,7 +2077,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 === 'vault' && state.status) credentialsUI.activate(true); if (route === 'files' && state.status) filesUI.activate(); }; const navigateFromHash = async focus => { @@ -2241,7 +2242,7 @@ // A source control CLI names its host (GitHub), an agent itself. What // came after the sign-in and did not work (gh as git's credential // helper) is said, without undoing it. - Kit.toast('Signed in to ' + (meta.provider || meta.name || signin.agent), st.warning ? { detail: st.warning } : undefined); + Kit.toast('Signed in to ' + (meta.provider || meta.name || signin.agent), st.warning ? { tone: 'warn', detail: st.warning } : undefined); loadStatus(); return; } @@ -2466,7 +2467,7 @@ // The button that opened this is gone from the row now. const row = M.agentRows(state.status, ui, now()).find((entry) => entry.id === id); if (document.activeElement === document.body && row) document.querySelector('[data-key="menu-' + row.key + '"]')?.focus({ preventScroll: true }); - if (res.ok) Kit.toast('T3 Code now uses this ' + name + ' sign-in', res.warning ? { detail: res.warning } : undefined); + if (res.ok) Kit.toast('T3 Code now uses this ' + name + ' sign-in', res.warning ? { tone: 'warn', detail: res.warning } : undefined); else Kit.toast('Couldn’t switch T3 Code to this sign-in', { tone: 'danger', detail: res.error || 'Try again.' }); }; @@ -2841,7 +2842,7 @@ const res = await api('/public-url', { body: seen ? { url, check: false } : { url } }); urlSaving = false; if (!res.ok) return res; - Kit.toast('Pairing links now point at ' + M.hostOf(res.data.publicUrl), seen ? {} : { detail: reachDetail(res.data.check) }); + Kit.toast('Pairing links now point at ' + M.hostOf(res.data.publicUrl), seen ? {} : { detail: reachDetail(res.data.check), ...(res.data.check && res.data.check.reaches !== 'this' ? { tone: 'info' } : {}) }); applyUrl(res.data); return res; }; @@ -4246,7 +4247,7 @@ // ------------------------------------------------------------ shortcuts -- Kit.bind('mod+k', 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()); + Kit.bind('/', () => state.route === 'files' ? filesUI.focusSearch() : state.route === 'mcp' ? mcpUI.focusSearch() : state.route === 'vault' ? 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 index c0f091f..a297dda 100644 --- a/docker/setup/client/credential-dialogs.js +++ b/docker/setup/client/credential-dialogs.js @@ -9,11 +9,11 @@ const T3CredentialDialogs = (() => { 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.`, + const yes = await Kit.confirm({ title: 'Delete ' + id + '?', body: html`Skills and commands that use ${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: 'key-round', text: 'The saved ' + S.secretKind(profile) + ' is erased from this server, but keeps working at the service until you revoke it' }, { 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' }); @@ -44,14 +44,14 @@ const T3CredentialDialogs = (() => { 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 if (!response?.ok || !response.data.hostKey) view.error = response?.data?.result ? S.state({ ...profile, hostKey: profile.fields.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.'}

+

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

${changed() ? profile.fields.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.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 => { @@ -72,7 +72,16 @@ const T3CredentialDialogs = (() => { }); await scan(); }; - return { remove, forget, review, beforeNavigate: () => !active?.busy }; + // The password goes to the host once more, to install the key, then it's deleted. + const useKey = async id => { + const profile = ctx.row(id); if (!profile || ctx.busy.has(id)) return; + const yes = await Kit.confirm({ title: 'Switch ' + id + ' to a key?', body: html`Signs in to ${profile.fields.host} with the saved password, adds a new key there, checks that the key works, then deletes the password.`, confirm: 'Switch to a key' }); + if (!yes) return; + const response = await ctx.request(id, 'use-key', '/credentials/install-key', { id, revision: profile.revision }); + if (response?.ok) Kit.toast(id + ' now signs in with a key', { detail: 'Its password has been deleted.' }); + else if (response) Kit.toast('Couldn’t switch ' + id + ' to a key', { tone: 'danger', detail: response.error }); + }; + return { remove, forget, review, useKey, beforeNavigate: () => !active?.busy }; }; return { create }; })(); diff --git a/docker/setup/client/credential-editor.js b/docker/setup/client/credential-editor.js index b1ba445..66be87f 100644 --- a/docker/setup/client/credential-editor.js +++ b/docker/setup/client/credential-editor.js @@ -1,32 +1,82 @@ -// 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. +// Drafts own their input subtree. Only newly typed secrets live here; neither +// saved values nor server responses are copied into the form or a preview. The +// exception is the paste box: what you just pasted comes back sorted into fields. 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 MAX_FILE = 48 * 1024; + const SIGN_INS = [ + ['generate', 'Generate a key', 'Made on this server. You add one line to the remote server.', 'Recommended'], + ['once', 'Use my password once', 'Installs a new key with your password, then forgets the password.'], + ['existing', 'Use an existing key', 'Paste or upload a private key.'], + ['password', 'Keep using a password', 'For servers that don’t allow keys.'], + ]; + const domId = path => 'cred-' + path.replace(/\./g, '-'); + const create = ctx => { let active = null; const close = reason => active ? active.close(reason) : Promise.resolve(true); - const open = async (profile, { replace = false } = {}) => { + const open = async (profile, { replace = false, type = null, name = '' } = {}) => { 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 }; + const view = { original: profile || null, draft: profile ? S.draftOf(profile) : S.defaults(type || 'token'), revision: profile?.revision, create: !profile, step: profile || type ? 'form' : 'start', + secrets: {}, replacing: new Set(), signin: 'generate', generated: null, passphrase: '', needsPassphrase: false, pasteText: '', detected: null, detecting: false, fileError: null, dragging: false, + touched: new Set(), busy: null, error: null, conflict: null, taken: false, result: null, scanned: null, trusted: false, revealed: new Set(), railOpen: false, generation: 0, closing: false }; + // A name from a link may be taken already, and Save can't explain that by itself. + if (name) { view.draft.id = name; view.touched.add('id'); } + if (profile && replace) { const primary = S.primarySecret(profile.type, profile.fields); if (primary) view.replacing.add(primary.key); } 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 isSsh = () => view.draft.type === 'ssh'; + const creatingSsh = () => view.create && isSsh(); + const setSignIn = choice => { + view.signin = choice; + view.draft.fields.auth = choice === 'once' || choice === 'password' ? 'password' : 'key'; + view.secrets = {}; view.needsPassphrase = false; view.passphrase = ''; view.result = null; view.scanned = null; view.trusted = false; view.touched.delete('secrets.privateKey'); view.touched.delete('secrets.password'); + if (choice === 'generate') void ensureKey(); + }; + const ensureKey = async () => { + if (view.generated || view.generating) return; + view.generating = true; paint(); + const response = await ctx.api('/credentials/generate-key', { body: {} }); + view.generating = false; + if (active !== view) return; + if (response.ok) view.generated = response.data; else view.error = response.error || 'A new key couldn’t be generated. Try again.'; + paint(); + }; + // A name from a link was never typed, so closing that form has nothing to discard. + const baseline = () => JSON.stringify(S.payload(view.original ? S.draftOf(view.original) : { ...S.defaults(view.draft.type), id: name })); + const dirty = () => { + if (view.step === 'start') return Boolean(view.pasteText); + return Boolean(Object.values(view.secrets).some(Boolean) || view.trusted || view.detected || view.draft.vars?.some(row => row.value) || JSON.stringify(S.payload(view.draft)) !== baseline()); + }; + const savedSecrets = () => view.original?.secretNames?.filter(key => !view.replacing.has(key)) || []; + const errors = () => { + const found = S.validate(view.draft, { editing: !view.create, names: ctx.rows().filter(row => row.id !== view.recreateId).map(row => row.id), secrets: view.secrets, savedSecrets: savedSecrets(), needSecret: !(creatingSsh() && view.signin === 'generate') }); + return { ...found, ...(view.taken ? { id: view.draft.id + ' is taken.' } : {}) }; + }; + const valid = () => view.step === 'form' && !Object.keys(errors()).length && !(creatingSsh() && view.signin === 'generate' && !view.generated); const canSave = () => !view.busy && valid() && (!view.original || dirty()) && !view.conflict; + const valueAt = path => { + if (path === 'id' || path === 'description') return view.draft[path]; + if (path === 'passphrase') return view.passphrase; + if (path === 'paste') return view.pasteText; + const [group, key, part] = path.split('.'); + if (group === 'fields') return view.draft.fields[key]; + if (group === 'env') return view.draft.env?.[key]; + if (group === 'secrets') return view.secrets[key]; + if (group === 'vars') return view.draft.vars[Number(key)]?.[part]; + return undefined; + }; 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]; + const value = valueAt(input.dataset.credField); 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(); }; + const paint = () => { if (active !== view || view.layer?.closed) return; view.layer.panel.classList.toggle('tc-cred-editor--start', view.step === 'start'); 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; } @@ -39,111 +89,307 @@ const T3CredentialEditor = (() => { }; const beforeUnload = event => { if (view.busy || dirty()) { event.preventDefault(); event.returnValue = ''; } }; window.addEventListener('beforeunload', beforeUnload); + + // ---- the paste box --------------------------------------------------------- + const suggestName = id => { + if (!id) return ''; + const taken = ctx.rows().map(row => row.id); + if (!taken.includes(id)) return id; + for (let n = 2; n < 100; n++) if (!taken.includes(id + '-' + n)) return id + '-' + n; + return ''; + }; + const applyDetected = found => { + const draft = S.defaults(found.type); + draft.id = suggestName(found.id); draft.fields = S.withDefaults(found.type, { ...draft.fields, ...found.fields }); draft.env = { ...found.env }; + if (S.typeOf(found.type)?.dynamic) draft.vars = [...Object.entries(found.secrets).map(([key, value]) => ({ name: key, value, secret: true })), ...Object.entries(found.fields).map(([key, value]) => ({ name: key, value, secret: false }))]; + view.draft = draft; view.secrets = S.typeOf(found.type)?.dynamic ? {} : { ...found.secrets }; + view.detected = { label: found.label, confident: found.confident }; view.pasteText = ''; view.step = 'form'; view.result = null; + if (found.type === 'ssh') { view.signin = 'existing'; view.draft.fields.auth = 'key'; } + paint(); + requestAnimationFrame(() => view.layer.panel.querySelector(draft.id ? '.tc-wizard-main input:not([value=""])' : '#cred-id')?.focus({ preventScroll: true })); + }; + const detectNow = async text => { + const generation = ++view.generation; + view.detecting = true; paint(); + const response = await ctx.api('/credentials/detect', { body: { text } }); + if (active !== view || generation !== view.generation) return; + view.detecting = false; + if (response.ok && response.data.detected) applyDetected(response.data.detected); + else if (!response.ok) { view.error = response.error || 'That couldn’t be read. Pick a type instead.'; paint(); } + else paint(); + }; + const readFile = async (file, target) => { + view.fileError = null; + if (file.size > MAX_FILE) { view.fileError = file.name + ' is larger than the 48 KiB limit.'; paint(); return; } + let text; + try { text = await file.text(); } catch { view.fileError = file.name + ' couldn’t be read.'; paint(); return; } + if (text.includes('\0')) { view.fileError = file.name + ' isn’t a text file.'; paint(); return; } + if (target === 'paste') return void detectNow(text); + view.secrets[target] = text; view.result = null; view.touched.delete('secrets.' + target); paint(); + }; + + // ---- inputs --------------------------------------------------------------- const hint = (key, fallback) => { const error = view.touched.has(key) && errors()[key]; - return html`${fallback ? html`

${fallback}

` : ''}${error ? html`` : ''}`; + return html`${fallback ? html`

${fallback}

` : ''}${error ? html`` : ''}`; + }; + const input = (path, label, options = {}) => { + const invalid = view.touched.has(path) && Boolean(errors()[path]); + return html`
${hint(path, options.hint)}
`; + }; + const fieldInput = entry => { + const path = 'fields.' + entry.key; + if (entry.kind === 'select') return html`
${entry.label}
${entry.options.map(([value, title]) => html``)}
`; + return input(path, entry.label, { optional: entry.optional, type: entry.kind === 'number' ? 'number' : 'text', max: entry.kind === 'number' ? null : entry.max, placeholder: entry.placeholder, hint: entry.hint }); + }; + const fileButton = target => html``; + const savedLine = (entry, path) => html`
${icon('circle-check')}Saved ${view.original.secretChangedAt ? '· changed ' + S.day(view.original.secretChangedAt) : '· from before the upgrade'}
`; + const secretInput = (entry, options = {}) => { + const path = 'secrets.' + entry.key, label = options.label || entry.label, showing = view.revealed.has(entry.key), invalid = view.touched.has(path) && Boolean(errors()[path]); + const isSaved = view.original?.secretNames?.includes(entry.key); + if (isSaved && !view.replacing.has(entry.key)) return html`
${label}${savedLine(entry, path)}
`; + const help = options.hint || (isSaved ? 'Leaving this empty keeps the saved ' + entry.label.toLowerCase() + '.' : 'Not shown again after you save.'); + const keep = isSaved ? html`` : ''; + const reveal = html``; + if (entry.multiline) return html`
${fileButton(entry.key)}${reveal}
${view.fileError ? html`` : ''}${hint(path, help)}${keep}
`; + return html`
${reveal}
${hint(path, help)}${keep}
`; + }; + const envInput = entry => input('env.' + entry.key, 'Environment variable', { max: 128, placeholder: S.resolveEnvName(entry.env, view.draft.id), hint: 'Commands get the ' + entry.label.toLowerCase() + ' under this name.' }); + const nameField = () => 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) + '.'}

`; + const descriptionField = () => input('description', 'Description', { optional: true, plain: true, max: 200, hint: 'Agents see this in t3-vault list, so say when to use it.' }); + + // ---- SSH ------------------------------------------------------------------ + const signInOptions = () => html`
How should agents sign in?
${SIGN_INS.map(([value, title, desc, tag]) => html``)}
`; + const sshSecret = () => { + const spec = S.typeOf('ssh'), keyEntry = spec.secrets.find(entry => entry.key === 'privateKey'), passwordEntry = spec.secrets.find(entry => entry.key === 'password'); + if (view.create) { + if (view.signin === 'generate') return ''; + if (view.signin === 'once') return secretInput(passwordEntry, { label: 'Server password', hint: 'Used once to add the key. It isn’t saved.' }); + if (view.signin === 'password') return secretInput(passwordEntry); + return html`${secretInput(keyEntry, { hint: 'Line breaks don’t matter. Its passphrase isn’t kept.' })}${view.needsPassphrase ? input('passphrase', 'Passphrase', { type: 'password', plain: true, hint: 'Opens the key once, then the key is saved without it.' }) : ''}`; + } + return view.draft.fields.auth === 'key' ? html`${secretInput(keyEntry)}${view.needsPassphrase ? input('passphrase', 'Passphrase', { type: 'password', plain: true }) : ''}` : secretInput(passwordEntry); }; - const input = (key, label, options = {}) => { - const invalid = view.touched.has(key) && Boolean(errors()[key]); - return html`
${hint(key, options.hint)}
`; + const sshFields = () => html`
${fieldInput(S.typeOf('ssh').fields[0])}${fieldInput(S.typeOf('ssh').fields[1])}
${fieldInput(S.typeOf('ssh').fields[2])}${view.create ? signInOptions() : ''}${sshSecret()}${Kit.isPhone() && creatingSsh() && view.signin === 'generate' ? keyPanel() : ''}`; + + // ---- everything else --------------------------------------------------------- + const varsEditor = () => html`
Variables
${view.draft.vars.map((row, index) => html`
`)}
${view.touched.has('vars') && errors().vars ? html`` : html`

Commands get each one under its own name.

`}
`; + const typedFields = () => { + const spec = S.typeOf(view.draft.type), visible = spec.fields.filter(entry => entry.kind !== 'derived' && S.applies(entry, view.draft.fields)); + const plain = visible.filter(entry => !entry.needs && entry.key !== 'baseUrl'), api = visible.filter(entry => entry.key === 'baseUrl' || entry.needs); + const secrets = html`${spec.secrets.filter(entry => S.applies(entry, view.draft.fields)).map(entry => secretInput(entry))}${[...spec.fields, ...spec.secrets].filter(entry => entry.editableEnv).map(envInput)}`; + // A token is the point of a token, while logins and databases read best in the order people say them. + return html`${spec.secretFirst ? secrets : ''}${plain.map(fieldInput)}${spec.secretFirst ? '' : secrets}${api.filter(entry => entry.key !== 'testPath').map(fieldInput)}`; + }; + const form = () => { + const spec = S.typeOf(view.draft.type); + return html`
${view.detected ? html`
${icon(view.detected.confident ? 'sparkles' : 'info')}${view.detected.confident ? 'Looks like ' + view.detected.label : 'Saving this as ' + S.typeLabel(view.draft.type).toLowerCase()}
` : ''}${nameField()}${isSsh() ? sshFields() : spec.dynamic ? varsEditor() : typedFields()}${descriptionField()}
`; + }; + + // ---- the first step ------------------------------------------------------------ + const start = () => html`
${view.detecting ? html`Reading it…` : html`Or drop a file here. It stays masked as you paste.`}${fileButton('paste')}
${view.fileError ? html`` : ''}

Or pick a type

${S.typeList().map(type => html``)}
`; + + // ---- the side panel -------------------------------------------------------------- + const commandList = () => html``; + const keyPanel = () => html`

Add this line on the server

${view.generated ? html`
${S.installLine(view.generated.publicKey)}

Run it on the server as ${view.draft.fields.username || 'the user agents sign in as'}, or save now and do it later.

` : html`

Generating a key…

`}
`; + const identity = () => { + const key = view.scanned?.presented; if (!key) return ''; + const changed = view.scanned.state === 'changed'; + const firstUse = !changed && view.draft.fields.auth === 'key' && view.signin !== 'once'; + const check = 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)}`; + return html`
${changed ? 'Different host key from ' : firstUse ? 'Server’s identity: ' : 'First connection to '}${view.draft.fields.host}${S.keyType(key.type)} ${key.fingerprint}${firstUse ? html`Pinned on first use. A different key later blocks it.` : html`${check}`}
`; }; - 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 shown = { ...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(shown) : null; + return html`
${view.busy === 'test' ? html`

Testing…

` : ''}${identity()}${state && !['host-key-untrusted', 'host-key-changed'].includes(state.code) ? html`
${icon(state.icon || 'info')}${view.result.ok && isSsh() ? 'Signed in as ' + view.draft.fields.username : state.label}${view.result.ms >= 1000 ? (view.result.ms / 1000).toFixed(1) + ' s' : view.result.ms + ' ms'}
${!view.result.ok ? html`

${state.desc}

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

Nothing is saved by a 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 testSection = () => !S.canTest(view.draft) ? (S.typeOf(view.draft.type)?.fields.some(entry => entry.key === 'baseUrl') ? html`

Test before saving

Add an API address to test this here.

` : '') : html`

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

${view.draft.fields.baseUrl ? fieldInput(S.typeOf(view.draft.type).fields.find(entry => entry.key === 'testPath')) : ''}${result()}
`; + const givesSection = () => { + const gives = S.givesPreview(view.draft); if (isSsh() || !gives.length) return ''; + return html`

Commands get

${gives.map((item, index) => html`${index ? ', ' : ''}${item.name}`)}

${gives.some(item => item.file) ? html`

Holds the path of a temporary file that only lasts for that command.

` : ''}
`; + }; + const rail = () => html`
Agents run ${S.commands(view.draft, { aliases: ctx.sshAliases() })[0]}${icon('chevron-down')}
`; + const conflict = () => !view.conflict ? '' : html``; + + // ---- talking to the server ---------------------------------------------------------- + const trustField = () => view.trusted && view.scanned ? { trust: { fingerprint: view.scanned.presented.fingerprint } } : {}; + const testBody = () => { + const draft = S.payload(view.draft, view.secrets); + return { draft, ...(!view.create ? { id: view.draft.id } : {}), ...trustField(), ...(creatingSsh() && view.signin === 'generate' ? { generatedKeyId: view.generated?.keyId } : {}), ...(view.passphrase ? { passphrase: view.passphrase } : {}) }; + }; + const saveBody = () => { + const credential = S.payload(view.draft, view.secrets), body = { credential, ...(!view.create ? { revision: view.revision } : {}), ...trustField(), ...(view.passphrase ? { passphrase: view.passphrase } : {}) }; + if (creatingSsh() && view.signin === 'generate') body.generatedKeyId = view.generated.keyId; + if (creatingSsh() && view.signin === 'once') { + credential.fields.auth = 'key'; body.generate = true; body.install = { password: view.secrets.password }; delete credential.secrets.password; + } + return body; + }; + const keyProblem = response => { + if (response.data?.field !== 'passphrase') return false; + view.needsPassphrase = true; view.error = response.error; paint(); + view.layer.panel.querySelector('#cred-passphrase')?.focus(); + return true; + }; const submit = async () => { - if (view.busy || active !== view || view.conflict) return; + if (view.busy || active !== view || view.conflict || view.step !== 'form') 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; + // A password must not leave before someone has looked at who's receiving it. + if (creatingSsh() && view.signin === 'once' && !view.trusted) { + await test(); + if (active === view && view.scanned && !view.trusted) { view.error = 'Check the server’s identity first, then save.'; paint(); view.layer.panel.querySelector('[data-cred-editor="trust"]')?.focus(); } + 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; + const body = saveBody(); + const response = await ctx.api(view.create ? '/credentials/create' : '/credentials/save', { body }); + if (body.install) body.install.password = ''; 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 if (keyProblem(response)) return; 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 id = view.draft.id, failedTrust = response.data.trust?.ok === false && view.draft.fields.auth === 'password', waitingForKey = creatingSsh() && view.signin === 'generate' && !view.result?.ok; 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); + Kit.toast(id + ' saved', !refreshed ? { tone: 'warn', detail: 'Saved, but the list could not be refreshed. Try again on the Vault page.' } : failedTrust ? { tone: 'warn', detail: 'Its host key could not be trusted. Review it before agents connect.', action: { label: 'Review', run: () => ctx.review(id) } } : waitingForKey ? { detail: 'Add its key to the server, then test it.' } : undefined); + if (waitingForKey && refreshed) ctx.inspect(id); }; 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; + const response = await ctx.api('/credentials/test', { body: testBody() }); if (active !== view || generation !== view.generation) return; view.busy = null; + if (!response.ok && keyProblem(response)) return; 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; } + if (view.result.hostKey) { view.scanned = view.result.hostKey; if (view.result.code !== 'ok') 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' }); + // The side panel can be taller than the dialog too, so this isn't only for phones. + (view.layer.panel.querySelector(view.error ? '#cred-editor-error' : '.tc-cred-test-line') || view.layer.panel.querySelector('.tc-cred-hostkey--first'))?.scrollIntoView({ block: 'nearest' }); + }; + + const head = () => { + const sub = view.step === 'start' ? 'Save it once. Agents use it by name.' : view.create ? S.typeLabel(view.draft.type) : S.typeLabel(view.draft.type) + ' · ' + (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'); + const back = view.create && view.step === 'form' && !view.detected ? html`` : ''; + return html`
${back}

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

${sub}
`; }; - 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'; } + const foot = () => view.step === 'start' ? html`
${icon('lock', 'tc-icon--sm')}Saved on this server, in plain text.
` : html`
${icon('lock', 'tc-icon--sm')}Saved on this server, in plain text.${Kit.isPhone() ? html`${S.canTest(view.draft) ? testButton(true) : ''}` : html``}
`; + + 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: view.step === 'start' ? '#cred-paste' : replace ? '.tc-wizard-main [data-cred-field^="secrets."]' : profile ? '.tc-wizard-main [data-cred-field^="fields."]' : view.draft.id ? '.tc-wizard-main [data-cred-field^="secrets."], .tc-wizard-main [data-cred-field^="fields."]' : '#cred-id', onEscape: () => { void view.close('escape'); }, onClose: () => { + view.generation++; view.secrets = {}; view.pasteText = ''; view.passphrase = ''; view.draft.vars = []; view.revealed.clear(); + for (const input of view.layer.panel.querySelectorAll('[data-cred-field^="secrets."], [data-cred-field="paste"]')) input.value = ''; 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(); + }, render: () => html`${head()}${view.step === 'start' ? html`
${start()}
` : html`
${conflict()}${form()}${Kit.isPhone() ? testSection() : ''}
${rail()}
`}${view.error ? html`` : ''}${foot()}` }); + sync(); view.layer.panel.classList.toggle('tc-cred-editor--start', view.step === 'start'); + if (creatingSsh()) { setSignIn('generate'); paint(); } + view.layer.panel.addEventListener('submit', event => { event.preventDefault(); void submit(); }); + let detectTimer = null; 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; } } + if (key === 'paste') { + view.pasteText = event.target.value; clearTimeout(detectTimer); + // A paste arrives whole, so read it right away. Typing waits for a pause. + if (view.pasteText.trim()) detectTimer = setTimeout(() => void detectNow(view.pasteText), event.inputType === 'insertFromPaste' ? 0 : 800); + return; + } + if (key === 'id') { view.draft.id = event.target.value.toLowerCase().replace(/\s/g, '-'); view.taken = false; event.target.value = view.draft.id; } + else if (key === 'description') view.draft.description = event.target.value; + else if (key === 'passphrase') view.passphrase = event.target.value; + else { + const [group, field, part] = key.split('.'); + if (group === 'fields') view.draft.fields[field] = event.target.value; + else if (group === 'env') view.draft.env[field] = event.target.value; + else if (group === 'secrets') view.secrets[field] = event.target.value; + else if (group === 'vars') view.draft.vars[Number(field)][part] = event.target.value; + if (group === 'fields' && ['host', 'port'].includes(field)) { view.scanned = null; view.trusted = false; } + } view.generation++; view.result = null; view.error = null; - if (['host', 'port'].includes(key)) { view.scanned = null; view.trusted = false; } paint(); }); + // A field's error moves everything below it. Showing it mid-press would make the + // click miss what it was aimed at, so it waits for the press to finish. + let pressing = false, waiting = false; + const released = () => { pressing = false; if (waiting) { waiting = false; setTimeout(paint); } }; + view.layer.panel.addEventListener('pointerdown', () => { pressing = true; window.addEventListener('pointerup', released, { once: true, capture: true }); window.addEventListener('pointercancel', released, { once: true, capture: true }); }, true); view.layer.panel.addEventListener('focusout', event => { - const key = event.target.dataset.credField; if (!key || view.busy) return; - view.touched.add(key); paint(); + const key = event.target.dataset.credField; if (!key || key === 'paste' || view.busy) return; + view.touched.add(key.startsWith('vars.') ? 'vars' : key); + if (pressing) waiting = true; else setTimeout(paint); + }); + // That paint can land before a
reports its toggle, so its state is noted on the click. + view.layer.panel.addEventListener('click', event => { + const fold = event.target.closest('summary')?.parentElement; if (!fold) return; + if (fold.matches('.tc-cred-compare')) view.compareOpen = !fold.open; + else if (fold.matches('.tc-wizard-rail-wrap') && Kit.isPhone()) view.railOpen = !fold.open; }); 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); + for (const name of ['dragover', 'dragleave', 'drop']) view.layer.panel.addEventListener(name, event => { + const zone = event.target.closest('[data-cred-drop]'); if (!zone) return; + event.preventDefault(); + if (name === 'dragover') { if (!view.dragging) { view.dragging = true; paint(); } return; } + view.dragging = false; paint(); + if (name === 'drop' && event.dataTransfer.files[0]) void readFile(event.dataTransfer.files[0], zone.dataset.credDrop); + }); 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; + const action = button.dataset.credEditor, secret = button.dataset.secret; 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 === 'copy') return void Kit.copy(S.commands(view.draft, { aliases: ctx.sshAliases() })[Number(button.dataset.index)], button, 'Command copied'); + if (action === 'copy-install') return void Kit.copy(S.installLine(view.generated.publicKey), button, '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 === 'pick-type') { + // A name and description survive a change of type. Secrets and fields don't. + view.generation++; view.draft = { ...S.defaults(button.dataset.value), id: view.draft.id, description: view.draft.description }; view.secrets = {}; view.detected = null; view.step = 'form'; view.result = null; view.touched.clear(); + paint(); + if (isSsh()) setSignIn('generate'); + view.layer.panel.querySelector('#cred-id')?.focus(); + return; + } + if (action === 'change-type') { view.generation++; view.step = 'start'; view.detected = null; view.secrets = {}; view.result = null; view.scanned = null; view.trusted = false; view.error = null; view.touched.clear(); paint(); view.layer.panel.querySelector('#cred-paste')?.focus(); return; } + if (action === 'replace') { view.replacing.add(secret); paint(); view.layer.panel.querySelector('#' + domId('secrets.' + secret))?.focus(); return; } + if (action === 'keep-saved') { delete view.secrets[secret]; view.replacing.delete(secret); view.revealed.delete(secret); view.touched.delete('secrets.' + secret); view.result = null; paint(); view.layer.panel.querySelector('#' + domId('secrets.' + secret) + '-replace')?.focus(); return; } + if (action === 'reveal') { if (view.revealed.has(secret)) view.revealed.delete(secret); else view.revealed.add(secret); paint(); return; } + if (action === 'select') { + view.draft.fields[button.dataset.field] = button.dataset.value; view.result = null; + if (button.dataset.field === 'authType') { view.draft.fields.authName = button.dataset.value === 'query' ? 'api_key' : button.dataset.value === 'header' ? 'X-API-Key' : ''; view.draft.fields.authPrefix = ''; view.touched.delete('fields.authName'); } + paint(); return; + } + if (action === 'var-add') { view.draft.vars.push({ name: '', value: '', secret: true }); paint(); view.layer.panel.querySelector('[data-cred-field="vars.' + (view.draft.vars.length - 1) + '.name"]')?.focus(); return; } + if (action === 'var-remove') { view.draft.vars.splice(Number(button.dataset.index), 1); view.result = null; paint(); return; } + if (action === 'var-secret') { const row = view.draft.vars[Number(button.dataset.index)]; row.secret = !row.secret; row.saved = false; 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 (!keep) { view.secrets = {}; view.replacing.clear(); } + view.revealed.clear(); 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(); } + if (action === 'new') { view.recreateId = view.draft.id; view.create = true; view.original = null; view.conflict = null; view.revision = undefined; paint(); if (valid()) void submit(); else view.layer.panel.querySelector('[data-cred-field^="secrets."]')?.focus(); } }); view.layer.panel.addEventListener('change', event => { + if (event.target.dataset.credFile) { const file = event.target.files[0]; const target = event.target.dataset.credFile; event.target.value = ''; if (file) void readFile(file, target); return; } + if (event.target.dataset.credEditor === 'signin' && !view.busy) { setSignIn(event.target.value); paint(); return; } if (event.target.dataset.credEditor !== 'trust' || view.busy) return; view.trusted = event.target.checked; view.result = null; paint(); if (view.trusted) void test(); }); diff --git a/docker/setup/client/credential-inspector.js b/docker/setup/client/credential-inspector.js index 9c4581f..ff6199b 100644 --- a/docker/setup/client/credential-inspector.js +++ b/docker/setup/client/credential-inspector.js @@ -19,24 +19,43 @@ const T3CredentialInspector = (() => { 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 saved = (profile, entry) => html`${icon('lock')}Saved ${profile.secretChangedAt ? '· changed ' + S.day(profile.secretChangedAt) : '· from before the upgrade'}${entry?.primary || !entry ? action('replace', 'Replace…', { ghost: true }) : ''}`; + const factRows = profile => { + const spec = S.typeOf(profile.type); + if (spec?.dynamic) return html`${Object.entries(profile.fields || {}).map(([name, value]) => html`
${name}
${value}
`)}${(profile.secretNames || []).map(name => html`
${name}
${saved(profile)}
`)}`; + const fields = (spec?.fields || []).filter(entry => entry.kind !== 'derived' && S.applies(entry, profile.fields) && entry.key !== 'authType' && entry.key !== 'authName' && entry.key !== 'authPrefix' && entry.key !== 'auth' && profile.fields[entry.key] !== undefined && profile.fields[entry.key] !== ''); + const auth = profile.fields?.baseUrl ? html`
Auth
${S.kind(profile)}
` : ''; + const secrets = (spec?.secrets || []).filter(entry => profile.secretNames?.includes(entry.key)); + return html`${fields.map(entry => html`
${entry.key === 'testPath' ? 'Test with' : entry.label}
${entry.key === 'testPath' ? 'GET ' + profile.fields.testPath : profile.fields[entry.key]}
`)}${auth}${secrets.map(entry => html`
${entry.label}
${saved(profile, entry)}
`)}`; + }; + const hostKeyRow = profile => profile.type !== 'ssh' ? '' : html`
Host key
${profile.hostKey ? html`${S.keyType(profile.hostKey.type)}${profile.hostKey.fingerprint}${profile.hostKey.firstUse ? 'pinned on first use ' : 'trusted '}${S.day(profile.hostKey.trustedAt)}${profile.hostKey.carried ? ' · carried over' : ''}` : profile.fields?.auth === 'key' ? html`Pinned on the first connection` : html`Not trusted yet${action('review', 'Trust…', { ghost: true })}`}
`; + const connection = profile => html`

Details

${factRows(profile)}${hostKeyRow(profile)}${profile.description ? html`
Description
${profile.description}
` : ''}${profile.delivers?.length ? html`
Commands get
${profile.delivers.map((name, index) => html`${index ? ', ' : ''}${name}`)}
` : ''}
`; + const keySection = profile => { + if (profile.type !== 'ssh') return ''; + if (profile.fields?.auth === 'password') return profile.hostKey ? html`
Switch to a keySigns in once with this password to add a new key, then deletes the password.
${action('use-key', 'Switch…')}
` : ''; + if (!profile.fields?.publicKey) return ''; + return html`

Add this key to a server${action('copy-install', 'Copy', { icon: 'copy' })}

${S.installLine(profile.fields.publicKey)}

Run it on the server as ${profile.fields.username}. The private key stays here.

`; + }; + const activity = profile => { + const origin = S.originText(profile) || 'Added in Setup', link = S.threadUrl(profile.origin, ctx.environmentId()); + const used = profile.usage?.at ? 'Last used ' + M.relTime(profile.usage.at, Date.now()) + (profile.usage.command ? ' by ' + profile.usage.command : '') : 'Never used'; + const usedLink = S.threadUrl(profile.usage, ctx.environmentId()); + return html`

Activity

Added
${origin}, ${S.day(profile.createdAt)}${link ? html` · View thread` : ''}
Used
${used}${usedLink ? html` · View thread` : ''}
${profile.changedBy?.by === 'agent' ? html`
Changed
By an agent, ${S.day(profile.updatedAt)}
` : ''}
`; + }; 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') : ''}
`; + 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 && !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) : ''}
`; + return html`
${state.icon ? icon(state.icon) : html``}
${state.title}${state.desc ? html`${profile.fields?.baseUrl && profile.test?.ok ? html`GET ${profile.fields.testPath || 'the address'} 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) : ''}
    ${S.commands(profile).map((command, index) => html`
  • ${command}
  • `)}
${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 snippet = profile => html`
${S.snippet(profile, { aliases: ctx.sshAliases() }).split('\n').map((line, index) => html`${index ? '\n' : ''}${/^(?:t3-vault|ssh) /.test(line) ? html`${line}` : line}`)}
`; + const use = profile => html`

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

    ${S.commands(profile, { aliases: ctx.sshAliases() }).map((command, index) => html`
  • ${command}
  • `)}
Snippet for SKILL.md${snippet(profile)}

${S.usesPlainSsh(profile, ctx.sshAliases()) ? 'Plain ssh, scp and git work with this name.' : 'Output that repeats a saved value shows [redacted].'}

`; 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``}
`; + return html`

${id}

${profile ? S.typeLabel(profile.type) + (S.kind(profile) ? ' · ' + S.kind(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)}${keySection(profile)}${profile.fields?.baseUrl ? html`

Every requestStays under the address

${requestPreview(profile)}
` : ''}${use(profile)}${usedIn(profile)}${activity(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); @@ -47,8 +66,8 @@ const T3CredentialInspector = (() => { 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'); + if (action === 'snippet') return void Kit.copy(S.snippet(profile, { aliases: ctx.sshAliases() }), button, 'Skill snippet copied'); + if (action === 'command') return void Kit.copy(S.commands(profile, { aliases: ctx.sshAliases() })[Number(button.dataset.index)], button, 'Command copied'); ctx.action(action, id, button); }); }; diff --git a/docker/setup/client/credential-model.js b/docker/setup/client/credential-model.js index 8c05dae..8651b67 100644 --- a/docker/setup/client/credential-model.js +++ b/docker/setup/client/credential-model.js @@ -1,78 +1,198 @@ // Pure credential presentation and draft validation. Saved secrets never enter // this model; request previews and snippets use fixed masks or placeholders. +// What each type holds comes from the server, so this file has no list of types. 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); + let TYPES = new Map(); + const setTypes = list => { if (Array.isArray(list)) TYPES = new Map(list.map(type => [type.id, type])); }; + const typeOf = id => TYPES.get(id) || null; + const typeList = () => [...TYPES.values()]; + const iconFor = id => typeOf(id)?.icon || 'key-round'; + const badge = id => typeOf(id)?.badge || 'Credential'; + const typeLabel = id => typeOf(id)?.label || 'Credential'; + + const validName = value => typeof value === 'string' && value.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value !== 'new'; 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 validHost = host => { + if (!text(host, 253)) return false; + if (/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(host)) return true; + if (!host.includes(':')) return false; + try { const [address, zone, extra] = host.split('%'), probe = 'http://[' + address + ']/'; return new URL(probe).hostname.startsWith('[') && extra === undefined && (zone === undefined || /^[a-z0-9_.-]+$/i.test(zone)); } catch { return false; } + }; + const validEnvName = value => /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(value || '') && !/^(?:PATH|HOME|USER|SHELL|PWD|IFS|LD_.*|DYLD_.*|NODE_OPTIONS|BASH_ENV|ENV)$/.test(value); + + const applies = (entry, fields) => (!entry.needs || Boolean(fields[entry.needs])) && (!entry.when || Object.entries(entry.when).every(([key, values]) => values.includes(fields[key]))); + const withDefaults = (type, fields = {}) => { + const result = { ...fields }; + for (const entry of typeOf(type)?.fields || []) if (result[entry.key] === undefined && entry.default !== undefined) result[entry.key] = entry.default; + return result; + }; + const primarySecret = (type, fields = {}) => (typeOf(type)?.secrets || []).find(entry => entry.primary && applies(entry, fields)) || null; + const envStem = id => (id || 'NAME').toUpperCase().replace(/-/g, '_'); + const resolveEnvName = (template, id) => { + if (!template.includes('{NAME}')) return template; + const stem = envStem(id), suffix = template.replace('{NAME}_', ''); + return stem === suffix || stem.endsWith('_' + suffix) ? stem : template.replace('{NAME}', stem); + }; + // The variable an entry reaches commands under, for a draft that may have no name yet. + const envFor = (draft, entry) => draft.env?.[entry.key] || (entry.env ? resolveEnvName(entry.env, draft.id) : null); + + const defaults = type => ({ id: '', type, description: '', fields: withDefaults(type, {}), env: {}, vars: typeOf(type)?.dynamic ? [{ name: '', value: '', secret: true }] : [] }); + const draftOf = credential => { + if (!credential) return defaults('token'); + const draft = { ...defaults(credential.type), id: credential.id, description: credential.description || '', fields: withDefaults(credential.type, credential.fields), env: {} }; + for (const entry of [...(typeOf(credential.type)?.fields || []), ...(typeOf(credential.type)?.secrets || [])]) if (entry.editableEnv && credential.env?.[entry.key]) draft.env[entry.key] = credential.env[entry.key]; + if (typeOf(credential.type)?.dynamic) draft.vars = [...Object.entries(credential.fields || {}).map(([name, value]) => ({ name, value, secret: false })), ...(credential.secretNames || []).map(name => ({ name, value: '', secret: true, saved: true }))]; + return draft; + }; + const payload = (draft, secrets = {}) => { + const type = typeOf(draft.type), base = { id: draft.id, type: draft.type, description: draft.description || '' }; + if (type?.dynamic) { + const rows = draft.vars.filter(row => row.name); + return { ...base, fields: Object.fromEntries(rows.filter(row => !row.secret).map(row => [row.name, row.value])), secretNames: rows.filter(row => row.secret).map(row => row.name), secrets: Object.fromEntries(rows.filter(row => row.secret && row.value).map(row => [row.name, row.value])) }; + } + const fields = {}; + for (const entry of type?.fields || []) { + if (entry.key === 'publicKey' || !applies(entry, draft.fields)) continue; + const value = draft.fields[entry.key]; + if (value !== undefined && value !== '') fields[entry.key] = entry.kind === 'number' ? Number(value) : value; + } + return { ...base, fields, env: draft.env || {}, secrets: Object.fromEntries(Object.entries(secrets).filter(([, value]) => value)) }; + }; + + const kind = credential => credential.type === 'ssh' ? (credential.fields?.auth === 'key' ? 'Key' : 'Password') : credential.fields?.baseUrl ? (credential.fields.authType === 'header' ? credential.fields.authName || 'Header key' : credential.fields.authType === 'query' ? '?' + (credential.fields.authName || 'key') : 'Bearer token') : null; + const secretKind = credential => primarySecret(credential.type, credential.fields)?.label.toLowerCase() || 'secret'; + const host = url => { try { return new URL(url).host; } catch { return url || ''; } }; + const target = credential => { + const fields = credential.fields || {}, names = credential.delivers || []; + if (credential.type === 'ssh') return (fields.username || 'user') + '@' + (fields.host || 'host') + (Number(fields.port || 22) !== 22 ? ':' + fields.port : ''); + if (credential.type === 'database') return (fields.username ? fields.username + '@' : '') + (fields.host || '') + (fields.database ? '/' + fields.database : ''); + if (credential.type === 'aws') return (fields.accessKeyId ? fields.accessKeyId.slice(0, 4) + '…' + fields.accessKeyId.slice(-4) : '') + (fields.region ? ' · ' + fields.region : ''); + if (credential.type === 'gcp') return fields.clientEmail || fields.project || names.join(', '); + const envName = credential.env?.token || credential.env?.password || credential.env?.content || names[0] || ''; + if (fields.baseUrl) return envName + ' · ' + host(fields.baseUrl); + if (credential.type === 'git') return envName + ' · ' + (fields.host || ''); + return credential.type === 'custom' ? names.slice(0, 4).join(', ') + (names.length > 4 ? ' and ' + (names.length - 4) + ' more' : '') : names.join(', '); + }; + const delivery = credential => { + const files = (typeOf(credential.type)?.secrets || []).some(entry => entry.file); + return files ? 'Given to each command as a temporary file' : (credential.delivers || []).join(', '); + }; + /** What a command would get, before the server has seen this draft. */ + const givesPreview = draft => { + const type = typeOf(draft.type); + if (!type) return []; + if (type.dynamic) return draft.vars.filter(row => row.name).map(row => ({ name: row.name })); + if (draft.type === 'database') return [{ name: 'DATABASE_URL' }, ...(draft.fields.engine === 'postgres' ? [{ name: 'PGPASSWORD' }] : draft.fields.engine === 'mysql' ? [{ name: 'MYSQL_PWD' }] : [])]; + return [...type.fields, ...type.secrets].filter(entry => entry.env && applies(entry, draft.fields)).map(entry => ({ name: envFor(draft, entry), file: Boolean(entry.file) })); + }; + const canTest = credential => credential.type === 'ssh' || Boolean(credential.fields?.baseUrl); + const usageCount = (credential, partial = false) => { const n = credential.usedIn?.length || 0, unit = credential.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 installLine = publicKey => "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '" + publicKey + "' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"; + + const commands = (credential, { aliases = true } = {}) => { + const id = credential.id || 'name', fields = credential.fields || {}; + if (credential.type === 'ssh') { + if (aliases && fields.auth === 'key') return ['ssh ' + id + ' uptime', 'scp app.tar ' + id + ':/srv/']; + return ['t3-vault ssh ' + id + ' -- uptime', ...(aliases ? ['t3-vault run ' + id + ' -- scp app.tar ' + id + ':/srv/'] : [])]; + } + const lines = ['t3-vault run ' + id + ' -- ']; + if (fields.baseUrl) lines.push('t3-vault http ' + id + ' GET ' + C.quote(fields.testPath || '')); + return lines; + }; + const snippet = (credential, { aliases = true } = {}) => { + const id = credential.id, fields = credential.fields || {}; + if (credential.type === 'ssh') return `Run commands on ${id} with its saved login:\n\n${aliases && fields.auth === 'key' ? `ssh ${id} ''` : `t3-vault ssh ${id} -- ''`}\n\nDon’t read or print credential files.`; + if (fields.baseUrl) return `Call ${host(fields.baseUrl)}’s API with its saved key:\n\nt3-vault http ${id} GET \nt3-vault http ${id} POST --stdin < body.json\n\nPaths are relative to ${fields.baseUrl}. Don’t read or print credential files.`; + return `Use ${id} for commands that need it:\n\nt3-vault run ${id} -- \n\nThe command gets ${(credential.delivers || []).join(', ') || 'the saved values'}. Don’t read or print credential files.`; }; - const request = (profile, short = false) => { + const request = (draft, short = false) => { + const fields = draft.fields || {}; 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 base = new URL(fields.baseUrl); if (!base.pathname.endsWith('/')) base.pathname += '/'; + const url = new URL(fields.testPath || '.', base); + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) return 'Enter a valid address and relative test path.'; + if (fields.authType === 'query') url.searchParams.set(fields.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'; + const header = fields.authType === 'query' ? '' : fields.authType === 'header' ? (fields.authName || 'X-API-Key') + ': ' + (fields.authPrefix ? fields.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.'; } + } catch { return 'Enter an address to preview the request.'; } + }; + + const validateField = (entry, value, fields) => { + const blank = value === undefined || value === null || value === ''; + if (blank) return entry.optional || entry.kind === 'derived' ? null : entry.message || 'Enter ' + entry.label.toLowerCase() + '.'; + if (entry.kind === 'number') return Number.isInteger(Number(value)) && Number(value) >= entry.min && Number(value) <= entry.max ? null : entry.message; + if (entry.kind === 'select') return entry.options.some(([option]) => option === value) ? null : 'Choose ' + entry.label.toLowerCase() + '.'; + if (entry.kind === 'host') return validHost(value) ? null : entry.message; + if (!text(String(value), entry.max ?? 2048)) return entry.message || 'Use up to ' + (entry.max ?? 2048) + ' characters, on one line.'; + if (entry.pattern && !new RegExp(entry.pattern).test(value)) return entry.message; + if (entry.key === 'baseUrl') { + let base; try { base = new URL(value); } catch {} + if (!base || !['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) return 'Use an HTTP or HTTPS address without credentials, a query or a fragment.'; + } + if (entry.key === 'authName' && fields.authType === 'header' && (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(value) || ['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'trailer', 'te', 'accept', 'content-type'].includes(value.toLowerCase()))) return 'Use a valid authentication header, such as X-API-Key.'; + if (entry.key === 'authPrefix' && (value.length > 128 || /[^\x20-\x7e]/.test(value))) return 'Use up to 128 printable characters for the prefix.'; + if (entry.key === 'testPath') { + if (value.startsWith('/') || value.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(value)) return 'Use a relative path, such as account.'; + try { + const base = new URL(fields.baseUrl); if (!base.pathname.endsWith('/')) base.pathname += '/'; + const url = new URL(value, base); + if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname) || url.hash) return 'The test path must stay under the address.'; + } catch { /* The address reports its own error. */ } + } + return null; }; - 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.'; + /** Field errors, keyed like the inputs: id, fields.host, secrets.token, env.token, vars. */ + const validate = (draft, { editing = false, names = [], secrets = {}, savedSecrets = [], needSecret = true } = {}) => { + const errors = {}, type = typeOf(draft.type); + if (!validName(draft.id)) errors.id = draft.id === 'new' ? 'That name is reserved. Choose another.' : '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 (!type) { errors.type = 'Choose a type.'; return errors; } + if (typeof draft.description === 'string' && draft.description.length > 200) errors.description = 'Use up to 200 characters.'; + if (type.dynamic) { + const rows = draft.vars.filter(row => row.name || row.value), seen = new Set(); + if (!rows.length) errors.vars = 'Add at least one variable.'; + for (const row of rows) { + if (!validEnvName(row.name)) errors.vars = (row.name || 'Each variable') + ' needs a name of letters, numbers and underscores.'; + else if (seen.has(row.name)) errors.vars = row.name + ' is listed twice.'; + else if (row.secret && !row.value && !row.saved) errors.vars = 'Enter a value for ' + row.name + '.'; + seen.add(row.name); } + return errors; + } + for (const entry of type.fields) { + if (!applies(entry, draft.fields)) continue; + const problem = validateField(entry, draft.fields[entry.key], draft.fields); + if (problem) errors['fields.' + entry.key] = problem; + } + if (needSecret) for (const entry of type.secrets) { + if (!applies(entry, draft.fields) || entry.optional) continue; + const value = secrets[entry.key]; + if (!value && !savedSecrets.includes(entry.key)) errors['secrets.' + entry.key] = 'Enter ' + entry.label.toLowerCase() + '.'; + else if (value && !entry.multiline && !text(value.replace(/\r?\n$/, ''), 8192)) errors['secrets.' + entry.key] = 'Use up to 8192 characters, without line breaks.'; + else if (value && entry.multiline && value.length > 48 * 1024) errors['secrets.' + entry.key] = 'Use up to 48 KiB.'; + } + for (const entry of [...type.fields, ...type.secrets]) { + const value = draft.env?.[entry.key]; + if (entry.editableEnv && value && !validEnvName(value)) errors['env.' + entry.key] = 'Use letters, numbers and underscores, and not a name the system relies on.'; } - 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 }; + const changes = (previous, current) => { + const keys = new Set([...Object.keys(previous?.fields || {}), ...Object.keys(current?.fields || {})]); + return [...keys].filter(key => key !== 'publicKey' && String(previous?.fields?.[key] ?? '') !== String(current?.fields?.[key] ?? '')); + }; + const fieldLabel = (key, type) => (typeOf(type)?.fields || []).find(entry => entry.key === key)?.label || key; + const matches = (credential, query) => !query.trim() || [credential.id, credential.description, target(credential), kind(credential), typeLabel(credential.type), credential.type, ...(credential.delivers || []), ...Object.values(credential.fields || {}).map(String)].filter(Boolean).join(' ').toLowerCase().includes(query.trim().toLowerCase()); + const needsAttention = (credential, attention) => credential.type === 'ssh' && (credential.fields?.auth === 'password' && !credential.hostKey || credential.test?.code === 'host-key-changed') || credential.test?.ok === false && (credential.test.code !== 'timeout' || attention?.some(row => row.id === credential.id)); + const threadUrl = (origin, environmentId) => origin?.threadId && environmentId ? '/' + encodeURIComponent(environmentId) + '/' + encodeURIComponent(origin.threadId) : null; + const usesPlainSsh = (credential, aliases) => aliases && credential.type === 'ssh' && credential.fields?.auth === 'key'; + const originText = credential => credential.origin?.by === 'agent' ? 'Added by an agent' : credential.origin?.by === 'terminal' ? 'Added from a terminal' : null; + + return { usesPlainSsh, givesPreview, setTypes, typeOf, typeList, iconFor, badge, typeLabel, validName, applies, withDefaults, primarySecret, envFor, resolveEnvName, defaults, draftOf, payload, kind, secretKind, target, delivery, canTest, usageCount, day, keyType, keyCommand, installLine, commands, snippet, request, validate, changes, fieldLabel, matches, needsAttention, threadUrl, originText, state: M.credentialState }; })(); diff --git a/docker/setup/client/credentials.js b/docker/setup/client/credentials.js index 5b2fd6c..bbad127 100644 --- a/docker/setup/client/credentials.js +++ b/docker/setup/client/credentials.js @@ -18,7 +18,7 @@ const T3CredentialsUI = (() => { 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; } + if (response.ok && Array.isArray(response.data?.credentials)) { S.setTypes(response.data.types); 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; @@ -44,21 +44,21 @@ const T3CredentialsUI = (() => { 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, + const ctx = { api, busy, errors, rows, row, usageWarnings, sshAliases: () => data?.sshAliases !== false, environmentId: () => data?.environmentId || null, inspect, 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'); + if (location.hash === '#vault/' + id) history.replaceState(null, '', '#vault'); 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; } }, + inspectorClosed: (id, reason) => { if (!['route', 'replaced'].includes(reason) && location.hash === '#vault/' + id) { history.replaceState(null, '', '#vault'); 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); @@ -71,12 +71,14 @@ const T3CredentialsUI = (() => { 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 (action === 'copy-command') return Kit.copy(S.commands(profile, { aliases: ctx.sshAliases() })[0], button, 'Command copied'); + if (action === 'copy-install' && profile.fields?.publicKey) return Kit.copy(S.installLine(profile.fields.publicKey), 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 === 'review') { if (profile.test?.code === 'host-key-changed' || !profile.hostKey) return dialogs.review(id); return inspect(id); } if (action === 'delete') return dialogs.remove(id); if (action === 'forget') return dialogs.forget(id); + if (action === 'use-key') return dialogs.useKey(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 }); @@ -84,28 +86,36 @@ const T3CredentialsUI = (() => { } }; 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) }, + ...(S.canTest(profile) ? [{ 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) }, + ...(profile.type === 'ssh' && profile.fields?.auth === 'password' && profile.hostKey ? [{ label: 'Switch to a key…', icon: 'key-round', disabled: busy.has(profile.id), run: () => dialogs.useKey(profile.id) }] : []), ...(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 nameBadge = profile => html`${S.badge(profile.type)}`; 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'; + if (!state.action) return ''; return html``; }; + const lastUsed = profile => profile.usage?.at ? 'used ' + M.relTime(profile.usage.at, Date.now()) : 'never used'; + // Who added it and when it last ran, which tells you what's safe to delete. + const meta = profile => { + const origin = S.originText(profile), link = S.threadUrl(profile.origin, data?.environmentId); + return html`${profile.usedIn?.length ? html`${icon('book-open')}Used in ${profile.usedIn.map(entry => entry.name).join(', ')} · ${lastUsed(profile)}` : origin ? html`${icon('history')}${origin}${link ? html` in a thread` : ''} · ${lastUsed(profile)}` : html`${icon('history')}${lastUsed(profile)[0].toUpperCase() + lastUsed(profile).slice(1)}`}`; + }; 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)}
`; + if (Kit.isPhone()) return html``; + return html`
${nameBadge(profile)}
${S.target(profile)}
${S.kind(profile) ? html`${icon('key-round')}${S.kind(profile)}` : ''}${meta(profile)}
${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 toolbar = () => html`
${attention().length || attentionOnly ? html`` : ''}
`; + const empty = () => html`
Keep secrets out of chats and skills

Save a key, token or password once, and every agent can use it by name. Agents can also ask you for one in chat and save it here.

1Save it once

Paste it here, or an agent asks you for it in chat. It stays on this server.

2Agents use its name

Skills and chats only ever carry the name.

t3-vault run github -- gh pr list
3The command gets the secret

t3-vault hands it to that one command, and hides it if the output repeats it.

${footer()}
`; const body = () => { if (error) return html``; if (!data) return html`
${[1, 2, 3].map(index => html``)}
`; @@ -115,22 +125,28 @@ const T3CredentialsUI = (() => { }; 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(); }); + window.addEventListener('focus', () => { if (started && location.hash.replace(/^#\/?/, '').split('/')[0] === 'vault' && !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 [, id, type, name] = location.hash.replace(/^#\/?/, '').split('/'); const loaded = force || !started ? await load() : Boolean(data); if (!loaded) return; paint(); + // An agent can send someone straight to a form it has filled in the start of. + if (id === 'new') { + // Opening uses the address up, so the same link works again after this form closes. + if (!editor.isOpen() && location.hash.replace(/^#\/?/, '').split('/')[1] === 'new') { history.replaceState(null, '', '#vault'); await editor.open(null, { type: S.typeOf(type) ? type : null, name: S.validName(name) ? name : '' }); } + return; + } 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 Kit.toast('Credential not found', { tone: 'info', 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 } })), + paletteItems: () => rows().filter(profile => !getStatus()?.credentials?.attention?.some(item => item.id === profile.id)).map(profile => ({ id: 'credential:' + profile.id, group: 'Vault', label: profile.id, icon: S.iconFor(profile.type), cmd: { cmd: 'goto', route: 'vault/' + profile.id }, keywords: 'credential vault ' + 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(); }, }; diff --git a/docker/setup/client/kit.js b/docker/setup/client/kit.js index 81a856b..11ef0c8 100644 --- a/docker/setup/client/kit.js +++ b/docker/setup/client/kit.js @@ -523,6 +523,7 @@ const Kit = (() => { // --------------------------------------------------------------- toasts -- const TOAST_MS = 4000; + const WARN_TOAST_MS = 10000; const TOAST_MAX = 3; /** * Bottom-right on desktop, bottom-centre above the tab bar on a phone. Three @@ -536,12 +537,12 @@ const Kit = (() => { const tone = opts.tone || 'ok'; const sticky = tone === 'danger' || opts.sticky; const actions=(opts.actions||(opts.action?[opts.action]:[])).slice(0,2); - const duration=Number.isFinite(opts.duration)?Math.max(1000,Math.min(60_000,opts.duration)):TOAST_MS; + const duration=Number.isFinite(opts.duration)?Math.max(1000,Math.min(60_000,opts.duration)):tone==='warn'?WARN_TOAST_MS:TOAST_MS; const node = document.createElement('div'); node.className = 'tc-toast'; node.setAttribute('data-tone', tone); node.setAttribute('role', tone === 'danger' ? 'alert' : 'status'); - node.innerHTML = String(html`${icon(tone === 'danger' ? 'circle-alert' : tone === 'info' ? 'info' : 'circle-check')}${text}${opts.detail ? html`${opts.detail}` : ''}${actions.map((action,index)=>html``)}${sticky ? html`` : ''}`); + node.innerHTML = String(html`${icon(tone === 'danger' ? 'circle-alert' : tone === 'warn' ? 'triangle-alert' : tone === 'info' ? 'info' : 'circle-check')}${text}${opts.detail ? html`${opts.detail}` : ''}${actions.map((action,index)=>html``)}${sticky ? html`` : ''}`); let timer = null; const leave = () => { if (!node.isConnected || node.hasAttribute('data-leaving')) return; diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index 602065d..22a171a 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -1371,7 +1371,7 @@ const T3Model = (() => { 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' } }); + name: item.id, text: credentialProblem(item.code), action: { cmd: 'goto', route: 'vault/' + 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); @@ -1381,6 +1381,8 @@ const T3Model = (() => { 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"}, + "key-rejected": {"tone": "danger", "row": ["Key not accepted", "{when}"], "sheet": {"title": "Key not accepted", "desc": "{host} didn’t accept this key for {user}. Add its public key to the server, then test again."}, "action": "Test"}, + "not-connected": {"tone": "muted", "row": ["Not connected yet", ""], "sheet": {"title": "Not connected yet", "desc": "Its identity gets pinned the first time {host} is reached."}, "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"}, @@ -1399,6 +1401,13 @@ const T3Model = (() => { "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"}, }, + "no-test": { + "tone": "muted", + "attention": false, + "row": ["No test set up", ""], + "sheet": {"title": "No test set up", "desc": "Add an API address to test it here, or run a real command with it."}, + "action": "", + }, untested: { "tone": "muted", "attention": false, @@ -1413,16 +1422,18 @@ const T3Model = (() => { }, }; 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, + const result = profile.test, fields = profile.fields || {}, ssh = profile.type === 'ssh', keyAuth = ssh && fields.auth === 'key', testable = ssh || Boolean(fields.baseUrl); + let code = busy ? 'testing' : ssh && !profile.hostKey && !keyAuth ? 'host-key-untrusted' : !testable ? 'no-test' : result?.code || (keyAuth && !profile.hostKey ? 'not-connected' : 'untested'); + const group = ssh ? CREDENTIAL_RESULTS.ssh : CREDENTIAL_RESULTS.api; + const source = CREDENTIAL_RESULTS[code] || group[code === 'auth-rejected' && keyAuth ? 'key-rejected' : code] || CREDENTIAL_RESULTS.untested; + let host = fields.host || fields.baseUrl || ''; + if (!ssh && fields.baseUrl) { try { host = new URL(fields.baseUrl).host; } catch {} } + const values = { id: profile.id, host, port: fields.port, status: result?.detail?.status, user: fields.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' }; + path: fields.testPath || 'the address', secretKind: fields.authType === 'bearer' ? 'token' : 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' }; + 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'; @@ -1530,7 +1541,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), + vault: 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 @@ -1726,7 +1737,7 @@ const T3Model = (() => { } 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('Vault', item.id, 'key-round', { cmd: 'goto', route: 'vault/' + 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' }); diff --git a/docker/setup/client/routes.js b/docker/setup/client/routes.js index c8c35ed..cc91a0f 100644 --- a/docker/setup/client/routes.js +++ b/docker/setup/client/routes.js @@ -30,8 +30,8 @@ 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: 'vault', title: 'Vault', icon: 'key-round', key: 'c', group: 'agents', + summary: 'Credentials your agents use by name. Agents can add them from chat too. Secrets stay out of chats and skills.', doc: 'vault.md', palette: { meta: 'Setup · credentials agents use by name', terms: ['credentials', 'ssh', 'ssh key', 'api key', 'token', 'password', 'secret', 'database', 'aws', 'kubeconfig'] } }, { 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'] } }, @@ -97,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 || (typeof name === 'string' && /^credentials\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) && name.slice(12).length <= 64 ? name : null); + const destination = name => resolve(name) || DESTINATIONS.find(item => item.hash === name)?.hash || (typeof name === 'string' && /^vault\/(?:new(?:\/[a-z]+(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)?)?|[a-z0-9]+(?:-[a-z0-9]+)*)$/.test(name) && name.split('/').at(-1).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 0f01e3a..6c88709 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -1571,6 +1571,7 @@ html.tc-locked { overflow: hidden; } :where(:root[data-compact]) body[data-console-route="mcp"] .tc-phonebar, :where(:root[data-compact]) body[data-console-route="mcp"] .tc-tabbar { display: none; } :where(:root[data-compact]) .tc-mcp-editor .tc-wizard-head > .tc-spacer { display: none; } + :where(:root[data-compact]) .tc-cred-editor .tc-wizard-head > .tc-spacer { display: none; } :where(:root[data-compact]) .tc-mcp-editor .tc-wizard-head > .tc-sheet-head-text { flex: 1; } .tc-mcp-editor .tc-mcp-secret-input { white-space: pre; scrollbar-width: none; } @@ -3376,6 +3377,9 @@ body[data-console-route="files"]:has(.tc-layer) .tc-toaster { visibility: hidden :root[data-host="tab"] .tc-wizard-rail { min-height: 0; padding: 0 1rem 1rem; } :root[data-host="tab"] .tc-wizard-rail-wrap:not([data-open]) > .tc-wizard-rail { display: none; } :root[data-host="tab"] .tc-wizard-rail-wrap[data-open] .tc-wizard-rail-toggle > .tc-icon { transform: rotate(180deg); } + /* The Vault editor shows its whole panel at these widths, which leaves a toggle nothing to do. */ + :root[data-host="tab"]:not([data-compact]) .tc-cred-editor .tc-wizard-rail-toggle { display: none; } + :root[data-host="tab"]:not([data-compact]) .tc-cred-editor .tc-wizard-rail { padding-top: 1rem; } } /* The selection lane can be much narrower than the console when Details is @@ -3473,6 +3477,57 @@ body[data-console-route="files"]:has(.tc-layer) .tc-toaster { visibility: hidden .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); } +/* ------------------------------------------------------------ Vault additions --- */ +.tc-cred-toolbar .tc-sk-search { flex: 1 1 12rem; min-width: 9rem; width: auto; } +.tc-cred-link { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 2px; } +.tc-cred-link:hover { color: var(--fg); } +.tc-cred-gives { margin: 0; font-size: 0.75rem; line-height: 1.25rem; color: var(--fg-muted); overflow-wrap: anywhere; } +.tc-cred-gives code { font: 400 0.75rem/1.25rem var(--font-mono); color: var(--fg); } +.tc-cred-optional { font-weight: 400; font-size: 0.75rem; color: var(--fg-muted); } +.tc-cred-labelrow { display: flex; align-items: center; gap: 0.25rem; min-height: 1.5rem; } +.tc-cred-file { cursor: pointer; } +.tc-cred-file:focus-within { outline: 2px solid var(--ring, var(--accent)); outline-offset: 1px; } +.tc-cred-textarea { height: auto; min-height: 6.5rem; padding-block: 0.5rem; resize: vertical; line-height: 1.25rem; white-space: pre; overflow: auto; } +.tc-cred-masked { -webkit-text-security: disc; } +@supports not (-webkit-text-security: disc) { .tc-cred-masked { color: transparent; text-shadow: 0 0 6px var(--fg); } } +.tc-cred-start { display: flex; flex-direction: column; gap: 1rem; min-width: 0; } +.tc-cred-start > .tc-insp-label { margin: 0.25rem 0 -0.25rem; } +.tc-cred-startgrid { grid-template-columns: minmax(0, 1fr) !important; } +.tc-cred-drop { display: flex; flex-direction: column; border: 1.5px dashed var(--border-strong); border-radius: var(--radius-xl); background: var(--surface-sunken); transition: border-color 0.12s ease; } +.tc-cred-drop:focus-within, .tc-cred-drop[data-dragging="true"] { border-color: var(--accent); border-style: solid; } +.tc-cred-paste { appearance: none; display: block; width: 100%; min-height: 5rem; padding: 0.75rem 0.875rem 0.25rem; border: 0; outline: 0; resize: none; background: transparent; color: var(--fg); font: 400 0.8125rem/1.25rem var(--font-mono); } +.tc-cred-paste::placeholder { -webkit-text-security: none; color: var(--fg-muted); font-family: var(--font-sans); font-size: 0.875rem; } +.tc-cred-drop-foot { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0.5rem 0.5rem 0.875rem; min-width: 0; } +.tc-cred-drop-foot .tc-hint { margin: 0; display: inline-flex; align-items: center; gap: 0.375rem; } +.tc-cred-types { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr)); gap: 0.5rem; } +.tc-cred-typecard { appearance: none; display: flex; align-items: center; gap: 0.625rem; min-height: 2.75rem; padding: 0.5rem 0.75rem; border: 1px solid var(--hairline); border-radius: var(--radius-lg); background: transparent; color: var(--fg); font: inherit; font-size: 0.8125rem; line-height: 1.125rem; text-align: left; cursor: pointer; } +.tc-cred-typecard:hover { background: var(--hover); border-color: var(--border); } +.tc-cred-typecard:focus-visible { outline: 2px solid var(--ring, var(--accent)); outline-offset: 1px; } +.tc-cred-typecard > .tc-icon { flex: none; width: 1rem; height: 1rem; color: var(--fg-muted); } +.tc-cred-detect { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: var(--radius-lg); font-size: 0.8125rem; line-height: 1.25rem; background: color-mix(in srgb, var(--ok) 8%, transparent); } +.tc-cred-detect[data-sure="false"] { background: var(--surface-sunken); } +.tc-cred-detect > .tc-icon { flex: none; width: 0.875rem; height: 0.875rem; color: var(--ok); } +.tc-cred-detect[data-sure="false"] > .tc-icon { color: var(--fg-muted); } +.tc-cred-detect > span { flex: 1; min-width: 0; } +.tc-cred-detect .tc-btn--link { flex: none; height: auto; padding: 0; font-size: 0.75rem; font-weight: 400; color: var(--fg-muted); text-decoration: underline; text-underline-offset: 2px; } +:where(:root:not([data-compact])) .tc-wizard.tc-cred-editor.tc-cred-editor--start { height: auto; } +.tc-cred-options { display: flex; flex-direction: column; gap: 0.375rem; } +.tc-cred-option { display: grid; grid-template-columns: 1rem minmax(0, 1fr); gap: 0.625rem; padding: 0.5625rem 0.75rem; border: 1px solid var(--hairline); border-radius: var(--radius-lg); cursor: pointer; } +.tc-cred-option:hover { background: var(--hover); } +.tc-cred-option[data-on="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 6%, transparent); } +.tc-cred-option:focus-within { outline: 2px solid var(--ring, var(--accent)); outline-offset: 1px; } +.tc-cred-radio { width: 0.875rem; height: 0.875rem; margin-top: 0.1875rem; border-radius: 50%; border: 1.5px solid var(--border-strong); } +.tc-cred-option[data-on="true"] .tc-cred-radio { border: 0.25rem solid var(--accent); } +.tc-cred-option b { font-size: 0.8125rem; line-height: 1.25rem; font-weight: 500; color: var(--fg); } +.tc-cred-option em { margin-left: 0.5rem; font-style: normal; font-size: 0.6875rem; font-weight: 500; color: var(--ok-text, var(--ok)); } +.tc-cred-option small { display: block; font-size: 0.75rem; line-height: 1.0625rem; color: var(--fg-muted); } +.tc-cred-install { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; padding: 0.625rem 0.75rem; border-radius: var(--radius-lg); border: 1px solid var(--hairline); background: var(--surface-sunken); min-width: 0; } +.tc-cred-install code { font: 400 0.6875rem/1.0625rem var(--font-mono); color: var(--fg); overflow-wrap: anywhere; } +.tc-cred-vars { display: flex; flex-direction: column; gap: 0.375rem; } +.tc-cred-var { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) auto auto; gap: 0.25rem; align-items: center; } +.tc-cred-var-add { align-self: flex-start; } +:where(:root[data-compact]) .tc-cred-types { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + /* ---------------------------------------------------------------- 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; } diff --git a/docker/setup/credentials.mjs b/docker/setup/credentials.mjs index 9e90d94..25d0b43 100644 --- a/docker/setup/credentials.mjs +++ b/docker/setup/credentials.mjs @@ -1,27 +1,55 @@ // 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 * as fs from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import { createCredentialIO } from './credentials/io.mjs'; -import { createCredentialState, emptyState, cleanResult } from './credentials/state.mjs'; +import { createCredentialState, createUsageLog, emptyState, cleanResult } from './credentials/state.mjs'; import { createHostKeys, publicKey, keyFile } from './credentials/hostkeys.mjs'; -import { normalize } from './credentials/model.mjs'; +import { normalize, upgradeProfile } from './credentials/model.mjs'; import { CredentialError, validId, object } from './credentials/errors.mjs'; +import { envNames } from './credentials/types.mjs'; +import { deliveredNames, secretValues } from './credentials/deliver.mjs'; +import { ensureInclude, hasInclude, sshConfigText } from './credentials/sshconfig.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 VERSION = 3; 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 serialize = credentials => JSON.stringify({ version: VERSION, credentials }, 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.'); }; +const missing = id => new CredentialError(`No saved credential named ${id}.`, 404, 'not-found'); +const cleanOrigin = value => object(value) && ['agent', 'setup', 'terminal'].includes(value.by) ? { by: value.by, ...(typeof value.threadId === 'string' && /^[\w:.-]{1,128}$/.test(value.threadId) ? { threadId: value.threadId } : {}) } : null; +const sameContent = (a, b) => JSON.stringify([a.fields, a.secrets, a.env]) === JSON.stringify([b.fields, b.secrets, b.env]); -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 }); +export function createCredentialStore({ directory = credentialDirectory(), readOnly = false, home = process.env.HOME || '/home/t3', io = createCredentialIO({ directory, readOnly }), hostKeys = createHostKeys({ io, home }), clock = Date.now, sshEffects = fs } = {}) { + const state = createCredentialState({ io }), usage = createUsageLog({ directory, clock }); 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'); + const configFile = () => io.file('ssh_config'); + + // Files OpenSSH reads, rebuilt from the store. Failing here costs plain `ssh`, + // not the saved credential, and `t3-vault ssh` doesn't need them. + const syncSsh = async credentials => { + if (io.readOnly) return false; + const rows = credentials.filter(row => row.type === 'ssh'); + try { + const wanted = new Set(); + await io.prepare('usage'); + for (const row of rows.filter(entry => entry.fields.auth === 'key')) { + wanted.add(row.id); + await io.write('keys/' + row.id, row.secrets.privateKey, { maxBytes: 64 * 1024, recover: true }); + } + for (const name of await io.list('keys')) if (!wanted.has(name)) await io.remove('keys/' + name); + await io.write('ssh_config', sshConfigText(credentials, { root: io.directory }), { recover: true }); + return rows.length ? await ensureInclude({ home, file: configFile(), effects: sshEffects }) : true; + } catch { return false; } + }; + + const commit = async (data, credentials, metadata, keys = new Map(), writeStore = true) => { + if (io.readOnly) throw new CredentialError('This command can\'t 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(); @@ -30,7 +58,7 @@ export function createCredentialStore({ directory = credentialDirectory(), readO 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)); } + if (writeStore) { attemptedStore = true; await io.write('credentials.json', serialize(credentials)); } } catch (error) { let restored = true; if (attemptedStore) { @@ -53,138 +81,162 @@ export function createCredentialStore({ directory = credentialDirectory(), readO } throw error; } + await syncSsh(credentials); }; + + // Version 1 had no dates or revisions. Version 2 had one secret per credential. 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); + const at = file.mtime, metadata = data.version === 1 ? emptyState() : await state.read([]), keys = new Map(); + const credentials = data.profiles.map(input => { + const upgraded = upgradeProfile(input), normalized = normalize(upgraded); + return { ...normalized, createdAt: upgraded.createdAt || at, updatedAt: upgraded.updatedAt || at, secretChangedAt: upgraded.secretChangedAt ?? null, revision: upgraded.revision || revision() }; + }); + if (data.version === 1) { + for (const credential of credentials.filter(row => row.type === 'ssh')) { + const key = await hostKeys.legacy({ host: credential.fields.host, port: credential.fields.port }); + keys.set(credential.id, key || null); + if (key) metadata.hostKeys[credential.id] = hostKeys.metadata(key, { carried: true }); + } } - await commit({ raw: file.text }, profiles, metadata, keys); - return { profiles, raw: serialize(profiles) }; + await commit({ raw: file.text }, credentials, metadata, keys); + return { credentials, raw: serialize(credentials) }; }; + const read = async () => { const file = await io.read('credentials.json'); - if (!file) return { profiles: [], raw: null }; + if (!file) return { credentials: [], 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 (!object(data) || ![1, 2, VERSION].includes(data.version)) throw unreadable(); + const rows = data.version === VERSION ? data.credentials : data.profiles; + if (!Array.isArray(rows) || rows.length > 64) throw unreadable(); + if (new Set(rows.map(row => row?.id)).size !== rows.length) throw unreadable(); + if (data.version === VERSION) data.credentials = rows.map(input => { + const credential = 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 }; + const origin = cleanOrigin(input.origin), changedBy = cleanOrigin(input.changedBy); + return { ...credential, createdAt: input.createdAt, updatedAt: input.updatedAt, secretChangedAt: input.secretChangedAt, revision: input.revision, ...(origin ? { origin } : {}), ...(changedBy ? { changedBy } : {}) }; }); - else data.profiles.forEach(input => normalize(input)); + else rows.forEach(input => normalize(upgradeProfile(input))); } catch { throw unreadable(); } - if (data.version === 1) { + if (data.version !== VERSION) { try { return await migrate(data, file); } catch (error) { if (error.code === 'migration') throw error; throw unreadable(); } } - return { profiles: data.profiles, raw: file.text }; + return { credentials: data.credentials, raw: file.text }; }; - const publicProfile = async (profile, metadata) => { - const { secret, ...visible } = profile; + + const publicCredential = async (credential, metadata) => { + const { secrets, ...visible } = credential; 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 }; + if (credential.type === 'ssh') { try { key = await hostKeys.read(credential.id); } catch { /* Unsafe files cannot confer trust. */ } } + const saved = metadata.hostKeys[credential.id], test = metadata.tests[credential.id], values = secretValues(credential); + const same = saved?.fingerprint === key?.fingerprint; + return { ...visible, env: envNames(credential), delivers: deliveredNames(credential), secretNames: Object.keys(secrets), hasSecret: values.length > 0, + test: test && (!test.revision || test.revision === credential.revision) ? cleanResult(test, values) : null, + usage: await usage.read(credential.id), + hostKey: credential.type === 'ssh' && key ? { ...publicKey(key), trustedAt: same ? saved.trustedAt : key.trustedAt, carried: same && saved.carried === true, firstUse: same && saved.firstUse === 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 credential = data.credentials.find(row => row.id === id); + if (!credential) throw new CredentialError('This credential was deleted in another window.', 409, 'gone'); + if (expected !== credential.revision) throw new CredentialError('This credential changed in another window.', 409, 'changed', { current: await publicCredential(credential, await state.read(data.credentials)) }); + return credential; }; - const privateProfile = (data, id) => { + const privateCredential = (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 credential = data.credentials.find(row => row.id === id); + if (!credential) throw missing(id); + return credential; }; 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)); }), + io, hostKeys, usage, + initialize: () => io.transaction(async () => { const data = await read(); await syncSsh(data.credentials); }), + list: () => io.transaction(async () => { + const data = await read(), metadata = await state.read(data.credentials); + return { credentials: await Promise.all(data.credentials.map(credential => publicCredential(credential, metadata))), + sshAliases: await hasInclude({ home, file: configFile(), effects: sshEffects }) }; + }), + public: id => io.transaction(async () => { const data = await read(); return publicCredential(privateCredential(data, id), await state.read(data.credentials)); }), 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 data = await read(), previous = id === undefined ? undefined : privateCredential(data, id), credential = normalize(draft, previous); + if (credential.type === 'ssh' && previous && credential.fields.host === previous.fields.host && credential.fields.port === previous.fields.port) { const key = await hostKeys.read(id).catch(() => null); - if (key) return { ...profile, knownHostsFile: key.file, hostKeyType: key.type }; + if (key) return { ...credential, knownHostsFile: key.file, hostKeyType: key.type }; } - return profile; + return credential; }), - save: ({ credential, revision: expected, create = false }) => io.transaction(async () => { + save: ({ credential, revision: expected, create = false, origin = { by: 'setup' }, pin = null }) => 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'); + const previous = data.credentials.find(row => row.id === credential.id); + if (create && previous) throw new CredentialError(`${credential.id} is already saved. Choose another name, or replace it.`, 409, 'taken'); if (!create) await current(data, credential.id, expected); - if (!previous && data.profiles.length >= 64) throw new CredentialError('Save at most 64 credentials.'); + if (!previous && data.credentials.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); + const who = cleanOrigin(origin) || { by: 'setup' }; + const secretsChanged = !previous || JSON.stringify(normalized.secrets) !== JSON.stringify(previous.secrets); + const saved = { ...normalized, createdAt: previous?.createdAt || at, updatedAt: at, secretChangedAt: secretsChanged ? at : previous.secretChangedAt, revision: revision(), origin: previous?.origin || who, ...(previous ? { changedBy: who } : {}) }; + const metadata = await state.read(data.credentials), keys = new Map(); + const changed = !previous || !sameContent(normalized, previous); 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) }; + if (saved.type === 'ssh' && (!previous || saved.fields.host !== previous.fields.host || saved.fields.port !== previous.fields.port)) { keys.set(saved.id, null); delete metadata.hostKeys[saved.id]; } + if (pin) { keys.set(saved.id, pin.key); metadata.hostKeys[saved.id] = hostKeys.metadata(pin.key, { firstUse: pin.firstUse === true }); } + await commit(data, [...data.credentials.filter(row => row.id !== saved.id), saved], metadata, keys); + return { credential: await publicCredential(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); + const data = await read(), credential = await current(data, id, expected), metadata = await state.read(data.credentials); 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()); + await commit(data, data.credentials.filter(row => row.id !== id), metadata, credential.type === 'ssh' ? new Map([[id, null]]) : new Map()); + await usage.remove(id); return {}; }), forUse: id => io.transaction(async () => { - const profile = privateProfile(await read(), id); - if (profile.type !== 'ssh') return profile; + const credential = privateCredential(await read(), id); + if (credential.type !== 'ssh') return credential; 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 }; + try { key = await hostKeys.read(id); } catch { /* Commands must stop at missing or unsafe trust. */ } + return { ...credential, 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); + trust: ({ id, revision: expected, key, firstUse = false }) => io.transaction(async () => { + const data = await read(), credential = await current(data, id, expected); + if (credential.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + const metadata = await state.read(data.credentials); + metadata.hostKeys[id] = hostKeys.metadata(key, { firstUse }); delete metadata.tests[id]; + await commit(data, data.credentials, metadata, new Map([[id, key]]), false); + return publicCredential(credential, 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); + const data = await read(), credential = privateCredential(data, id); + if (credential.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + const metadata = await state.read(data.credentials); delete metadata.hostKeys[id]; delete metadata.tests[id]; - await commit(data, data.profiles, metadata, new Map([[id, null]]), false); - return publicProfile(profile, metadata); + await commit(data, data.credentials, metadata, new Map([[id, null]]), false); + return publicCredential(credential, 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); + const data = await read(), credential = data.credentials.find(row => row.id === id); + if (!credential || credential.revision !== expected) return false; + if (credential.type === 'ssh') { const key = await hostKeys.read(id).catch(() => null); if ((key?.fingerprint || null) !== hostFingerprint) return false; } + const metadata = await state.read(data.credentials), previous = metadata.tests[id], cleaned = cleanResult(result, secretValues(credential)); 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 }] : []; + const data = await read(), metadata = await state.read(data.credentials), credentials = await Promise.all(data.credentials.map(credential => publicCredential(credential, metadata))); + return { count: credentials.length, attention: credentials.flatMap(credential => { + let code = credential.test?.ok === false ? credential.test.code : null; + if (code === 'timeout' && (metadata.tests[credential.id]?.consecutiveTimeouts || 0) < 2) code = null; + // A password is never sent to an unconfirmed host, so someone has to look. + // A key waits for its first connection, which pins the host by itself. + if (credential.type === 'ssh' && !credential.hostKey && credential.fields.auth === 'password') code = 'host-key-untrusted'; + return code ? [{ id: credential.id, code }] : []; }) }; }), }; diff --git a/docker/setup/credentials/SKILL.md b/docker/setup/credentials/SKILL.md new file mode 100644 index 0000000..db70598 --- /dev/null +++ b/docker/setup/credentials/SKILL.md @@ -0,0 +1,45 @@ +--- +name: t3-vault +description: Saved passwords, tokens, API keys, SSH keys and other credentials on this server. Use this before asking the user for any secret, and whenever a task needs to sign in to a server, service, database, package registry or cloud account. +--- + +# Credentials on this server + +Run `t3-vault list` before asking the user for a secret. Whatever is saved works for every agent, in every thread. + +## Use what's saved + +```sh +t3-vault list # what's saved, and the command to use each one +t3-vault run NAME -- COMMAND [ARG...] # the command gets the secrets as environment variables +ssh NAME COMMAND # SSH servers saved with a key also work with scp and git +t3-vault ssh NAME -- COMMAND # any saved SSH server, including ones with a password +t3-vault http NAME GET PATH # for credentials saved with an API address +``` + +`t3-vault run` hides saved values in the command's output. That catches accidents, not intent, so never print a secret on purpose, and never read the files in `~/.t3/server-credentials`. + +## When something is missing + +1. Call `request_secret` with `delivery: "file"`. In `reason`, say what it's for, where to get it and the name you'll save it under. For example: "To deploy with wrangler. Create one at dash.cloudflare.com/profile/api-tokens with Workers edit access. I'll save it in your Vault as cloudflare-token, so no agent has to ask again." +2. Save it with `t3-vault add NAME --secret-file `. This deletes T3's copy of the file. A recognized secret picks its own type, and `t3-vault types` lists the options. Add `--env VAR` for the variable name the tool expects, such as `CLOUDFLARE_API_TOKEN`. +3. Use it with the command that `add` prints. + +If the user only wants it used once, use the file directly and skip saving it. + +The card has a single-line box. Tokens, passwords, SSH keys and JSON key files fit. Kubeconfigs and `.env` files don't: run `t3-vault add NAME --type kubernetes` (or `--type file`), give the user the link it prints, then check with `t3-vault list`. + +## When a saved secret is rejected + +Ask with `request_secret` again, run `t3-vault replace NAME --secret-file `, then retry. + +## Adding an SSH server + +Prefer keys, in this order: + +1. **New key:** `t3-vault add NAME --host HOST --user USER --generate-key`. Show the user the line it prints to run on the server, then check with `t3-vault test NAME`. +2. **The user only has a password:** ask for it with `request_secret`, then run `t3-vault add NAME --host HOST --user USER --install-with-password-file `. It prints the server's fingerprint. Show that to the user, and once they confirm it, run the same command with `--trust FINGERPRINT`. This installs a new key and doesn't keep the password. +3. **The user has a key:** `t3-vault add NAME --host HOST --user USER --key-file `. +4. **The server doesn't allow keys:** `t3-vault add NAME --host HOST --user USER --password-file `, then `t3-vault trust NAME` and follow what it prints. + +If a server's key has changed, stop and show the user what `t3-vault trust NAME` prints. Never get past it with `StrictHostKeyChecking=no` or by editing a known_hosts file. diff --git a/docker/setup/credentials/cli.mjs b/docker/setup/credentials/cli.mjs index 0f57e4c..2c0c99d 100644 --- a/docker/setup/credentials/cli.mjs +++ b/docker/setup/credentials/cli.mjs @@ -1,47 +1,472 @@ +// t3-vault: how agents and people use saved credentials from a shell. Its output +// is read by agents, so every failure says what to do next. No command prints a +// secret: whatever an agent prints ends up in its transcript. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; 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 +import { apiRequest, canTest, runWith, sshRequest } from './run.mjs'; +import { callerThread } from './state.mjs'; +import { detect } from './detect.mjs'; +import { TYPES, publicTypes, primarySecret, isType, withDefaults } from './types.mjs'; +import { installCommand } from './keys.mjs'; +import { deliveredNames } from './deliver.mjs'; +import { validId } from './errors.mjs'; +import { validEnvName } from './model.mjs'; +import { readSaved, resolvePublicUrl } from '../public-url.mjs'; + +const USAGE = `t3-vault uses saved credentials without putting their values in chats or commands. + + t3-vault list What's saved, and how to use each one + t3-vault show NAME Details of one credential + t3-vault run NAME... -- COMMAND [ARG...] Run a command with those secrets in its environment + NAME=VAR gives the main secret another variable name + t3-vault ssh NAME [-- COMMAND [ARG...]] Run a command on a saved SSH server + t3-vault http NAME METHOD PATH [--stdin] Call a saved API. --stdin sends a JSON body + t3-vault add NAME [--type TYPE] [OPTIONS] Save a credential. See t3-vault types + t3-vault replace NAME --secret-file FILE Replace its secret. --stdin also works + t3-vault test NAME Check that it still works + t3-vault trust NAME [FINGERPRINT] Review or confirm an SSH server's identity + t3-vault use-key NAME Move a password sign-in to a new key + t3-vault rm NAME --yes Delete it + t3-vault types Every type and its options + +Saved SSH servers also work with plain ssh, scp and git, such as: ssh NAME uptime +Secrets are hidden in the output of run, ssh and http. Manage everything in Setup → Vault. `; -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.'); } +const ASK = `Ask the user with request_secret (delivery: "file"), and say in its reason that you'll save it.`; +const out = text => process.stdout.write(text + '\n'); +const fail = (message, status = 400, code = 'invalid') => { throw new CredentialError(message, status, code); }; +const kebab = key => key.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase()); + +const readOnlyStore = () => createCredentialStore({ readOnly: true }); +const usageFor = () => createCredentialStore({ readOnly: true }).usage; +async function writeService() { + const { createCredentialsService } = await import('./service.mjs'); + const store = createCredentialStore(); + return { store, service: createCredentialsService({ store, usage: { annotate: async credentials => ({ credentials }), invalidate() {} } }) }; +} +const origin = () => { + const threadId = callerThread(); + return threadId ? { by: 'agent', threadId } : { by: 'terminal' }; +}; + +// ---- reading secrets ---------------------------------------------------------- +// T3 Code's request_secret writes here. These are one-use, so they go once saved. +const isCardFile = file => /[\\/]secrets[\\/]files[\\/][0-9a-f]{32}$/.test(path.resolve(file)); + +async function readSecretFile(file) { + let handle; + try { + handle = await fs.open(file, 'r'); + const stat = await handle.stat(); + if (!stat.isFile()) fail(`${file} isn't a regular file.`); + if (stat.size > 48 * 1024) fail(`${file} is larger than the 48 KiB limit.`); + return await handle.readFile('utf8'); + } catch (error) { + if (error instanceof CredentialError) throw error; + fail(`Couldn't read ${file}. ${error.code === 'ENOENT' ? `It may have expired. ${ASK}` : ''}`.trim()); + } finally { await handle?.close(); } +} +async function readStdin() { + const chunks = []; let length = 0; + for await (const chunk of process.stdin) { + length += chunk.length; + if (length > 48 * 1024) fail('The secret on stdin is larger than the 48 KiB limit.'); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} +// For people at a terminal. The value is never echoed. +async function promptHidden(label) { + const readline = await import('node:readline'); + process.stderr.write(label + ': '); + const rl = readline.createInterface({ input: process.stdin, output: null, terminal: true }); + const answer = await new Promise(resolve => rl.question('', resolve)); + rl.close(); process.stderr.write('\n'); + return answer; +} + +// ---- argument parsing ----------------------------------------------------------- +function parseFlags(args) { + const flags = new Map(), rest = [], vars = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (!arg.startsWith('--')) { rest.push(arg); continue; } + // --var values hold their own = sign and may repeat. + if (arg === '--var') { const value = args[++index]; if (value === undefined) fail('Use --var NAME=VALUE.'); vars.push(value); continue; } + const equal = arg.indexOf('='); + if (equal > 0) { flags.set(arg.slice(2, equal), arg.slice(equal + 1)); continue; } + const next = args[index + 1]; + if (next === undefined || next.startsWith('--')) flags.set(arg.slice(2), true); + else { flags.set(arg.slice(2), next); index++; } + } + return { flags, rest, vars }; +} +const stringFlag = (flags, name) => { const value = flags.get(name); if (value === true) fail(`--${name} needs a value.`); return value; }; + +// ---- what agents read ------------------------------------------------------------- +// Plain ssh can't answer a password prompt, so only key sign-ins get the short form. +function howToUse(credential, sshAliases) { + if (credential.type === 'ssh') return (sshAliases && credential.fields?.auth === 'key' ? [`ssh ${credential.id} `] : []).concat(`t3-vault ssh ${credential.id} -- `); + const lines = [`t3-vault run ${credential.id} -- `]; + if (credential.fields?.baseUrl) lines.push(`t3-vault http ${credential.id} GET ${credential.fields.testPath || ''}`); + return lines; +} +const describeTarget = credential => { + const { fields = {} } = credential; + if (credential.type === 'ssh') return `${fields.username}@${fields.host}${fields.port !== 22 ? ':' + fields.port : ''}, ${fields.auth === 'key' ? 'key' : 'password'}`; + return credential.delivers?.join(', ') || TYPES[credential.type]?.label || ''; +}; +const sshState = credential => { + if (credential.type !== 'ssh' || credential.hostKey) return ''; + return credential.fields.auth === 'password' ? ` (blocked: run t3-vault trust ${credential.id})` : ' (not connected yet: run t3-vault test ' + credential.id + ')'; +}; + +async function list(args) { + const { flags } = parseFlags(args); + const { credentials, sshAliases } = await readOnlyStore().list(); + if (flags.has('json')) { out(JSON.stringify({ credentials: credentials.map(row => ({ name: row.id, type: row.type, description: row.description || undefined, gives: row.delivers, use: howToUse(row, sshAliases) })) }, null, 2)); return; } + if (!credentials.length) { + out(`The vault is empty.\n${ASK}\nThen save it: t3-vault add NAME --secret-file `); + return; + } + const rows = [...credentials].sort((a, b) => a.id.localeCompare(b.id, 'en')); + const width = Math.max(...rows.map(row => row.id.length)); + for (const row of rows) { + out(`${row.id.padEnd(width)} ${TYPES[row.type].badge.padEnd(10)} ${describeTarget(row)}${sshState(row)}${row.description ? ' - ' + row.description : ''}`); + out(`${' '.repeat(width)} use: ${howToUse(row, sshAliases)[0]}`); + } +} + +async function show([name]) { + if (!name) fail('Use t3-vault show NAME.'); + const store = readOnlyStore(), { credentials, sshAliases } = await store.list(); + const row = credentials.find(entry => entry.id === name); + if (!row) throw notFound(name); + const lines = [`${row.id} (${TYPES[row.type].label})`]; + if (row.description) lines.push(row.description); + for (const [key, value] of Object.entries(row.fields || {})) if (value !== '' && key !== 'publicKey') lines.push(` ${key}: ${value}`); + if (row.delivers?.length) lines.push(` gives commands: ${row.delivers.join(', ')}`); + if (row.hostKey) lines.push(` server's key: ${row.hostKey.type} ${row.hostKey.fingerprint}`); + if (row.fields?.publicKey) lines.push(` public key: ${row.fields.publicKey}`); + if (row.test) lines.push(` last test: ${row.test.ok ? 'passed' : row.test.code}`); + lines.push('Use it with:', ...howToUse(row, sshAliases).map(line => ' ' + line)); + out(lines.join('\n')); +} + +function notFound(name) { + const stem = validId(name) ? name : 'NAME'; + return new CredentialError(`No saved credential named ${name}. Run t3-vault list to see what's saved.\n${ASK}\nThen save it: t3-vault add ${stem} --secret-file `, 404, 'not-found'); +} +const forUse = async (store, name) => { + try { return await store.forUse(name); } + catch (error) { if (error.code === 'not-found') throw notFound(name); throw error; } +}; + +async function run(args) { + const split = args.indexOf('--'); + if (split < 1 || split === args.length - 1) fail('Use t3-vault run NAME... -- COMMAND [ARGUMENT...].'); + const store = readOnlyStore(), credentials = []; + for (const token of args.slice(0, split)) { + const [name, rename] = token.split('='); + if (rename !== undefined && !validEnvName(rename)) fail(`${rename} can't be used as a variable name.`); + credentials.push({ credential: await forUse(store, name), rename }); + } + const command = args.slice(split + 1); + const code = await runWith(credentials, command); + await Promise.all(credentials.map(({ credential }) => store.usage.record(credential.id, { command: command[0] }))); + process.exitCode = code; +} + +async function pinIfNew(name, credential) { + if (credential.knownHostsFile || credential.fields.auth !== 'key') return credential; + const { store, service } = await writeService(); + const pinned = await service.pinFirstUse(name); + if (!pinned.ok) return credential; + process.stderr.write(`First connection to ${name}. Pinned its key: ${pinned.hostKey.type} ${pinned.hostKey.fingerprint}\n`); + return store.forUse(name); +} + +async function ssh(args) { + const [name, ...rest] = args; + if (!name) fail('Use t3-vault ssh NAME -- COMMAND.'); + const command = rest[0] === '--' ? rest.slice(1) : rest; + const store = readOnlyStore(), interactive = !command.length && process.stdin.isTTY && process.stdout.isTTY; + if (!command.length && !interactive) fail(`Supply a command, such as t3-vault ssh ${name} -- uptime.`); + const credential = await pinIfNew(name, await forUse(store, name)); + process.exitCode = await sshRequest(credential, command, { interactive }); + await store.usage.record(name, { command: 'ssh' }); +} + +async function http(args) { + const { flags, rest } = parseFlags(args), [name, method, relative = ''] = rest; + if (!name || !method || rest.length > 3 || [...flags.keys()].some(key => key !== 'stdin')) fail('Use t3-vault http NAME METHOD RELATIVE_PATH [--stdin].'); + let body; + if (flags.has('stdin')) { + if (['GET', 'HEAD'].includes(method)) fail('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) fail('The JSON request body exceeds 1 MiB.'); + chunks.push(chunk); } - process.exitCode = await apiRequest(await store.forUse(id), method, relative, { body }); + body = Buffer.concat(chunks).toString('utf8'); + try { JSON.parse(body); } catch { fail('Supply a valid JSON request body on stdin.'); } } + const store = readOnlyStore(), credential = await forUse(store, name); + process.exitCode = await apiRequest(credential, method, relative, { body }); + await store.usage.record(name, { command: 'http' }); } -main().catch(error => { - process.stderr.write(credentialFailure(error).body.error + '\n'); +// ---- saving ------------------------------------------------------------------------- +const secretFlag = entry => entry.flag || kebab(entry.key) + '-file'; + +function typeOptions(id) { + const spec = TYPES[id]; + if (spec.dynamic) return ['--var NAME=VALUE (not secret, repeatable)', '--secret-file FILE (NAME=value lines, all secret)']; + const fields = spec.fields.filter(entry => entry.kind !== 'derived').map(entry => `--${kebab(entry.key)} ${entry.kind === 'select' ? entry.options.map(([value]) => value).join('|') : 'VALUE'}${entry.optional || entry.default !== undefined ? ' (optional)' : ''}`); + const secrets = spec.secrets.map(entry => `--${secretFlag(entry)} FILE${entry.optional ? ' (optional)' : ''}`); + return [...fields, ...secrets, ...(spec.secrets.some(entry => entry.editableEnv) ? ['--env VAR'] : [])]; +} +function types() { + for (const type of publicTypes()) { + out(`${type.id}: ${type.label}`); + for (const line of typeOptions(type.id)) out(' ' + line); + } + out(`\nFor any type, --secret-file FILE reads the main secret. Without --type, a recognized secret picks its own type.\nSSH also takes --generate-key, --passphrase-file FILE, --install-with-password-file FILE and --trust FINGERPRINT.`); +} + +async function gatherSecret(flags, label) { + const file = stringFlag(flags, 'secret-file'); + if (file !== undefined) return { text: await readSecretFile(file), file }; + if (flags.has('stdin')) return { text: await readStdin(), file: null }; + if (process.stdin.isTTY && label) return { text: await promptHidden(label), file: null }; + return { text: null, file: null }; +} + +async function add(args) { + const { flags, rest, vars } = parseFlags(args), [name, extra] = rest; + if (!name || extra !== undefined) fail('Use t3-vault add NAME [--type TYPE] [OPTIONS]. See t3-vault types.'); + if (!validId(name)) fail('Use up to 64 lowercase letters, numbers and single hyphens for the name, such as cloudflare-token.'); + const requested = stringFlag(flags, 'type'); + if (requested !== undefined && !isType(requested)) fail(`There's no type called ${requested}. See t3-vault types.`); + const fromFiles = [], generating = flags.has('generate-key'); + const installFile = stringFlag(flags, 'install-with-password-file'); + const sshHinted = ['host', 'key-file', 'password-file'].some(flag => flags.has(flag)) || generating || installFile !== undefined; + const needsPrompt = !generating && installFile === undefined && !flags.has('key-file') && !flags.has('password-file'); + const { text, file } = await gatherSecret(flags, needsPrompt && (requested || !sshHinted) ? `Secret for ${name}` : null); + if (file) fromFiles.push(file); + const detected = text ? detect(text) : null; + const type = requested ?? (sshHinted ? 'ssh' : detected?.type ?? 'token'); + const spec = TYPES[type], usable = detected?.type === type ? detected : null; + const fields = { ...usable?.fields }, secrets = { ...usable?.secrets }, env = { ...usable?.env }; + const consumed = new Set(['type', 'secret-file', 'stdin', 'description', 'env', 'generate-key', 'install-with-password-file', 'trust', 'passphrase-file']); + + for (const entry of spec.fields) { + // --user is what anyone used to ssh will type. + for (const flag of entry.key === 'username' ? ['username', 'user'] : [kebab(entry.key)]) { + const value = stringFlag(flags, flag); + if (value !== undefined) { fields[entry.key] = value; consumed.add(flag); } + } + } + // A secret on the command line would land in the transcript and in `ps`. + for (const entry of spec.secrets) { + const flag = secretFlag(entry); + if (flags.has(kebab(entry.key))) fail(`Secrets can't go on the command line, where the transcript keeps them. Use --${flag} FILE.`); + const from = stringFlag(flags, flag); + if (from !== undefined) { secrets[entry.key] = await readSecretFile(from); fromFiles.push(from); consumed.add(flag); } + } + if (type === 'ssh') { + if (flags.has('key-file') || generating || installFile !== undefined) fields.auth = 'key'; + if (flags.has('password-file')) fields.auth = 'password'; + } + const withDefault = withDefaults(type, fields), primary = primarySecret(type, withDefault); + if (text && !usable) { + if (!primary) fail(`${spec.label} takes NAME=value lines in its secret file.`); + secrets[primary.key] = text; + } + if (flags.has('env')) { + if (!primary?.editableEnv) fail(`${spec.label} uses fixed variable names, so --env doesn't apply.`); + env[primary.key] = stringFlag(flags, 'env'); + } + const unknown = [...flags.keys()].filter(flag => !consumed.has(flag)); + if (unknown.length) fail(`Unknown option --${unknown[0]} for ${spec.label}. See t3-vault types.`); + + if (spec.dynamic) { + for (const pair of vars) { const at = pair.indexOf('='); if (at < 1) fail('Use --var NAME=VALUE.'); withDefault[pair.slice(0, at)] = pair.slice(at + 1); } + } else if (vars.length) fail(`--var only applies to the custom type.`); + const missingSecret = primary && !primary.optional && secrets[primary.key] === undefined && !generating && installFile === undefined; + if (missingSecret) { + // T3's card has a single-line box, which can't hold a multi-line file. + if (primary.multiline && type !== 'ssh') fail(`This doesn't fit T3's single-line secret card. Ask the user to paste it here: ${setupForm(type, name)}\nThen check with t3-vault list.`); + fail(`Nothing to save yet. ${ASK}\nThen run: t3-vault add ${name} --secret-file ${requested ? ' --type ' + requested : ''}`); + } + + const passphraseFile = stringFlag(flags, 'passphrase-file'); + const passphrase = passphraseFile === undefined ? undefined : (await readSecretFile(passphraseFile)).replace(/\r?\n$/, ''); + if (passphraseFile) fromFiles.push(passphraseFile); + const trust = stringFlag(flags, 'trust'); + const credential = { id: name, type, description: stringFlag(flags, 'description') ?? '', fields: withDefault, secrets, env }; + const { service, store } = await writeService(); + const input = { credential, generate: generating || installFile !== undefined || undefined, passphrase, ...(trust ? { trust: { fingerprint: trust } } : {}) }; + + if (installFile !== undefined) { + if (!trust) { + const scanned = await scanTarget(service, withDefault); + fail(`Before your password goes to ${withDefault.host}, confirm that server's identity.\n${scanned}\nShow the user this fingerprint. Once they confirm it, run this again with --trust FINGERPRINT.`, 409, 'host-key-untrusted'); + } + const password = (await readSecretFile(installFile)).replace(/\r?\n$/, ''); + input.install = { password }; + fromFiles.push(installFile); + } + const saved = await service.save(input, { create: true, origin: origin() }); + await Promise.all(fromFiles.filter(isCardFile).map(item => fs.rm(item, { force: true }).catch(() => {}))); + // Whether the key already works decides what the next step is. + if (saved.credential.type === 'ssh' && saved.credential.hostKey) { + saved.signIn = await service.testInput({ id: name }).then(answer => answer.result, () => null); + saved.credential = { ...saved.credential, hostKey: saved.credential.hostKey }; + } + saved.sshAliases = (await store.list()).sshAliases; + report(saved, fromFiles.some(isCardFile)); +} + +// A link when this server knows its own address, directions when it doesn't. +function setupForm(type, name) { + const state = process.env.T3CODE_HOME || path.join(process.env.HOME || '/home/t3', '.t3'); + const { url } = resolvePublicUrl({ env: process.env, saved: readSaved(state) }); + return url ? `${url.replace(/\/$/, '')}/__setup/#vault/new/${type}/${name}` : `Setup → Vault → Add credential → ${TYPES[type].label}, with the name ${name}`; +} + +async function scanTarget(service, fields) { + const answer = await service.handle('/credentials/scan', { host: fields.host, port: Number(fields.port ?? 22) }); + if (answer.hostKey) return `Server's key: ${answer.hostKey.presented.type} ${answer.hostKey.presented.fingerprint}`; + fail(`Couldn't reach ${fields.host}: ${answer.result?.code || 'no answer'}.`, 502, 'unreachable'); +} + +function report({ credential, trust, signIn, sshAliases = true }, deleted) { + const { id, fields = {} } = credential, lines = [`Saved ${id}${deleted ? ' and deleted the file' : ''}.`]; + if (credential.type === 'ssh') { + if (credential.hostKey) lines.push(`Server's key: ${credential.hostKey.type} ${credential.hostKey.fingerprint}${credential.hostKey.firstUse ? ' (pinned on first use)' : ''}`); + else if (trust?.code && fields.auth === 'key') lines.push(`Couldn't reach ${fields.host} yet (${trust.code}). Its key gets pinned on the first connection.`); + if (signIn?.ok) lines.push(`Signed in as ${fields.username}.`); + else if (fields.auth === 'key' && fields.publicKey) lines.push('The server doesn\'t accept this key yet. Ask the user to run this on it, then run t3-vault test ' + id + ':', ' ' + installCommand(fields.publicKey)); + if (fields.auth === 'password' && !credential.hostKey) lines.push(`It can't be used until the server's identity is confirmed. Run t3-vault trust ${id}.`); + lines.push(`Use it with: ${howToUse(credential, sshAliases)[0]}`); + } else { + if (credential.delivers?.length) lines.push(`Commands get: ${credential.delivers.join(', ')}`); + lines.push(`Use it with: t3-vault run ${id} -- `); + if (fields.baseUrl) lines.push(`Or: t3-vault http ${id} GET ${fields.testPath || ''}`); + } + out(lines.join('\n')); +} + +async function replace(args) { + const { flags, rest } = parseFlags(args), [name] = rest; + if (!name) fail('Use t3-vault replace NAME --secret-file FILE.'); + const { store, service } = await writeService(); + let current; + try { current = await store.public(name); } catch (error) { if (error.code === 'not-found') throw notFound(name); throw error; } + const spec = TYPES[current.type], primary = primarySecret(current.type, current.fields); + if (!primary) fail(`${spec.label} has more than one secret. Change it in Setup → Vault.`); + if (flags.has('passphrase')) fail(`Secrets can't go on the command line, where the transcript keeps them. Use --passphrase-file FILE.`); + const passphraseFile = stringFlag(flags, 'passphrase-file'); + const passphrase = passphraseFile === undefined ? undefined : (await readSecretFile(passphraseFile)).replace(/\r?\n$/, ''); + const { text, file } = await gatherSecret(flags, `New ${primary.label.toLowerCase()} for ${name}`); + if (text === null) fail(`Nothing to replace it with. ${ASK}\nThen run: t3-vault replace ${name} --secret-file `); + const usable = detect(text); + const secret = usable?.type === current.type && usable.secrets[primary.key] !== undefined ? usable.secrets[primary.key] : text; + const saved = await service.save({ credential: { id: name, type: current.type, fields: current.fields, secrets: { [primary.key]: secret }, env: {} }, revision: current.revision, passphrase }, { create: false, origin: origin() }); + const deleted = file && isCardFile(file); + if (deleted) await fs.rm(file, { force: true }).catch(() => {}); + if (passphraseFile && isCardFile(passphraseFile)) await fs.rm(passphraseFile, { force: true }).catch(() => {}); + report(saved, deleted); +} + +const FAILURES = { + 'auth-rejected': 'The server rejected the sign-in. If the key isn\'t on the server yet, run t3-vault show NAME and add it. Otherwise replace the secret.', + rejected: 'The service rejected the secret. Ask the user for a new one and run t3-vault replace NAME.', + 'host-key-changed': 'The server presented a different key. Don\'t connect until the user has checked it: run t3-vault trust NAME.', + 'host-key-untrusted': 'The server\'s identity isn\'t confirmed yet. Run t3-vault trust NAME.', + unreachable: 'The server couldn\'t be reached.', + timeout: 'The server took too long to respond.', + certificate: 'The server\'s certificate couldn\'t be verified.', + redirect: 'The service redirected the request. Save its final address instead.', + 'not-found': 'The test path wasn\'t found. Check the API address.', + 'http-error': 'The service returned an error.', + 'scan-failed': 'The server\'s key couldn\'t be read.', +}; + +async function testCommand([name]) { + if (!name) fail('Use t3-vault test NAME.'); + const { store, service } = await writeService(); + let credential; + try { credential = await store.public(name); } catch (error) { if (error.code === 'not-found') throw notFound(name); throw error; } + if (!canTest(credential)) { out(`${name} has no test set up. To check it, run a real command with t3-vault run ${name} -- .`); return; } + const { result } = await service.testInput({ id: name }); + if (result.ok) { out(`${name} works.${result.hostKey?.presented ? ` Pinned the server's key: ${result.hostKey.presented.type} ${result.hostKey.presented.fingerprint}` : ''}`); return; } process.exitCode = 1; + out(`${name} failed: ${(FAILURES[result.code] || result.detail?.line || 'The test failed.').replaceAll('NAME', name)}`); + if (result.detail?.status) out(`HTTP ${result.detail.status}`); + if (result.detail?.presented) out(`Server's key now: ${result.detail.presented.type} ${result.detail.presented.fingerprint}`); +} + +const keyCheck = type => `ssh-keygen -lf /etc/ssh/ssh_host_${type === 'ssh-rsa' ? 'rsa' : type?.startsWith('ecdsa') ? 'ecdsa' : 'ed25519'}_key.pub`; + +async function trust([name, fingerprint]) { + if (!name) fail('Use t3-vault trust NAME [FINGERPRINT].'); + const { store, service } = await writeService(); + let credential; + try { credential = await store.public(name); } catch (error) { if (error.code === 'not-found') throw notFound(name); throw error; } + if (credential.type !== 'ssh') fail(`${name} isn't an SSH credential.`); + if (!fingerprint) { + const scanned = await service.scan(name); + if (!scanned.ok) fail(`Couldn't reach ${credential.fields.host}: ${scanned.code}.`, 502, 'unreachable'); + if (scanned.hostKey.state === 'trusted') { out(`${name} is already trusted: ${scanned.hostKey.presented.type} ${scanned.hostKey.presented.fingerprint}`); return; } + out(`${scanned.hostKey.state === 'changed' ? `Warning: this is different from the key saved for ${name}. Only accept it if the server was reinstalled.\nSaved key: ${scanned.hostKey.trusted.type} ${scanned.hostKey.trusted.fingerprint}\n` : ''}Server's key: ${scanned.hostKey.presented.type} ${scanned.hostKey.presented.fingerprint}\nThe user can check it on the server with: ${keyCheck(scanned.hostKey.presented.type)}\nShow them this fingerprint. Once they confirm it, run: t3-vault trust ${name} ${scanned.hostKey.presented.fingerprint}`); + return; + } + const answer = await service.trust(name, fingerprint, credential.revision); + if (!answer.ok) fail(`Couldn't reach ${credential.fields.host}: ${answer.code}.`, 502, 'unreachable'); + out(`Trusted ${name}: ${answer.hostKey.type} ${answer.hostKey.fingerprint}`); +} + +async function useKey([name]) { + if (!name) fail('Use t3-vault use-key NAME.'); + const { service } = await writeService(); + try { + await service.installKey({ id: name }, { origin: origin() }); + const { sshAliases } = await readOnlyStore().list(); + out(`${name} now signs in with a new key, and its password has been deleted.\nUse it with: ${sshAliases ? `ssh ${name} ` : `t3-vault ssh ${name} -- `}`); + } catch (error) { if (error.code === 'not-found') throw notFound(name); throw error; } +} + +async function remove(args) { + const { flags, rest } = parseFlags(args), [name] = rest; + if (!name) fail('Use t3-vault rm NAME --yes.'); + if (!flags.has('yes')) fail(`This erases ${name} from the vault and can't be undone. Only do this if the user asked. Add --yes to confirm.`); + const { store } = await writeService(); + let current; + try { current = await store.public(name); } catch (error) { if (error.code === 'not-found') throw notFound(name); throw error; } + await store.remove({ id: name, revision: current.revision }); + out(`Deleted ${name}. This doesn't revoke it at the service.`); +} + +const COMMANDS = { list, ls: list, show, run, ssh, http, add, replace, test: testCommand, trust, 'use-key': useKey, rm: remove, types }; + +async function main() { + const [command, ...args] = process.argv.slice(2); + if (!command || ['--help', '-h', 'help'].includes(command)) { process.stdout.write(USAGE); return; } + const action = COMMANDS[command]; + if (!action) fail(`There's no t3-vault command called ${command}. Run t3-vault --help.`); + await action(args); +} + +main().catch(error => { + const failure = credentialFailure(error); + process.stderr.write(failure.body.error + '\n'); + process.exitCode = failure.status === 127 ? 127 : failure.status === 126 ? 126 : 1; +}).finally(() => { + // A pending key scan or stdin shouldn't hold the process open once it's done. + if (process.exitCode === undefined) process.exitCode = 0; }); diff --git a/docker/setup/credentials/deliver.mjs b/docker/setup/credentials/deliver.mjs new file mode 100644 index 0000000..8fd7bf6 --- /dev/null +++ b/docker/setup/credentials/deliver.mjs @@ -0,0 +1,61 @@ +// How a saved credential reaches a command: environment variables and temporary +// files. Nothing here starts a process. +import { TYPES, applies, envNames, envStem } from './types.mjs'; + +const DEFAULT_PORTS = { postgres: 5432, mysql: 3306, mongodb: 27017, redis: 6379 }; +const databaseUrl = ({ fields, secrets }) => { + const encode = encodeURIComponent, user = fields.username ? encode(fields.username) : '', password = secrets.password ? ':' + encode(secrets.password) : ''; + const auth = user || password ? user + password + '@' : '', host = fields.host.includes(':') ? `[${fields.host}]` : fields.host; + return `${fields.scheme || fields.engine}://${auth}${host}${fields.port ? ':' + fields.port : ''}/${fields.database ? encode(fields.database) : ''}${fields.options ? '?' + fields.options : ''}`; +}; +const databaseEnv = credential => { + const { fields, secrets } = credential, env = { DATABASE_URL: databaseUrl(credential) }; + if (fields.engine === 'postgres') Object.assign(env, { PGHOST: fields.host, PGPORT: String(fields.port ?? DEFAULT_PORTS.postgres), ...(fields.username ? { PGUSER: fields.username } : {}), ...(secrets.password ? { PGPASSWORD: secrets.password } : {}), ...(fields.database ? { PGDATABASE: fields.database } : {}) }); + if (fields.engine === 'mysql') Object.assign(env, { MYSQL_HOST: fields.host, MYSQL_TCP_PORT: String(fields.port ?? DEFAULT_PORTS.mysql), ...(secrets.password ? { MYSQL_PWD: secrets.password } : {}) }); + if (fields.engine === 'redis' && secrets.password) env.REDISCLI_AUTH = secrets.password; + return env; +}; + +// Git reads this from the environment, so `git push` works without touching +// anyone's git config. The helper reads the token from a variable at run time, +// which keeps it out of the command line that `ps` shows. +const gitHelper = stem => `!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$T3_VAULT_GIT_USERNAME_${stem}" "$T3_VAULT_GIT_TOKEN_${stem}"; }; f`; +const gitEnv = ({ id, fields, secrets }, base) => { + const start = Number.parseInt(base.GIT_CONFIG_COUNT, 10) || 0, section = `credential.https://${fields.host}`, stem = envStem(id); + const pairs = [[section + '.helper', ''], [section + '.helper', gitHelper(stem)]]; + const env = { [`T3_VAULT_GIT_USERNAME_${stem}`]: fields.username || 'x-access-token', [`T3_VAULT_GIT_TOKEN_${stem}`]: secrets.token, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: String(start + pairs.length) }; + pairs.forEach(([key, value], index) => { env[`GIT_CONFIG_KEY_${start + index}`] = key; env[`GIT_CONFIG_VALUE_${start + index}`] = value; }); + return env; +}; + +/** Every secret value a credential holds, for masking its command's output. */ +export const secretValues = credential => Object.values(credential.secrets ?? {}).filter(value => typeof value === 'string' && value); + +export function delivery(credential, { rename, base = process.env, askpass } = {}) { + const spec = TYPES[credential.type], env = {}, files = []; + if (spec.dynamic) return { env: { ...credential.fields, ...credential.secrets }, files, masks: secretValues(credential) }; + const names = envNames(credential); + for (const entry of spec.fields) { + const value = credential.fields[entry.key]; + if (entry.env && value !== undefined && value !== '' && applies(entry, credential.fields)) env[names[entry.key]] = String(value); + } + for (const entry of spec.secrets) { + const value = credential.secrets[entry.key]; + if (value === undefined || !entry.env) continue; + const target = entry.primary && rename ? rename : names[entry.key]; + if (entry.file) files.push({ env: target, name: entry.file === true ? credential.fields.filename || 'credential' : entry.file, content: value }); + else env[target] = value; + } + if (credential.type === 'aws' && credential.fields.region) env.AWS_DEFAULT_REGION = credential.fields.region; + if (credential.type === 'database') Object.assign(env, databaseEnv(credential)); + if (credential.type === 'git') Object.assign(env, gitEnv(credential, base)); + if (credential.type === 'ssh' && credential.fields.auth === 'password') Object.assign(env, { SSH_ASKPASS: askpass, SSH_ASKPASS_REQUIRE: 'force', DISPLAY: base.DISPLAY || 't3-vault', T3_CREDENTIAL_PASSWORD: credential.secrets.password }); + return { env, files, masks: secretValues(credential) }; +} + +/** Variable names a command would get, for showing to people and agents. */ +export function deliveredNames(credential) { + const { env, files } = delivery({ ...credential, secrets: Object.fromEntries(Object.keys(credential.secrets ?? {}).map(key => [key, 'x'])) }, { base: {}, askpass: '' }); + const names = [...Object.keys(env), ...files.map(file => file.env)]; + return names.filter(name => !/^(?:GIT_CONFIG_|T3_VAULT_|T3_CREDENTIAL_|SSH_ASKPASS|DISPLAY|GIT_TERMINAL_PROMPT)/.test(name)); +} diff --git a/docker/setup/credentials/detect.mjs b/docker/setup/credentials/detect.mjs new file mode 100644 index 0000000..19d6f92 --- /dev/null +++ b/docker/setup/credentials/detect.mjs @@ -0,0 +1,90 @@ +// Works out what a pasted value is. Used by Setup's paste box and by +// `t3-vault add` without --type. It only reads the text it is given, never the +// network, and returns the secrets it found so the caller can place them. +import { rebuildPrivateKey } from './keys.mjs'; + +const API = (baseUrl, testPath, auth = {}) => ({ baseUrl, testPath, authType: 'bearer', ...auth }); +const TOKENS = [ + { pattern: /^(?:gh[pousr]_|github_pat_)[A-Za-z0-9_]{20,}$/, label: 'a GitHub token', type: 'git', id: 'github', fields: { host: 'github.com', username: 'x-access-token', ...API('https://api.github.com/', 'user') }, env: { token: 'GH_TOKEN' } }, + { pattern: /^glpat-[A-Za-z0-9_.-]{16,}$/, label: 'a GitLab token', type: 'git', id: 'gitlab', fields: { host: 'gitlab.com', username: 'oauth2', ...API('https://gitlab.com/api/v4/', 'user', { authType: 'header', authName: 'PRIVATE-TOKEN' }) }, env: { token: 'GITLAB_TOKEN' } }, + { pattern: /^sk-ant-[A-Za-z0-9_-]{16,}$/, label: 'an Anthropic API key', type: 'token', id: 'anthropic', fields: API('https://api.anthropic.com/v1/', 'models', { authType: 'header', authName: 'x-api-key' }), env: { token: 'ANTHROPIC_API_KEY' } }, + { pattern: /^sk-[A-Za-z0-9_-]{20,}$/, label: 'an OpenAI API key', type: 'token', id: 'openai', fields: API('https://api.openai.com/v1/', 'models'), env: { token: 'OPENAI_API_KEY' } }, + { pattern: /^(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}$/, label: 'a Stripe key', type: 'token', id: 'stripe', fields: API('https://api.stripe.com/v1/', 'balance'), env: { token: 'STRIPE_API_KEY' } }, + { pattern: /^xox[abeprs]-[A-Za-z0-9-]{16,}$/, label: 'a Slack token', type: 'token', id: 'slack', fields: API('https://slack.com/api/', 'auth.test'), env: { token: 'SLACK_TOKEN' } }, + { pattern: /^npm_[A-Za-z0-9]{20,}$/, label: 'an npm token', type: 'registry', id: 'npm', fields: API('https://registry.npmjs.org/', '-/whoami'), env: { token: 'NPM_TOKEN' } }, +]; + +const ENGINES = { postgres: 'postgres', postgresql: 'postgres', mysql: 'mysql', mariadb: 'mysql', mongodb: 'mongodb', 'mongodb+srv': 'mongodb', redis: 'redis', rediss: 'redis' }; +const safeDecode = value => { try { return decodeURIComponent(value); } catch { return value; } }; +const databaseUrl = text => { + const match = /^([a-z][a-z0-9+]*):\/\//i.exec(text); + if (!match || !ENGINES[match[1].toLowerCase()]) return null; + let url; + try { url = new URL(text); } catch { return null; } + if (!url.hostname) return null; + const scheme = match[1].toLowerCase(), engine = ENGINES[scheme], database = safeDecode(url.pathname.replace(/^\//, '')); + const fields = { engine, ...(scheme !== engine && !['postgresql', 'mariadb'].includes(scheme) ? { scheme } : {}), host: url.hostname, ...(url.port ? { port: Number(url.port) } : {}), ...(database ? { database } : {}), ...(url.username ? { username: safeDecode(url.username) } : {}), ...(url.search.length > 1 ? { options: url.search.slice(1) } : {}) }; + return { label: 'a ' + { postgres: 'PostgreSQL', mysql: 'MySQL', mongodb: 'MongoDB', redis: 'Redis' }[engine] + ' connection URL', type: 'database', id: database ? database.replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-|-$/g, '') + '-db' : engine, fields, secrets: url.password ? { password: safeDecode(url.password) } : {} }; +}; + +const serviceAccount = text => { + if (!text.startsWith('{')) return null; + try { + const data = JSON.parse(text); + if (data?.type !== 'service_account' || typeof data.private_key !== 'string') return null; + return { label: 'a Google Cloud service account key', type: 'gcp', id: String(data.project_id || 'google-cloud').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'google-cloud', fields: { ...(data.project_id ? { project: String(data.project_id) } : {}), ...(data.client_email ? { clientEmail: String(data.client_email) } : {}) }, secrets: { serviceAccount: text } }; + } catch { return null; } +}; + +const kubeconfig = text => /^\s*apiVersion:\s*v1\s*$/m.test(text) && /^\s*kind:\s*Config\s*$/m.test(text) && /^clusters:/m.test(text) + ? { label: 'a Kubernetes config', type: 'kubernetes', id: 'kubernetes', fields: {}, secrets: { kubeconfig: text } } + : null; + +// Both an ~/.aws/credentials block and a plain pair of variables. +const awsPair = text => { + const id = /(?:aws_access_key_id|AWS_ACCESS_KEY_ID)\s*[=:]\s*"?([A-Z0-9]{16,})"?/.exec(text)?.[1], key = /(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*"?([A-Za-z0-9/+=]{16,})"?/.exec(text)?.[1]; + if (!id || !key) return null; + const session = /(?:aws_session_token|AWS_SESSION_TOKEN)\s*[=:]\s*"?([A-Za-z0-9/+=]+)"?/.exec(text)?.[1], region = /(?:^|\s)(?:region|AWS_REGION|AWS_DEFAULT_REGION)\s*[=:]\s*"?([a-z0-9-]+)"?/.exec(text)?.[1]; + return { label: 'AWS credentials', type: 'aws', id: 'aws', fields: { accessKeyId: id, ...(region ? { region } : {}) }, secrets: { secretAccessKey: key, ...(session ? { sessionToken: session } : {}) } }; +}; + +// One that can't be rebuilt is still a key, so it gets a useful error at save. +const privateKey = text => { + const key = rebuildPrivateKey(text); + if (!key && !/^-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text)) return null; + return { label: 'an SSH private key', type: 'ssh', id: '', fields: { auth: 'key' }, secrets: { privateKey: key ?? text } }; +}; + +// Two or more NAME=value lines. Every value is treated as secret until the +// person says otherwise, because a miss here would show it on the page. +const dotenv = text => { + const rows = text.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#')); + if (rows.length < 2 && !/^export\s/.test(rows[0] || '')) return null; + const secrets = {}, fields = {}; + for (const row of rows) { + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(row); + if (!match) return null; + const value = match[2].replace(/^(['"])(.*)\1$/, '$2'); + // FOO= is common in these files, and an empty value has nothing to hide. + (value ? secrets : fields)[match[1]] = value; + } + return Object.keys(secrets).length + Object.keys(fields).length ? { label: 'an environment file', type: 'custom', id: '', fields, secrets } : null; +}; + +/** Returns { type, label, id, fields, secrets, env, confident } for pasted text. */ +export function detect(input) { + const text = String(input ?? '').trim(); + if (!text) return null; + const known = privateKey(text) || serviceAccount(text) || kubeconfig(text) || databaseUrl(text) || awsPair(text) || dotenv(text); + if (known) return { env: {}, ...known, confident: true }; + if (!/\s/.test(text)) { + if (/^(?:AKIA|ASIA)[A-Z0-9]{12,}$/.test(text)) return { type: 'aws', label: 'an AWS access key ID', id: 'aws', fields: { accessKeyId: text }, secrets: {}, env: {}, confident: true }; + for (const { pattern, label, type, id, fields, env } of TOKENS) { + if (pattern.test(text)) return { type, label, id, fields, env, secrets: { token: text }, confident: true }; + } + return { type: 'token', label: 'a token', id: '', fields: {}, env: {}, secrets: { token: text }, confident: false }; + } + return /\n/.test(text) + ? { type: 'file', label: 'a file', id: '', fields: {}, env: {}, secrets: { content: text + '\n' }, confident: false } + : { type: 'login', label: 'a password', id: '', fields: {}, env: {}, secrets: { password: text }, confident: false }; +} diff --git a/docker/setup/credentials/diagnostics.mjs b/docker/setup/credentials/diagnostics.mjs index 97b1400..3ef2fe1 100644 --- a/docker/setup/credentials/diagnostics.mjs +++ b/docker/setup/credentials/diagnostics.mjs @@ -7,8 +7,8 @@ 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-')); +export async function createSshDiagnostics(secrets, write = () => {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-ssh-diagnostics-')); const file = path.join(directory, 'diagnostics'); let handle, ended = false, text = ''; try { @@ -16,7 +16,7 @@ export async function createSshDiagnostics(secret, write = () => {}) { 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 filter = outputFilter(secrets, value => { text = (text + value).slice(-65536); write(value); }); const reading = (async () => { const buffer = Buffer.alloc(8192), decoder = new TextDecoder(); try { diff --git a/docker/setup/credentials/hostkeys.mjs b/docker/setup/credentials/hostkeys.mjs index 19f6bfc..656db9c 100644 --- a/docker/setup/credentials/hostkeys.mjs +++ b/docker/setup/credentials/hostkeys.mjs @@ -7,8 +7,8 @@ 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) => { +export const execute = (program, args, { input, signal, timeout = 5000, env } = {}) => new Promise((resolve, reject) => { + const child = execFile(program, args, { encoding: 'utf8', maxBuffer: 64 * 1024, timeout, signal, killSignal: 'SIGKILL', ...(env ? { env } : {}) }, (error, stdout, stderr) => { if (error?.code === 'ENOENT' || error?.code === 'ABORT_ERR' || error?.killed) return reject(error); resolve({ code: error?.code || 0, stdout, stderr }); }); @@ -87,6 +87,6 @@ export function createHostKeys({ io, run = execute, reach = reachHost, home = pr 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 }), + metadata: (key, { carried = false, firstUse = false } = {}) => ({ ...publicKey(key), trustedAt: clock(), carried, firstUse }), }; } diff --git a/docker/setup/credentials/io.mjs b/docker/setup/credentials/io.mjs index 231ad67..fba1537 100644 --- a/docker/setup/credentials/io.mjs +++ b/docker/setup/credentials/io.mjs @@ -11,6 +11,8 @@ const harness = existsSync(new URL('lock.mjs', localHarness)) ? localHarness : n 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'); +const FOLDERS = new Set(['known_hosts', 'keys', 'usage']); +const folderOf = name => { const at = name.indexOf('/'); return at > 0 ? name.slice(0, at) : ''; }; export function createCredentialIO({ directory, readOnly = false, effects = fs }) { const root = path.resolve(directory); @@ -20,7 +22,7 @@ export function createCredentialIO({ directory, readOnly = false, effects = fs } return target; }; const prepare = async (relative = '') => { - if (relative && relative !== 'known_hosts') throw unsafe(); + if (relative && !FOLDERS.has(relative)) 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; @@ -31,8 +33,8 @@ export function createCredentialIO({ directory, readOnly = false, effects = fs } } return true; }; - const read = async (name, { maxBytes = 512 * 1024, missing = null } = {}) => { - if (!await prepare(name.startsWith('known_hosts/') ? 'known_hosts' : '')) return missing; + const read = async (name, { maxBytes = 4 * 1024 * 1024, missing = null } = {}) => { + if (!await prepare(folderOf(name))) return missing; let handle; try { handle = await effects.open(file(name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); @@ -49,10 +51,10 @@ export function createCredentialIO({ directory, readOnly = false, effects = fs } } catch (error) { if (error.code === 'ENOENT') return missing; throw error; } finally { await handle?.close(); } }; - const write = async (name, text, { maxBytes = 512 * 1024, recover = false } = {}) => { + const write = async (name, text, { maxBytes = 4 * 1024 * 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' : ''); + await prepare(folderOf(name)); if (!recover) await read(name, { maxBytes }); const target = file(name), temporary = path.join(path.dirname(target), '.' + randomUUID() + '.tmp'); try { @@ -65,10 +67,14 @@ export function createCredentialIO({ directory, readOnly = false, effects = fs } }; 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' : ''); + await prepare(folderOf(name)); // Unlink the directory entry, never follow a damaged metadata symlink. await effects.rm(file(name), { force: true }); }; + const list = async folder => { + if (!await prepare(folder)) return []; + try { return await effects.readdir(file(folder)); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } + }; const transaction = action => { if (readOnly) return Promise.resolve().then(action); const previous = queues.get(root) || Promise.resolve(); @@ -90,5 +96,5 @@ export function createCredentialIO({ directory, readOnly = false, effects = fs } void result.finally(() => { if (queues.get(root) === result) queues.delete(root); }).catch(() => {}); return result; }; - return { directory: root, readOnly, file, prepare, read, write, remove, transaction }; + return { directory: root, readOnly, file, prepare, read, write, remove, list, transaction }; } diff --git a/docker/setup/credentials/keys.mjs b/docker/setup/credentials/keys.mjs new file mode 100644 index 0000000..1edd605 --- /dev/null +++ b/docker/setup/credentials/keys.mjs @@ -0,0 +1,70 @@ +// SSH private keys, handled only through OpenSSH's own tools in a private +// temporary folder. The folder never outlives the call. +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CredentialError } from './errors.mjs'; +import { execute } from './hostkeys.mjs'; + +const KEYGEN = '/usr/bin/ssh-keygen'; +// ssh-keygen's -P would put the passphrase in the process list. +const askpassEnv = passphrase => ({ ...process.env, SSH_ASKPASS: fileURLToPath(new URL('./askpass.sh', import.meta.url)), SSH_ASKPASS_REQUIRE: 'force', DISPLAY: process.env.DISPLAY || 't3-vault', T3_CREDENTIAL_PASSWORD: passphrase }); + +/** + * A key in its normal form, or null if this isn't one. T3's secret card has a + * single-line box, and browsers drop line breaks from those, so a pasted key + * arrives as one long line. The body is base64, so it can be wrapped again. + */ +export function rebuildPrivateKey(input) { + // Old-style encrypted keys have headers that line breaks are part of. They + // can't be rebuilt, but one that still has its line breaks is fine as it is. + if (/^-----BEGIN [A-Z ]*PRIVATE KEY-----\r?\nProc-Type:/.test(String(input).trim())) return String(input).trim().replace(/\r\n/g, '\n') + '\n'; + // Plain string steps, because a backtracking pattern here slows badly on long pastes. + const text = String(input).trim(); + const head = /^-----BEGIN (?:(?:OPENSSH|RSA|EC|DSA) )?PRIVATE KEY-----/.exec(text)?.[0]; + const foot = /-----END (?:(?:OPENSSH|RSA|EC|DSA) )?PRIVATE KEY-----$/.exec(text.slice(-64))?.[0]; + if (!head || !foot || text.length < head.length + foot.length) return null; + const inner = text.slice(head.length, text.length - foot.length).replace(/\s+/g, ''); + if (!inner || !/^[A-Za-z0-9+/=]+$/.test(inner)) return null; + return head + '\n' + inner.match(/.{1,70}/g).join('\n') + '\n' + foot + '\n'; +} + +async function workspace(work) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-key-')); + try { await fs.chmod(directory, 0o700); return await work(directory); } + finally { await fs.rm(directory, { recursive: true, force: true }); } +} + +/** Generates a new ed25519 key without a passphrase: the store has its own protection. */ +export const generateKey = (comment, { run = execute } = {}) => workspace(async directory => { + const file = path.join(directory, 'key'); + const answer = await run(KEYGEN, ['-q', '-t', 'ed25519', '-N', '', '-C', comment, '-f', file], { timeout: 10000 }); + if (answer.code !== 0) throw new CredentialError('A new SSH key could not be generated.', 500, 'key-failed'); + return { privateKey: await fs.readFile(file, 'utf8'), publicKey: (await fs.readFile(file + '.pub', 'utf8')).trim() }; +}); + +/** + * Checks a pasted key and returns it without a passphrase, plus its public half. + * The store is plain text either way, and `ssh prod` can't answer a passphrase + * prompt, so a passphrase here would add a failure without adding protection. + */ +export const openPrivateKey = (input, { passphrase, comment, run = execute } = {}) => workspace(async directory => { + const text = rebuildPrivateKey(input); + if (!text) throw new CredentialError('That doesn’t look like a private key. Paste the whole file, including its BEGIN and END lines.', 400, 'key-invalid', { field: 'privateKey' }); + const file = path.join(directory, 'key'); + await fs.writeFile(file, text, { mode: 0o600, flag: 'wx' }); + let open = await run(KEYGEN, passphrase ? ['-y', '-f', file] : ['-y', '-P', '', '-f', file], { timeout: 10000, ...(passphrase ? { env: askpassEnv(passphrase) } : {}) }); + if (open.code !== 0 && !passphrase) throw new CredentialError('This key needs its passphrase, or it isn’t a valid private key.', 400, 'key-passphrase', { field: 'passphrase' }); + if (open.code !== 0) throw new CredentialError('That passphrase didn’t open this key.', 400, 'key-passphrase', { field: 'passphrase' }); + const publicKey = open.stdout.trim().split(/\s+/).slice(0, 2).join(' ') + (comment ? ' ' + comment : ''); + if (passphrase) { + open = await run(KEYGEN, ['-q', '-p', '-N', '', '-f', file], { timeout: 10000, env: askpassEnv(passphrase) }); + if (open.code !== 0) throw new CredentialError('This key’s passphrase could not be removed.', 400, 'key-passphrase', { field: 'passphrase' }); + return { privateKey: await fs.readFile(file, 'utf8'), publicKey }; + } + return { privateKey: text, publicKey }; +}); + +/** The line to run on a server so this key can sign in. */ +export const installCommand = publicKey => `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '${publicKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`; diff --git a/docker/setup/credentials/model.mjs b/docker/setup/credentials/model.mjs index 4e84049..a4c5d74 100644 --- a/docker/setup/credentials/model.mjs +++ b/docker/setup/credentials/model.mjs @@ -1,52 +1,136 @@ import { isIP } from 'node:net'; -import { CredentialError, object, validText as text } from './errors.mjs'; -const fail = message => { throw new CredentialError(message); }; +import { CredentialError, object, validId, validText as text } from './errors.mjs'; +import { TYPES, applies, isType, withDefaults } from './types.mjs'; -export function apiTarget(profile, relative) { - if (profile.type !== 'api') throw new CredentialError('Choose an API connection.'); +const fail = (message, field) => { throw new CredentialError(message, 400, 'invalid', field ? { field } : {}); }; +const MULTILINE_MAX = 48 * 1024; +const SECRET_MAX = 8192; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +// A saved value under these names would break the command, or change what it runs. +const RESERVED_ENV = /^(?:PATH|HOME|USER|SHELL|PWD|IFS|LD_.*|DYLD_.*|NODE_OPTIONS|BASH_ENV|ENV)$/; + +export const validHost = value => text(value, 253) && Boolean(isIP(value) || /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(value)); +export const validEnvName = value => typeof value === 'string' && ENV_NAME.test(value) && !RESERVED_ENV.test(value); + +export function apiTarget(credential, relative) { + const base = credential.fields?.baseUrl; + if (!base) throw new CredentialError(`${credential.id} has no API address. Add one in Setup → Vault, or use t3-vault run instead.`); 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.'); + const root = new URL(base), target = new URL(relative || '.', root); + if (target.origin !== root.origin || !target.pathname.startsWith(root.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; +function normalizeApi(fields, id) { + let url; + try { url = new URL(fields.baseUrl); } catch { fail('Enter a complete http:// or https:// address.', 'baseUrl'); } + if (!text(fields.baseUrl, 2048) || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Use an HTTP or HTTPS address without credentials, a query or a fragment.', 'baseUrl'); + if (!url.pathname.endsWith('/')) url.pathname += '/'; + fields.baseUrl = url.href; + if (fields.authType !== 'bearer') { + if (!text(fields.authName, 128)) fail('Enter an authentication header or parameter name of up to 128 characters.', 'authName'); + if (fields.authType === 'header') { + try { new Headers({ [fields.authName]: 'placeholder' }); } catch { fail('Enter a valid HTTP header name.', 'authName'); } + if (['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'expect', 'trailer', 'te', 'accept', 'content-type'].includes(fields.authName.toLowerCase())) fail('Choose an authentication header, not a request transport or content header.', 'authName'); + if (fields.authPrefix !== undefined && (fields.authPrefix.length > 128 || /[^\x20-\x7e]/.test(fields.authPrefix))) fail('Use up to 128 printable ASCII characters for the optional header prefix.', 'authPrefix'); + if (fields.authPrefix !== undefined) fields.authPrefix = fields.authPrefix.trim(); } - profile.testPath = input.testPath ?? previous?.testPath ?? ''; - apiTarget(profile, profile.testPath); } - return profile; + fields.testPath ??= ''; + apiTarget({ id, fields }, fields.testPath); +} + +function normalizeField(entry, raw) { + const blank = raw === undefined || raw === null || raw === ''; + if (entry.kind === 'number') { + if (blank) { if (entry.optional) return undefined; fail(entry.message, entry.key); } + const value = typeof raw === 'string' ? Number(raw) : raw; + if (!Number.isInteger(value) || value < entry.min || value > entry.max) fail(entry.message, entry.key); + return value; + } + if (blank) { if (entry.optional) return undefined; fail(entry.message || `Enter ${entry.label.toLowerCase()}.`, entry.key); } + if (entry.kind === 'select') { if (!entry.options.some(([value]) => value === raw)) fail(`Choose ${entry.label.toLowerCase()}.`, entry.key); return raw; } + if (!text(raw, entry.max ?? 2048)) fail(entry.message || `Enter ${entry.label.toLowerCase()} of up to ${entry.max ?? 2048} characters, without line breaks.`, entry.key); + if (entry.kind === 'host' && !validHost(raw)) fail(entry.message, entry.key); + if (entry.pattern && !new RegExp(entry.pattern).test(raw)) fail(entry.message, entry.key); + return raw; +} + +function normalizeSecret(entry, raw, previous) { + const value = raw === undefined || raw === null || raw === '' ? previous : raw; + if (value === undefined) { if (entry.optional) return undefined; fail(`Enter ${entry.label.toLowerCase()}.`, entry.key); } + if (typeof value !== 'string' || !value.isWellFormed()) fail(`Enter ${entry.label.toLowerCase()} as plain text.`, entry.key); + if (entry.multiline) { + if (value.length > MULTILINE_MAX || value.includes('\0') || !value.trim()) fail(`Enter ${entry.label.toLowerCase()} as text of up to 48 KiB.`, entry.key); + return value; + } + // A pasted value or a file often ends in a line break that isn't part of it. + const line = value.replace(/\r?\n$/, ''); + if (!text(line, SECRET_MAX)) fail(`Enter ${entry.label.toLowerCase()}, up to ${SECRET_MAX} characters without line breaks.`, entry.key); + return line; +} + +function normalizeCustom(input, previous) { + const fields = {}, secrets = {}, kept = Array.isArray(input.secretNames) ? input.secretNames : Object.keys(input.secrets ?? {}); + const rawFields = object(input.fields) ? input.fields : {}, rawSecrets = object(input.secrets) ? input.secrets : {}; + if (Object.keys(rawFields).length + kept.length > 64) fail('Save at most 64 variables in one credential.'); + for (const [name, value] of Object.entries(rawFields)) { + if (!validEnvName(name)) fail(`${String(name).slice(0, 64)} can't be used as a variable name.`, 'vars'); + if (typeof value !== 'string' || value.length > SECRET_MAX || !value.isWellFormed() || value.includes('\0')) fail(`Enter ${name} as text of up to ${SECRET_MAX} characters.`, 'vars'); + fields[name] = value; + } + for (const name of kept) { + if (!validEnvName(name)) fail(`${String(name).slice(0, 64)} can't be used as a variable name.`, 'vars'); + if (Object.hasOwn(fields, name)) fail(`${name} is listed twice.`, 'vars'); + const value = normalizeSecret({ key: name, label: name, multiline: true }, rawSecrets[name], previous?.secrets?.[name]); + secrets[name] = value; + } + if (!Object.keys(fields).length && !Object.keys(secrets).length) fail('Add at least one variable.', 'vars'); + return { fields, secrets, env: {} }; +} + +export function normalize(input, previous) { + if (!object(input)) fail('Enter credential details.'); + if (!validId(input.id)) fail('Use up to 64 lowercase letters, numbers and single hyphens for the credential name.', 'id'); + if (input.id === 'new') fail('That name is reserved. Choose another.', 'id'); + if (!isType(input.type)) fail('Choose a credential type.', 'type'); + if (previous && previous.type !== input.type) fail('Create a new credential to change its type.', 'type'); + if (previous && previous.id !== input.id) fail('The credential name cannot change.', 'id'); + const description = input.description ?? previous?.description ?? ''; + if (typeof description !== 'string' || description.length > 200 || (description && !text(description, 200))) fail('Use up to 200 characters for the description, on one line.', 'description'); + const base = { id: input.id, type: input.type, description }; + const spec = TYPES[input.type]; + if (spec.dynamic) return { ...base, ...normalizeCustom(input, previous) }; + const raw = withDefaults(input.type, object(input.fields) ? { ...previous?.fields, ...input.fields } : previous?.fields); + const fields = {}; + // API settings only mean something beside an address, so they vanish with it. + for (const entry of spec.fields) { + if (!applies(entry, raw)) continue; + const value = normalizeField(entry, raw[entry.key]); + if (value !== undefined) fields[entry.key] = value; + } + if (fields.baseUrl) normalizeApi(fields, input.id); + const secrets = {}, rawSecrets = object(input.secrets) ? input.secrets : {}; + for (const entry of spec.secrets) { + if (!applies(entry, fields)) continue; + const value = normalizeSecret(entry, rawSecrets[entry.key], previous?.secrets?.[entry.key]); + if (value !== undefined) secrets[entry.key] = value; + } + const env = {}, rawEnv = object(input.env) ? { ...previous?.env, ...input.env } : previous?.env ?? {}; + for (const entry of [...spec.fields, ...spec.secrets]) { + const name = rawEnv[entry.key]; + if (!entry.editableEnv || name === undefined || name === '') continue; + if (!validEnvName(name)) fail(`Use letters, numbers and underscores for the variable name, not a name the system relies on.`, 'env'); + env[entry.key] = name; + } + return { ...base, fields, secrets, env }; } +// One record from before the Vault, in the shape above. +export function upgradeProfile(profile) { + const { id, type, secret, createdAt, updatedAt, secretChangedAt, revision } = profile; + const stamps = { ...(createdAt ? { createdAt } : {}), ...(updatedAt ? { updatedAt } : {}), ...(secretChangedAt !== undefined ? { secretChangedAt } : {}), ...(revision ? { revision } : {}) }; + if (type === 'ssh') return { id, type: 'ssh', description: '', fields: { host: profile.host, port: profile.port, username: profile.username, auth: 'password' }, secrets: { password: secret }, env: {}, ...stamps }; + const fields = { baseUrl: profile.baseUrl, authType: profile.authType ?? 'bearer', ...(profile.authName ? { authName: profile.authName } : {}), ...(profile.authPrefix ? { authPrefix: profile.authPrefix } : {}), testPath: profile.testPath ?? '' }; + return { id, type: 'token', description: '', fields, secrets: { token: secret }, env: {}, ...stamps }; +} diff --git a/docker/setup/credentials/redact.mjs b/docker/setup/credentials/redact.mjs index 8145b22..0ddcb0c 100644 --- a/docker/setup/credentials/redact.mjs +++ b/docker/setup/credentials/redact.mjs @@ -1,21 +1,70 @@ -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); +// Hides saved values in command output. This catches accidents, such as a tool +// echoing its own token. It can't stop a command that sets out to reveal one. +export const REDACTED = '[redacted]'; +const MIN_LENGTH = 4; +const MIN_LINE = 16; + +// The forms a value commonly takes in output. A file's long lines count as well, +// so printing part of a private key is caught too. +export function maskValues(secrets) { + const values = new Set(); + for (const secret of [secrets].flat()) { + if (typeof secret !== 'string' || secret.length < MIN_LENGTH) continue; + values.add(secret); values.add(secret.trim()); + values.add(JSON.stringify(secret).slice(1, -1)); + values.add(encodeURIComponent(secret)); + values.add(new URLSearchParams([['', secret]]).toString().slice(1)); + values.add(Buffer.from(secret).toString('base64')); + if (secret.includes('\n')) for (const line of secret.split(/\r?\n/)) if (line.trim().length >= MIN_LINE) values.add(line.trim()); + } + return [...values].filter(value => value.length >= MIN_LENGTH).sort((a, b) => b.length - a.length); +} + +function filterValues(values, write) { + if (!values.length) return { write: chunk => write(chunk), end() {} }; const pattern = new RegExp(values.map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'); - const keep = Math.max(...values.map(value => value.length)) - 1; + const longest = values[0].length, firsts = new Set(values.map(value => value[0])); let pending = ''; + // Only text that could still become a value waits for the next chunk, so + // ordinary output and prompts come through straight away. + const held = rest => { + for (let at = Math.max(0, rest.length - longest + 1); at < rest.length; at++) { + if (!firsts.has(rest[at])) continue; + const tail = rest.slice(at); + if (values.some(value => value.startsWith(tail))) return rest.length - at; + } + return 0; + }; const flush = final => { - let cut = final ? pending.length : Math.max(0, pending.length - keep); + let out = '', from = 0; for (const match of pending.matchAll(pattern)) { - if (match.index < cut && match.index + match[0].length > cut) cut = match.index + match[0].length; + out += pending.slice(from, match.index) + REDACTED; + from = match.index + match[0].length; } - if (cut) { write(pending.slice(0, cut).replace(pattern, '[redacted]')); pending = pending.slice(cut); } + const rest = pending.slice(from), keep = final ? 0 : held(rest); + out += rest.slice(0, rest.length - keep); + pending = keep ? rest.slice(-keep) : ''; + if (out) write(out); }; return { write(chunk) { pending += chunk; flush(false); }, end() { flush(true); } }; } +/** Text in, text out. */ +export const outputFilter = (secrets, write) => filterValues(maskValues(secrets), write); + +/** + * Bytes in, bytes out. Latin-1 maps each byte to one character and back, so + * binary output such as an image or an archive passes through unchanged. + */ +export function byteFilter(secrets, write) { + const values = maskValues(secrets).map(value => Buffer.from(value).toString('latin1')).sort((a, b) => b.length - a.length); + const filter = filterValues(values, text => write(Buffer.from(text, 'latin1'))); + return { write: chunk => filter.write(Buffer.from(chunk).toString('latin1')), end: () => filter.end() }; +} -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; +export function redactText(text, secrets) { + let result = ''; + const filter = outputFilter(secrets, 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 index 199a4e1..f0b1fdf 100644 --- a/docker/setup/credentials/run.mjs +++ b/docker/setup/credentials/run.mjs @@ -1,91 +1,122 @@ +import * as fs from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { Readable } from 'node:stream'; +import os from 'node:os'; +import path from 'node:path'; import { CredentialError } from './errors.mjs'; import { apiTarget } from './model.mjs'; -import { outputFilter } from './redact.mjs'; +import { byteFilter } from './redact.mjs'; import { createSshDiagnostics } from './diagnostics.mjs'; +import { delivery, secretValues } from './deliver.mjs'; export { apiTarget } from './model.mjs'; -export { outputFilter } from './redact.mjs'; +export { outputFilter, byteFilter } from './redact.mjs'; -async function pump(stream, write, secret) { +export const askpassPath = fileURLToPath(new URL('./askpass.sh', import.meta.url)); + +async function pump(stream, write, secrets) { if (!stream) return; - stream.setEncoding('utf8'); - const filter = outputFilter(secret, write); + const filter = byteFilter(secrets, write); try { for await (const chunk of stream) filter.write(chunk); } finally { filter.end(); } } + +// Memory-backed where the container has it, so secrets in files never reach the disk. +async function privateFolder(prefix) { + let parent = os.tmpdir(); + try { await fs.access('/dev/shm', fs.constants.W_OK); parent = '/dev/shm'; } catch { /* Use the default. */ } + const directory = await fs.mkdtemp(path.join(parent, prefix)); + await fs.chmod(directory, 0o700); + return directory; +} + const failure = code => ({ ok: false, code, detail: {} }); -function sshResult(code, diagnostics, profile, aborted) { +function sshResult(code, diagnostics, credential, 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 } }; + if (code === 0) return { ok: true, code: 'ok', detail: { user: credential.fields.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) { +const pinOptions = (credential, knownHostsFile, hostKeyType) => { + const algorithm = hostKeyType === 'ssh-rsa' ? 'rsa-sha2-512,rsa-sha2-256' : hostKeyType; + return ['-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + knownHostsFile.replace(/%/g, '%%'), '-o', 'GlobalKnownHostsFile=/dev/null', + '-o', 'HostKeyAlias=' + credential.id, '-o', 'HostKeyAlgorithms=' + algorithm]; +}; +const authOptions = (credential, keyFile) => credential.fields.auth === 'key' + ? ['-i', keyFile, '-o', 'IdentitiesOnly=yes', '-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no', '-o', 'BatchMode=yes'] + : ['-o', 'PreferredAuthentications=password,keyboard-interactive', '-o', 'PubkeyAuthentication=no', '-o', 'NumberOfPasswordPrompts=1', '-o', 'BatchMode=no']; + +export async function sshRequest(credential, command, { stdout = process.stdout, stderr = process.stderr, stdin = 'inherit', probe = false, interactive = false, signal, spawnProcess = spawn, diagnosticsFactory = createSshDiagnostics } = {}) { + if (credential.type !== 'ssh') throw new CredentialError(`${credential.id} isn't an SSH credential. Use t3-vault run instead.`); + if (!credential.knownHostsFile || !credential.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'); + throw new CredentialError(`The host key for ${credential.id} isn't trusted yet. Run t3-vault trust ${credential.id}, or review it in Setup → Vault.`, 409, 'host-key-untrusted'); + } + const secrets = secretValues(credential), keyAuth = credential.fields.auth === 'key'; + let folder = null, keyFile = null; + if (keyAuth) { + folder = await privateFolder('t3-vault-ssh-'); + keyFile = path.join(folder, 'key'); + await fs.writeFile(keyFile, credential.secrets.privateKey, { mode: 0o600, flag: 'wx' }); } - 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; + const env = { ...process.env, ...(keyAuth ? {} : { SSH_ASKPASS: askpassPath, SSH_ASKPASS_REQUIRE: 'force', DISPLAY: process.env.DISPLAY || 't3-vault', T3_CREDENTIAL_PASSWORD: credential.secrets.password }) }; + const target = ['-p', String(credential.fields.port), '-l', credential.fields.username, '-o', 'ConnectTimeout=10', '-o', 'ConnectionAttempts=1', '-o', 'ControlPath=none', + ...pinOptions(credential, credential.knownHostsFile, credential.hostKeyType), ...authOptions(credential, keyFile), '--', credential.fields.host]; 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; + if (interactive) { + const child = spawnProcess('/usr/bin/ssh', ['-o', 'LogLevel=ERROR', ...target, ...command], { env, stdio: 'inherit', signal }); + return await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', status => resolve(status ?? 1)); }); + } + let diagnostics; + try { diagnostics = await diagnosticsFactory(secrets, probe ? () => {} : value => stderr.write(value)); } + catch { if (probe) return failure('failed'); throw new CredentialError('SSH diagnostics could not start.', 502); } + let child, code = 1, startFailed = false; + try { + child = spawnProcess('/usr/bin/ssh', ['-T', '-E', diagnostics.file, '-o', 'LogLevel=ERROR', ...target, ...command], { env, 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), secrets), + pump(child.stderr, probe ? discard : value => stderr.write(value), secrets), done, + ]); + } catch { startFailed = true; child?.kill('SIGKILL'); } + const diagnosticText = await diagnostics.close(); + if (probe) return sshResult(startFailed ? 1 : code, diagnosticText, credential, signal?.aborted); + if (startFailed) throw new CredentialError('SSH could not start or was interrupted.', 502); + return code; + } finally { if (folder) await fs.rm(folder, { recursive: true, force: true }); } } + 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 } = {}) { +export async function apiRequest(credential, 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); + const target = apiTarget(credential, relative), { authType, authName, authPrefix } = credential.fields, secret = credential.secrets.token; + const secrets = secretValues(credential); 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); + if (authType === 'query') target.searchParams.set(authName, secret); + else if (authType === 'header') headers.set(authName, (authPrefix ? authPrefix + ' ' : '') + secret); + else headers.set('authorization', 'Bearer ' + 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'); + throw new CredentialError('API request refused a redirect. Use the final API address in Setup → Vault.', 502, 'redirect'); } - if (response.body) await pump(Readable.fromWeb(response.body), value => stdout.write(value), profile.secret); + if (response.body) await pump(Readable.fromWeb(response.body), value => stdout.write(value), secrets); if (!response.ok) stderr.write('HTTP ' + response.status + '\n'); return response.ok ? 0 : 1; } catch (error) { @@ -95,6 +126,56 @@ export async function apiRequest(profile, method, relative, { body, stdout = pro 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 }); + +/** + * Runs a command with saved credentials in its environment and hides their + * values in its output. Returns the exit code a shell would report. + */ +export async function runWith(credentials, command, { stdout = process.stdout, stderr = process.stderr, stdin = 'inherit', env = process.env, spawnProcess = spawn, signals = process } = {}) { + const merged = { ...env }, masks = [], set = new Map(), files = []; + const claim = (name, owner) => { + if (set.has(name) && set.get(name) !== owner) throw new CredentialError(`${set.get(name)} and ${owner} both set ${name}. Give one a different name with ${owner}=OTHER_NAME.`); + set.set(name, owner); + }; + for (const { credential, rename } of credentials) { + const given = delivery(credential, { rename, base: merged, askpass: askpassPath }); + for (const [name, value] of Object.entries(given.env)) { if (!name.startsWith('GIT_CONFIG_')) claim(name, credential.id); merged[name] = value; } + for (const file of given.files) { claim(file.env, credential.id); files.push(file); } + masks.push(...given.masks); + } + let folder = null; + try { + if (files.length) { + folder = await privateFolder('t3-vault-run-'); + for (const [index, file] of files.entries()) { + const directory = path.join(folder, String(index)); + await fs.mkdir(directory, { mode: 0o700 }); + const target = path.join(directory, file.name); + await fs.writeFile(target, file.content, { mode: 0o600, flag: 'wx' }); + merged[file.env] = target; + } + } + const child = spawnProcess(command[0], command.slice(1), { env: merged, stdio: [stdin, 'pipe', 'pipe'] }); + const forward = name => () => { child.kill(name); }; + const handlers = ['SIGINT', 'SIGTERM', 'SIGHUP'].map(name => [name, forward(name)]); + for (const [name, handler] of handlers) signals.on(name, handler); + try { + const closed = new Promise(resolve => { + child.once('error', error => resolve({ error })); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const [, , result] = await Promise.all([ + pump(child.stdout, value => stdout.write(value), masks), + pump(child.stderr, value => stderr.write(value), masks), closed, + ]); + if (result.error?.code === 'ENOENT') throw new CredentialError(`Command not found: ${command[0]}`, 127, 'command-not-found'); + if (result.error) throw new CredentialError(`${command[0]} could not start.`, 126, 'command-failed'); + return result.signal ? 128 + (os.constants.signals[result.signal] || 0) : result.code ?? 1; + } finally { for (const [name, handler] of handlers) signals.off(name, handler); } + } finally { if (folder) await fs.rm(folder, { recursive: true, force: true }); } +} + +export const canTest = credential => credential.type === 'ssh' || Boolean(credential.fields?.baseUrl); +export const testCredential = (credential, { signal = AbortSignal.timeout(15000), ...options } = {}) => credential.type === 'ssh' + ? sshRequest(credential, ['true'], { ...options, probe: true, stdin: 'ignore', signal }) + : apiRequest(credential, 'GET', credential.fields.testPath || '', { ...options, probe: true, signal }); diff --git a/docker/setup/credentials/service.mjs b/docker/setup/credentials/service.mjs index cce734e..0550ddc 100644 --- a/docker/setup/credentials/service.mjs +++ b/docker/setup/credentials/service.mjs @@ -1,93 +1,225 @@ import * as fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; +import { randomBytes } from 'node:crypto'; import { createCredentialStore } from '../credentials.mjs'; import { createCredentialUsage } from './usage.mjs'; -import { testCredential } from './run.mjs'; -import { CredentialError, object } from './errors.mjs'; +import { canTest, sshRequest, testCredential } from './run.mjs'; +import { CredentialError, object, validId } from './errors.mjs'; import { cleanResult } from './state.mjs'; +import { detect } from './detect.mjs'; +import { generateKey, installCommand, openPrivateKey } from './keys.mjs'; +import { publicTypes, withDefaults } from './types.mjs'; +import { secretValues } from './deliver.mjs'; +import { normalize } from './model.mjs'; -export const CREDENTIAL_ROUTES = ['/credentials', '/credentials/create', '/credentials/save', '/credentials/remove', '/credentials/scan', '/credentials/test', '/credentials/trust', '/credentials/forget-host-key']; +export const CREDENTIAL_ROUTES = ['/credentials', '/credentials/create', '/credentials/save', '/credentials/remove', '/credentials/scan', '/credentials/test', '/credentials/trust', '/credentials/forget-host-key', '/credentials/detect', '/credentials/generate-key', '/credentials/install-key']; +const PENDING_KEY_MS = 30 * 60_000; 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 } = {}) { +const target = credential => ({ ...(credential.id ? { id: credential.id } : {}), host: credential.fields.host, port: credential.fields.port }); +const INSTALL_PROBLEMS = { + 'auth-rejected': 'The server rejected that password, so the key wasn’t installed.', + 'host-key-changed': 'The server’s key changed during setup, so nothing was sent.', + unreachable: 'The server couldn’t be reached.', + timeout: 'The server took too long to respond.', +}; + +export function createCredentialsService({ store = createCredentialStore(), usage = createCredentialUsage(), test = testCredential, ssh = sshRequest, clock = Date.now, environmentId = async () => null, keygen = generateKey, keyOpener = openPrivateKey } = {}) { let running = 0; - const shutdown = new AbortController(), keys = store.hostKeys; + const shutdown = new AbortController(), keys = store.hostKeys, pending = new Map(); 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 scan = async (credential, signal) => keys.inspect(credential, { 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); + const credential = await store.public(id); + if (credential.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + if (revision !== undefined && revision !== credential.revision) throw new CredentialError('This credential changed before its key could be reviewed. Read the current details first.', 409, 'changed', { current: credential }); + const scanned = await scan(target(credential), 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 }); + const saved = await store.trust({ id, revision: credential.revision, key: scanned.key }); return { ok: true, hostKey: saved.hostKey }; }; + + // A key sign-in can't hand the key to an impostor, so a new host is pinned the + // first time it's reached. From then on, a different key blocks it. + const pinFirstUse = async (id, signal) => { + const credential = await store.public(id); + if (credential.type !== 'ssh' || credential.fields.auth !== 'key') return { ok: false, code: 'host-key-untrusted' }; + if (credential.hostKey) return { ok: true, hostKey: credential.hostKey }; + const scanned = await scan(target(credential), signal); + if (!scanned.ok) return { ok: false, code: scanned.code }; + const saved = await store.trust({ id, revision: credential.revision, key: scanned.key, firstUse: true }); + return { ok: true, firstUse: true, hostKey: saved.hostKey }; + }; + + const takePending = id => { + for (const [key, value] of pending) if (clock() - value.at > PENDING_KEY_MS) pending.delete(key); + const value = pending.get(id); + if (!value) throw new CredentialError('That generated key has expired. Generate a new one.', 409, 'key-expired'); + return value; + }; + const generate = async id => { + const made = await keygen(validId(id) ? 't3-vault:' + id : 't3-vault'); + for (const [key, value] of pending) if (clock() - value.at > PENDING_KEY_MS) pending.delete(key); + while (pending.size >= 16) pending.delete(pending.keys().next().value); + const keyId = randomBytes(16).toString('hex'); + pending.set(keyId, { ...made, at: clock() }); + return { keyId, publicKey: made.publicKey, install: installCommand(made.publicKey) }; + }; + + // Everything key-related happens before the store is touched, so a bad key + // leaves nothing behind. A key's passphrase isn't kept: see openPrivateKey. + const withKey = async ({ credential, generatedKeyId, generate: fresh, passphrase }) => { + if (!object(credential) || credential.type !== 'ssh') return credential; + const fields = withDefaults('ssh', credential.fields), secrets = { ...credential.secrets }; + delete fields.publicKey; // Only ever derived from the key itself. + if (fields.auth !== 'key') return { ...credential, fields, secrets }; + if (fresh) { const made = await keygen('t3-vault:' + credential.id); secrets.privateKey = made.privateKey; fields.publicKey = made.publicKey; } + else if (generatedKeyId !== undefined) { const made = takePending(generatedKeyId); secrets.privateKey = made.privateKey; fields.publicKey = made.publicKey; } + else if (secrets.privateKey) { const opened = await keyOpener(secrets.privateKey, { passphrase, comment: 't3-vault:' + credential.id }); secrets.privateKey = opened.privateKey; fields.publicKey = opened.publicKey; } + return { ...credential, fields, secrets }; + }; + + const withKnownHost = async (credential, key, work) => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 't3-credential-key-')); + try { + await fs.chmod(temporary, 0o700); + const file = path.join(temporary, 'known_hosts'); + await fs.writeFile(file, `${credential.id} ${key.type} ${key.key}\n`, { mode: 0o600, flag: 'wx' }); + return await work({ ...credential, knownHostsFile: file, hostKeyType: key.type }); + } finally { await fs.rm(temporary, { recursive: true, force: true }); } + }; + + /** Installs a key with a password, proves the key works, then saves only the key. */ + const installWithPassword = async ({ credential, password, expected, revision, create, origin }) => network(async signal => { + if (!credential.secrets?.privateKey || !credential.fields.publicKey) throw new CredentialError('A key is needed before it can be installed.'); + const scanned = await scan(target({ ...credential, id: undefined }), signal); + if (!scanned.ok) throw new CredentialError(INSTALL_PROBLEMS[scanned.code] || 'The server couldn’t be checked.', 409, 'install-failed', { detail: 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 }); + await withKnownHost(credential, scanned.key, async pinned => { + const withPassword = { ...pinned, fields: { ...pinned.fields, auth: 'password' }, secrets: { password } }; + const installed = await ssh(withPassword, [installCommand(credential.fields.publicKey)], { probe: true, stdin: 'ignore', signal: AbortSignal.any([AbortSignal.timeout(20000), signal]) }); + if (!installed.ok) throw new CredentialError(INSTALL_PROBLEMS[installed.code] || 'The key couldn’t be installed on the server.', 409, 'install-failed', { detail: installed.code }); + const verified = await test(pinned, { signal: AbortSignal.any([AbortSignal.timeout(15000), signal]) }); + if (!verified.ok) throw new CredentialError('The key was added to the server, but signing in with it failed. Your password wasn’t saved.', 409, 'install-failed', { detail: verified.code }); + }); + return store.save({ credential, revision, create, origin, pin: { key: scanned.key } }); + }); + + const save = async (input, { create, origin = { by: 'setup' } }) => { + if (input.trust !== undefined) validTrust(input.trust); + const credential = await withKey(input); + if (input.install !== undefined) { + if (!object(input.install) || typeof input.install.password !== 'string' || !input.install.password) throw new CredentialError('Enter the server’s password so the key can be installed.', 400, 'invalid', { field: 'password' }); + if (input.trust === undefined) throw new CredentialError('Review the server’s fingerprint before sending it your password.', 409, 'host-key-untrusted'); + if (!create) throw new CredentialError('Save this credential first, then move it to a key.'); + const saved = await installWithPassword({ credential: normalize(credential), password: input.install.password, expected: input.trust.fingerprint, revision: input.revision, create, origin }); + if (input.generatedKeyId !== undefined) pending.delete(input.generatedKeyId); + return { ok: true, ...saved }; + } + const saved = await store.save({ credential, revision: input.revision, create, origin }); + if (input.generatedKeyId !== undefined) pending.delete(input.generatedKeyId); + const { id } = saved.credential; + if (input.trust !== undefined) { + try { saved.trust = await network(signal => trustSaved(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(id); + } else if (saved.credential.type === 'ssh' && saved.credential.fields.auth === 'key' && !saved.credential.hostKey) { + try { saved.trust = await network(signal => pinFirstUse(id, signal)); } + catch (error) { saved.trust = { ok: false, code: error.code || 'failed' }; } + saved.credential = await store.public(id); + } + return { ok: true, ...saved }; + }; + + /** Moves a saved password sign-in to a new key, then forgets the password. */ + const installKey = async ({ id, revision }, { origin = { by: 'setup' } } = {}) => { + const credential = await store.forUse(id); + if (credential.type !== 'ssh' || credential.fields.auth !== 'password') throw new CredentialError(`${id} isn't an SSH credential that signs in with a password.`); + if (!credential.knownHostsFile) throw new CredentialError(`Trust the host key for ${id} before sending it the password.`, 409, 'host-key-untrusted'); + if (revision !== undefined && revision !== credential.revision) throw new CredentialError('This credential changed in another window.', 409, 'changed'); + const made = await keygen('t3-vault:' + id); + const { hostFingerprint, knownHostsFile, hostKeyType } = credential; + return network(async signal => { + const installed = await ssh(credential, [installCommand(made.publicKey)], { probe: true, stdin: 'ignore', signal: AbortSignal.any([AbortSignal.timeout(20000), signal]) }); + if (!installed.ok) throw new CredentialError(INSTALL_PROBLEMS[installed.code] || 'The key couldn’t be installed on the server.', 409, 'install-failed', { detail: installed.code }); + const next = { id, type: 'ssh', description: credential.description, fields: { host: credential.fields.host, port: credential.fields.port, username: credential.fields.username, auth: 'key', publicKey: made.publicKey }, secrets: { privateKey: made.privateKey } }; + const verified = await test({ ...next, knownHostsFile, hostKeyType }, { signal: AbortSignal.any([AbortSignal.timeout(15000), signal]) }); + if (!verified.ok) throw new CredentialError('The key was added to the server, but signing in with it failed. The password is still saved.', 409, 'install-failed', { detail: verified.code }); + const saved = await store.save({ credential: next, revision: credential.revision, origin }); + return { ok: true, ...saved, fingerprint: hostFingerprint }; + }); + }; + 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); + let credential = draft ? await store.draft({ draft: await withKey({ credential: input.draft, generatedKeyId: input.generatedKeyId, passphrase: input.passphrase }), id: input.id }) : await store.forUse(input.id); + if (!canTest(credential)) return { ok: true, result: null }; + let hostFingerprint = credential.hostFingerprint || null, result, shown = null; + const run = async pinned => { + const answer = result || await test(pinned, { signal: AbortSignal.any([AbortSignal.timeout(15000), signal]) }); + const cleaned = cleanResult({ ...answer, ...(shown ? { hostKey: shown } : {}), ms: clock() - started }, secretValues(credential)); 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 }); + if (!draft) await store.recordTest({ id: pinned.id, revision: pinned.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 }); } + }; + if (credential.type !== 'ssh') return run(credential); + const scanned = await scan(draft && !credential.knownHostsFile ? target({ ...credential, id: undefined }) : target(credential), signal); + if (!scanned.ok) { result = scanned; return run(credential); } + const presented = scanned.hostKey.presented, trusted = scanned.hostKey.trusted, keyAuth = credential.fields.auth === 'key'; + // 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 } }; return run(credential); } + if (!agreed && scanned.hostKey.state === 'changed') { result = { ok: false, code: 'host-key-changed', detail: { trusted, presented }, hostKey: scanned.hostKey }; return run(credential); } + if (!agreed && scanned.hostKey.state === 'new' && !keyAuth) { result = { ok: false, code: 'host-key-untrusted', detail: {}, hostKey: scanned.hostKey }; return run(credential); } + if (scanned.hostKey.state === 'new' || agreed) { + shown = scanned.hostKey; + if (draft) return withKnownHost(credential, scanned.key, run); + const saved = await store.trust({ id: credential.id, revision: credential.revision, key: scanned.key, firstUse: true }); + hostFingerprint = saved.hostKey.fingerprint; + credential = await store.forUse(credential.id); + } + return run(credential); }; + return { initialize: store.initialize, summary: store.summary, invalidateUsage: () => usage.invalidate(), close: () => shutdown.abort(), + save, installKey, testInput: input => network(signal => testInput(input, signal)), pinFirstUse: id => network(signal => pinFirstUse(id, signal)), + trust: (id, expected, revision) => network(signal => trustSaved(id, expected, signal, revision)), + scan: id => network(async signal => { const credential = await store.public(id); return scan(target(credential), signal); }), 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') { + const listed = await store.list(); + return { ok: true, ...await usage.annotate(listed.credentials), sshAliases: listed.sshAliases, types: publicTypes(), environmentId: await environmentId().catch(() => null) }; } + if (route === '/credentials/create' || route === '/credentials/save') return save(input, { create: route === '/credentials/create' }); 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/detect') { + if (typeof input.text !== 'string' || input.text.length > 48 * 1024) throw new CredentialError('Paste at most 48 KiB.'); + return { ok: true, detected: detect(input.text) }; + } + if (route === '/credentials/generate-key') return { ok: true, ...await generate(input.id) }; + if (route === '/credentials/install-key') return installKey(input); 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); + const credential = input.id === undefined ? { fields: { host: input.host, port: input.port } } : await store.public(input.id); + if (input.id !== undefined && credential.type !== 'ssh') throw new CredentialError('Choose an SSH credential.'); + if (input.revision !== undefined && input.revision !== credential.revision) throw new CredentialError('This credential changed before its key could be reviewed. Read the current details first.', 409, 'changed', { current: credential }); + const started = clock(), scanned = await scan(target(credential), 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/skill.mjs b/docker/setup/credentials/skill.mjs new file mode 100644 index 0000000..ab00dab --- /dev/null +++ b/docker/setup/credentials/skill.mjs @@ -0,0 +1,44 @@ +// Tells every agent that the Vault exists. T3 Code's own instructions send agents +// straight to request_secret, so without this they would ask for a secret every +// time and never look here. Shared AGENTS.md only reaches three agents, while +// these three skill folders reach all seven. +// +// It behaves like any other skill once installed: an edit or a removal sticks, +// and only an untouched copy gets updated when the image ships a new one. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const SOURCE = fileURLToPath(new URL('./SKILL.md', import.meta.url)); +const digest = text => createHash('sha256').update(text).digest('hex'); + +export function vaultSkillFolders({ home, env = {} }) { + const claude = path.resolve(home, String(env.CLAUDE_CONFIG_DIR || '').trim() || '.claude'); + return [path.join(claude, 'skills', 't3-vault'), path.join(home, '.agents/skills/t3-vault'), path.join(home, '.gemini/config/skills/t3-vault')]; +} + +async function writeAtomic(file, text, effects) { + const temporary = path.join(path.dirname(file), '.' + randomUUID() + '.tmp'); + try { await effects.writeFile(temporary, text, { flag: 'wx', mode: 0o644 }); await effects.rename(temporary, file); } + finally { await effects.rm(temporary, { force: true }); } +} + +export async function installVaultSkill({ home, env = {}, stateFile, effects = fs, source = SOURCE }) { + const content = await effects.readFile(source, 'utf8'), wanted = digest(content); + let last = null; + try { last = JSON.parse(await effects.readFile(stateFile, 'utf8')).digest ?? null; } catch { /* First run. */ } + for (const folder of vaultSkillFolders({ home, env })) { + const file = path.join(folder, 'SKILL.md'); + try { + let existing = null; + try { existing = await effects.readFile(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') continue; } + if (existing === null ? last !== null : digest(existing) !== last) continue; + if (existing === content) continue; + await effects.mkdir(folder, { recursive: true }); + await writeAtomic(file, content, effects); + } catch { /* One unwritable agent folder shouldn't affect the others. */ } + } + await effects.mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 }); + await writeAtomic(stateFile, JSON.stringify({ digest: wanted }) + '\n', effects); +} diff --git a/docker/setup/credentials/sshconfig.mjs b/docker/setup/credentials/sshconfig.mjs new file mode 100644 index 0000000..2428da8 --- /dev/null +++ b/docker/setup/credentials/sshconfig.mjs @@ -0,0 +1,58 @@ +// Makes plain `ssh prod`, `scp` and `git` work for saved SSH credentials. The +// Vault owns one generated file, and ~/.ssh/config gets one Include line for it. +// Everything else in that file stays as it was. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const quoted = value => `"${String(value).replace(/["\\]/g, '\\$&')}"`; +// ssh expands %d and friends in these, so a literal % has to be doubled. +const literal = value => String(value).replace(/%/g, '%%'); + +export function sshConfigText(credentials, { root }) { + const blocks = credentials.filter(row => row.type === 'ssh').sort((a, b) => a.id.localeCompare(b.id, 'en')).map(row => { + const { host, port, username, auth } = row.fields; + const touch = path.join(root, 'usage', row.id + '.touch'); + const lines = [`Host ${row.id}`, ` HostName ${host}`, ` Port ${port}`, ` User ${username}`, + ` UserKnownHostsFile ${quoted(literal(path.join(root, 'known_hosts', row.id)))}`, ' GlobalKnownHostsFile /dev/null', ` HostKeyAlias ${row.id}`, ' StrictHostKeyChecking yes', + ' PermitLocalCommand yes', ` LocalCommand /usr/bin/touch ${quoted(literal(touch))} 2>/dev/null`]; + if (auth === 'key') lines.push(` IdentityFile ${quoted(literal(path.join(root, 'keys', row.id)))}`, ' IdentitiesOnly yes', ' PreferredAuthentications publickey', ' BatchMode yes'); + else lines.push(' PubkeyAuthentication no', ' PreferredAuthentications password,keyboard-interactive'); + return lines.join('\n'); + }); + return '# Generated by t3-vault. Changes here are replaced. Manage these in Setup → Vault.\n\n' + blocks.join('\n\n') + (blocks.length ? '\n' : ''); +} + +const includeLine = file => `Include ${quoted(file)}`; + +/** True if ~/.ssh/config already reads the generated file. */ +export async function hasInclude({ home, file, effects = fs }) { + try { return (await effects.readFile(path.join(home, '.ssh/config'), 'utf8')).split(/\r?\n/).some(line => line.trim() === includeLine(file)); } + catch { return false; } +} + +/** + * Adds the Include at the top, where ssh reads it before any `Host *` block. + * Returns false rather than guess if ~/.ssh/config is a link or isn't ours to + * write, and `t3-vault ssh` keeps working either way. + */ +export async function ensureInclude({ home, file, effects = fs }) { + const directory = path.join(home, '.ssh'), target = path.join(directory, 'config'); + try { + await effects.mkdir(directory, { recursive: true, mode: 0o700 }); + let existing = '', mode = 0o600; + try { + const stat = await effects.lstat(target); + if (!stat.isFile()) return false; + existing = await effects.readFile(target, 'utf8'); mode = stat.mode & 0o777; + } catch (error) { if (error.code !== 'ENOENT') return false; } + if (existing.split(/\r?\n/).some(line => line.trim() === includeLine(file))) return true; + const temporary = path.join(directory, '.' + randomUUID() + '.tmp'); + try { + const handle = await effects.open(temporary, 'wx', mode); + try { await handle.writeFile(includeLine(file) + '\n\n' + existing); await handle.sync(); } finally { await handle.close(); } + await effects.rename(temporary, target); + } finally { await effects.rm(temporary, { force: true }); } + return true; + } catch { return false; } +} diff --git a/docker/setup/credentials/state.mjs b/docker/setup/credentials/state.mjs index 9233f4a..58124ae 100644 --- a/docker/setup/credentials/state.mjs +++ b/docker/setup/credentials/state.mjs @@ -1,3 +1,6 @@ +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; 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']); @@ -27,12 +30,52 @@ export function createCredentialState({ io }) { 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); + const result = cleanResult(value, Object.values(profiles.find(profile => profile.id === id)?.secrets ?? {})); 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 }; + 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, firstUse: value.firstUse === 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 }) }; } + +// Which thread started this command, when T3 Code ran it. +export const callerThread = (env = process.env) => { + const id = env.T3CODE_THREAD_ID; + return typeof id === 'string' && /^[\w:.-]{1,128}$/.test(id) ? id : null; +}; + +/** + * When each credential was last used. Each one has its own disposable file, + * written without the store's lock, so a busy command never waits on a save or + * blocks one. Losing an update here only makes a "last used" line slightly old. + */ +export function createUsageLog({ directory, clock = Date.now, effects = fs }) { + const folder = path.join(directory, 'usage'); + const fileFor = id => path.join(folder, id + '.json'); + const read = async id => { + if (!validId(id)) return null; + let saved = null, touched = null; + try { + const data = JSON.parse(await effects.readFile(fileFor(id), 'utf8')); + if (object(data) && typeof data.at === 'string' && Number.isFinite(Date.parse(data.at))) saved = { at: data.at, count: Number.isSafeInteger(data.count) ? data.count : 1, ...(typeof data.threadId === 'string' ? { threadId: data.threadId } : {}), ...(typeof data.command === 'string' ? { command: data.command } : {}) }; + } catch { /* Disposable. */ } + // ssh's own LocalCommand touches this, so plain `ssh prod` counts as a use. + try { touched = (await effects.stat(path.join(folder, id + '.touch'))).mtime.toISOString(); } catch { /* Never used that way. */ } + if (touched && (!saved || touched > saved.at)) return { count: saved?.count ?? 1, at: touched, viaSsh: true }; + return saved; + }; + const record = async (id, { command, env = process.env } = {}) => { + if (!validId(id)) return; + try { + await effects.mkdir(folder, { recursive: true, mode: 0o700 }); + const previous = await read(id), thread = callerThread(env), name = typeof command === 'string' ? path.basename(command).slice(0, 64) : null; + const temporary = path.join(folder, '.' + randomUUID() + '.tmp'); + await effects.writeFile(temporary, JSON.stringify({ at: new Date(clock()).toISOString(), count: (previous?.count ?? 0) + 1, ...(thread ? { threadId: thread } : {}), ...(name ? { command: name } : {}) }) + '\n', { mode: 0o600, flag: 'wx' }); + await effects.rename(temporary, fileFor(id)); + } catch { /* Never let bookkeeping fail a command that worked. */ } + }; + const remove = async id => { if (!validId(id)) return; await Promise.all([fileFor(id), path.join(folder, id + '.touch')].map(file => effects.rm(file, { force: true }).catch(() => {}))); }; + return { read, record, remove }; +} diff --git a/docker/setup/credentials/types.mjs b/docker/setup/credentials/types.mjs new file mode 100644 index 0000000..a974983 --- /dev/null +++ b/docker/setup/credentials/types.mjs @@ -0,0 +1,156 @@ +// What each kind of credential holds and how a command receives it. Everything +// here is data: the store validates against it, the CLI builds its flags from +// it and the Setup page draws its form from it, so a new type needs no new code +// unless a command needs something more than environment variables. +// +// fields not secret, shown everywhere +// secrets write-only, never leave the store except for a command that uses them +// +// `env` names the variable a command gets, with {NAME} standing for the +// credential's name. `file` delivers the value as a temporary file instead, and +// the variable holds its path. `when` shows an entry only for some field values. +const API_FIELDS = Object.freeze([ + { key: 'baseUrl', label: 'API address', kind: 'url', optional: true, max: 2048, placeholder: 'https://api.example.com/v1/', hint: 'Lets agents call it with t3-vault http, and lets Setup test it.' }, + { key: 'authType', label: 'Authentication', kind: 'select', default: 'bearer', needs: 'baseUrl', options: [['bearer', 'Bearer token'], ['header', 'Header'], ['query', 'Query parameter']] }, + { key: 'authName', label: 'Header or parameter name', optional: true, max: 128, needs: 'baseUrl', when: { authType: ['header', 'query'] }, placeholder: 'X-API-Key' }, + { key: 'authPrefix', label: 'Value prefix', optional: true, max: 128, needs: 'baseUrl', when: { authType: ['header'] }, placeholder: 'Token' }, + { key: 'testPath', label: 'GET test path', optional: true, max: 2048, needs: 'baseUrl', placeholder: 'account', hint: 'A relative path that needs authentication.' }, +]); + +const HOST = { kind: 'host', max: 253, message: 'Enter a hostname or IP address, without a username or port.' }; +const USER = { pattern: '^[a-zA-Z_][a-zA-Z0-9_.-]*\\$?$', max: 64, message: 'Enter a valid username.' }; + +export const TYPES = Object.freeze({ + ssh: { + label: 'SSH server', badge: 'SSH', icon: 'terminal', order: 1, + fields: [ + { key: 'host', label: 'Host', ...HOST, placeholder: '192.0.2.10' }, + { key: 'port', label: 'Port', kind: 'number', default: 22, min: 1, max: 65535, message: 'Use a port from 1 to 65535.' }, + { key: 'username', label: 'Username', ...USER, placeholder: 'deploy' }, + { key: 'auth', label: 'Sign-in', kind: 'select', default: 'key', options: [['key', 'Key'], ['password', 'Password']] }, + { key: 'publicKey', label: 'Public key', kind: 'derived', optional: true, max: 16384, when: { auth: ['key'] } }, + ], + secrets: [ + { key: 'privateKey', label: 'Private key', multiline: true, primary: true, when: { auth: ['key'] }, flag: 'key-file', placeholder: 'Paste a private key' }, + { key: 'password', label: 'Password', primary: true, when: { auth: ['password'] }, flag: 'password-file' }, + ], + }, + token: { + label: 'API token', badge: 'Token', icon: 'globe', order: 2, secretFirst: true, + fields: API_FIELDS, + secrets: [{ key: 'token', label: 'Token', primary: true, env: '{NAME}_TOKEN', editableEnv: true }], + }, + login: { + label: 'Username and password', badge: 'Login', icon: 'user-round', order: 3, + fields: [ + { key: 'username', label: 'Username', max: 256, env: '{NAME}_USERNAME', editableEnv: true }, + { key: 'url', label: 'Address', optional: true, max: 2048, placeholder: 'https://example.com', env: '{NAME}_URL' }, + ], + secrets: [{ key: 'password', label: 'Password', primary: true, env: '{NAME}_PASSWORD', editableEnv: true }], + }, + database: { + label: 'Database', badge: 'Database', icon: 'database', order: 4, + fields: [ + { key: 'engine', label: 'Engine', kind: 'select', default: 'postgres', options: [['postgres', 'PostgreSQL'], ['mysql', 'MySQL'], ['mongodb', 'MongoDB'], ['redis', 'Redis']] }, + { key: 'host', label: 'Host', ...HOST, placeholder: 'db.example.net' }, + { key: 'port', label: 'Port', kind: 'number', optional: true, min: 1, max: 65535, message: 'Use a port from 1 to 65535.' }, + { key: 'database', label: 'Database', optional: true, max: 256 }, + { key: 'username', label: 'Username', optional: true, max: 256 }, + { key: 'options', label: 'Connection options', optional: true, max: 1024, placeholder: 'sslmode=require' }, + // rediss:// and mongodb+srv:// mean TLS and DNS lookups, so a pasted URL keeps its own. + { key: 'scheme', label: 'URL scheme', kind: 'derived', optional: true, max: 32, pattern: '^[a-z][a-z0-9+]*$', message: 'Use a URL scheme, such as rediss.' }, + ], + secrets: [{ key: 'password', label: 'Password', primary: true, optional: true }], + }, + aws: { + label: 'AWS', badge: 'AWS', icon: 'cloud', order: 5, + fields: [ + { key: 'accessKeyId', label: 'Access key ID', max: 128, pattern: '^[A-Z0-9]+$', message: 'Enter an access key ID, such as AKIA followed by letters and numbers.', env: 'AWS_ACCESS_KEY_ID' }, + { key: 'region', label: 'Region', optional: true, max: 64, placeholder: 'us-east-1', env: 'AWS_REGION' }, + ], + secrets: [ + { key: 'secretAccessKey', label: 'Secret access key', primary: true, env: 'AWS_SECRET_ACCESS_KEY' }, + { key: 'sessionToken', label: 'Session token', optional: true, env: 'AWS_SESSION_TOKEN' }, + ], + }, + gcp: { + label: 'Google Cloud', badge: 'Google Cloud', icon: 'cloud', order: 6, + fields: [ + { key: 'project', label: 'Project', optional: true, max: 128, env: 'GOOGLE_CLOUD_PROJECT' }, + { key: 'clientEmail', label: 'Service account', optional: true, max: 256 }, + ], + secrets: [{ key: 'serviceAccount', label: 'Service account key', multiline: true, primary: true, file: 'service-account.json', env: 'GOOGLE_APPLICATION_CREDENTIALS', placeholder: 'Paste the JSON key file' }], + }, + kubernetes: { + label: 'Kubernetes', badge: 'Kubernetes', icon: 'box', order: 7, + fields: [], + secrets: [{ key: 'kubeconfig', label: 'Kubeconfig', multiline: true, primary: true, file: 'kubeconfig', env: 'KUBECONFIG', placeholder: 'Paste the kubeconfig file' }], + }, + git: { + label: 'Git host', badge: 'Git', icon: 'git-branch', order: 8, secretFirst: true, + fields: [ + { key: 'host', label: 'Host', ...HOST, default: 'github.com', placeholder: 'github.com', hint: 'Commands run with this credential can use git over HTTPS to this host.' }, + { key: 'username', label: 'Username', optional: true, max: 256, placeholder: 'x-access-token' }, + ...API_FIELDS, + ], + secrets: [{ key: 'token', label: 'Token', primary: true, env: 'GIT_TOKEN', editableEnv: true }], + }, + registry: { + label: 'Package registry', badge: 'Registry', icon: 'package', order: 9, secretFirst: true, + fields: [ + { key: 'username', label: 'Username', optional: true, max: 256, env: '{NAME}_USERNAME' }, + ...API_FIELDS, + ], + secrets: [{ key: 'token', label: 'Token or password', primary: true, env: '{NAME}_TOKEN', editableEnv: true }], + }, + file: { + label: 'File', badge: 'File', icon: 'file-text', order: 10, secretFirst: true, + fields: [{ key: 'filename', label: 'File name', default: 'credential', pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$', max: 128, message: 'Use letters, numbers, dots, hyphens and underscores.', hint: 'The temporary file gets this name, for tools that care.' }], + secrets: [{ key: 'content', label: 'Contents', multiline: true, primary: true, file: true, env: '{NAME}_FILE', editableEnv: true, placeholder: 'Paste the file' }], + }, + custom: { + label: 'Custom', badge: 'Custom', icon: 'list', order: 11, dynamic: true, + fields: [], + secrets: [], + }, +}); + +export const TYPE_IDS = Object.freeze(Object.keys(TYPES)); +export const isType = value => typeof value === 'string' && Object.hasOwn(TYPES, value); + +/** Whether a field or secret applies, given the credential's fields with their defaults. */ +export function applies(entry, fields = {}) { + if (entry.needs && !fields[entry.needs]) return false; + if (!entry.when) return true; + return Object.entries(entry.when).every(([key, values]) => values.includes(fields[key])); +} + +/** Field defaults filled in, so `when` sees what the store will save. */ +export function withDefaults(type, fields = {}) { + const result = { ...fields }; + for (const entry of TYPES[type].fields) if (result[entry.key] === undefined && entry.default !== undefined) result[entry.key] = entry.default; + return result; +} + +export const envStem = id => id.toUpperCase().replace(/-/g, '_'); +/** cloudflare-token with {NAME}_TOKEN is CLOUDFLARE_TOKEN, not CLOUDFLARE_TOKEN_TOKEN. */ +export function resolveEnvName(template, id) { + if (!template.includes('{NAME}')) return template; + const stem = envStem(id), suffix = template.replace('{NAME}_', ''); + return stem === suffix || stem.endsWith('_' + suffix) ? stem : template.replace('{NAME}', stem); +} + +/** The variable each entry reaches a command under, for this credential. */ +export function envNames(credential) { + const spec = TYPES[credential.type], names = {}; + for (const entry of [...spec.fields, ...spec.secrets]) { + if (!entry.env) continue; + names[entry.key] = credential.env?.[entry.key] || resolveEnvName(entry.env, credential.id); + } + return names; +} + +export const primarySecret = (type, fields = {}) => TYPES[type].secrets.find(entry => entry.primary && applies(entry, fields)) || null; + +/** The page and CLI's view of the types. No functions, so it survives JSON. */ +export const publicTypes = () => TYPE_IDS.map(id => ({ id, ...TYPES[id] })).sort((a, b) => a.order - b.order); diff --git a/docker/setup/credentials/usage.mjs b/docker/setup/credentials/usage.mjs index b919a8b..267c9a5 100644 --- a/docker/setup/credentials/usage.mjs +++ b/docker/setup/credentials/usage.mjs @@ -3,9 +3,26 @@ import path from 'node:path'; import { scanSkillFiles, readSkillFile, skillMetadata, skillProviders } from '../skills.mjs'; import { readAgentsMd } from '../context.mjs'; +const SINGLE = /(?:^|[^\w-])t3-vault\s+(?:ssh|http|show|test|replace|trust|use-key)\s+(['"]?)([a-z0-9]+(?:-[a-z0-9]+)*)\1(?![\w-])/g; +const RUN = /(?:^|[^\w-])t3-vault\s+run\s+([^\n|;&]*)/g; +const PLAIN_SSH = /(?:^|[^\w-])ssh\s+(['"]?)([a-z0-9]+(?:-[a-z0-9]+)*)\1(?![\w.@:-])/g; + +/** Names a skill uses through t3-vault. */ 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])); + const text = String(content), names = new Set([...text.matchAll(SINGLE)].map(match => match[2])); + for (const match of text.matchAll(RUN)) { + for (const token of match[1].trim().split(/\s+/)) { + if (token === '--' || token === '') break; + const name = /^(['"]?)([a-z0-9]+(?:-[a-z0-9]+)*)(?:=[A-Za-z_][A-Za-z0-9_]*)?\1$/.exec(token)?.[2]; + if (!name) break; + names.add(name); + } + } + return names; } +/** Names a skill reaches with plain `ssh NAME`, which only count for SSH credentials. */ +export const sshMentions = content => new Set([...String(content).matchAll(PLAIN_SSH)].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 () => { @@ -36,12 +53,16 @@ export function createCredentialUsage({ home = process.env.HOME || '/home/t3', b 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 }); + for (const id of sshMentions(read.content)) if (!credentialMentions(read.content).has(id)) add(id, { kind: 'skill', name, where, sshOnly: true }); } 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' }); + else if (global.content) { + for (const id of credentialMentions(global.content)) add(id, { kind: 'instructions', name: 'Global instructions' }); + for (const id of sshMentions(global.content)) if (!credentialMentions(global.content).has(id)) add(id, { kind: 'instructions', name: 'Global instructions', sshOnly: true }); + } } catch { warnings.add('Global instructions could not be read.'); } return { usage, usageAt: new Date(clock()).toISOString(), usageWarnings: [...warnings] }; }; @@ -55,6 +76,6 @@ export function createCredentialUsage({ home = process.env.HOME || '/home/t3', b }; 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 }; + return { credentials: credentials.map(credential => ({ ...credential, usedIn: (value.usage.get(credential.id) || []).filter(item => !item.sshOnly || credential.type === 'ssh').map(({ sshOnly, ...item }) => item) })), usageAt: value.usageAt, usageWarnings: value.usageWarnings }; } }; } diff --git a/docker/setup/design/components.css b/docker/setup/design/components.css index f4dc375..e7c4aca 100644 --- a/docker/setup/design/components.css +++ b/docker/setup/design/components.css @@ -501,6 +501,7 @@ [data-theme="dark"] .tc-toast { border-color: var(--border); } .tc-toast > .tc-icon { color: var(--ok); } .tc-toast[data-tone="danger"] > .tc-icon { color: var(--danger); } +.tc-toast[data-tone="warn"] > .tc-icon { color: var(--warn); } .tc-toast[data-tone="info"] > .tc-icon { color: var(--info); } .tc-toast-text { display: flex; flex-direction: column; min-width: 0; } .tc-toast-text small { font-size: 0.75rem; color: var(--fg-muted); } diff --git a/docker/setup/design/ui.js b/docker/setup/design/ui.js index 5467e4c..0ea7efe 100644 --- a/docker/setup/design/ui.js +++ b/docker/setup/design/ui.js @@ -56,6 +56,11 @@ ICONS.folder = ''; ICONS.clock = ''; ICONS.sparkles = ''; + ICONS['cloud'] = ''; + ICONS['box'] = ''; + ICONS['package'] = ''; + ICONS['user-round'] = ''; + ICONS['arrow-left'] = ''; ICONS['file-text'] = ''; ICONS.plug = ''; diff --git a/docker/setup/server.mjs b/docker/setup/server.mjs index 045c953..652a7c9 100644 --- a/docker/setup/server.mjs +++ b/docker/setup/server.mjs @@ -15,6 +15,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto"; import { promisify } from "node:util"; import { gzip as gzipCallback } from "node:zlib"; import { readFileSync, writeFileSync, existsSync, rmSync, statSync } from "node:fs"; +import path from "node:path"; import vm from "node:vm"; import { withTimeout, @@ -40,8 +41,9 @@ import { autoInstallable, autoUpdateCandidate } from "./skills/update-review.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 { createCredentialStore, credentialFailure, CredentialError, credentialDirectory } from "./credentials.mjs"; import { createCredentialsService, CREDENTIAL_ROUTES } from "./credentials/service.mjs"; +import { installVaultSkill } from "./credentials/skill.mjs"; import { createCredentialUsage } from "./credentials/usage.mjs"; import { connectState, createConnectPreferences, jsonFrom, parseLinkOutput } from "./connect.mjs"; import { @@ -566,7 +568,7 @@ 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'], -}) }); +}), environmentId: async () => (await readFile(path.join(STATE_DIR, 'userdata/environment-id'), 'utf8')).trim() }); 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), @@ -3283,7 +3285,9 @@ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) { // 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.'); } +catch { console.warn('[setup] Saved credentials need attention; open Vault to inspect the store.'); } +try { await installVaultSkill({ home: CONTEXT_HOME, env: process.env, stateFile: path.join(credentialDirectory(), 'skill.json') }); } +catch { console.warn('[setup] The t3-vault skill could not be installed; agents can still run t3-vault directly.'); } 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 70557e8..b7cd371 100644 --- a/docker/t3-client/setup-bridge.js +++ b/docker/t3-client/setup-bridge.js @@ -146,7 +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: 'vault', route: 'vault', title: 'Vault', meta: 'Setup · credentials agents use by name', terms: ['credentials', 'ssh', 'ssh key', 'api key', 'token', 'password', 'secret', 'database', 'aws', 'kubeconfig'] }, { 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 deleted file mode 100644 index f5ba928..0000000 --- a/docs/credentials.md +++ /dev/null @@ -1,170 +0,0 @@ -# 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 deleted file mode 100644 index 2185c06..0000000 Binary files a/docs/media/credentials/api-details-light.png and /dev/null differ diff --git a/docs/media/credentials/api-editor-dark.png b/docs/media/credentials/api-editor-dark.png deleted file mode 100644 index 4a4f4d7..0000000 Binary files a/docs/media/credentials/api-editor-dark.png and /dev/null differ diff --git a/docs/media/credentials/delete-dark.png b/docs/media/credentials/delete-dark.png deleted file mode 100644 index 50e8749..0000000 Binary files a/docs/media/credentials/delete-dark.png and /dev/null differ diff --git a/docs/media/credentials/edit-conflict-light.png b/docs/media/credentials/edit-conflict-light.png deleted file mode 100644 index e0ddbc9..0000000 Binary files a/docs/media/credentials/edit-conflict-light.png and /dev/null differ diff --git a/docs/media/credentials/empty-light.png b/docs/media/credentials/empty-light.png deleted file mode 100644 index e5e89fe..0000000 Binary files a/docs/media/credentials/empty-light.png and /dev/null differ diff --git a/docs/media/credentials/host-key-light.png b/docs/media/credentials/host-key-light.png deleted file mode 100644 index 4e9ef21..0000000 Binary files a/docs/media/credentials/host-key-light.png and /dev/null differ diff --git a/docs/media/credentials/list-dark.png b/docs/media/credentials/list-dark.png deleted file mode 100644 index d450779..0000000 Binary files a/docs/media/credentials/list-dark.png and /dev/null differ diff --git a/docs/media/credentials/list-light.png b/docs/media/credentials/list-light.png deleted file mode 100644 index fc73fdb..0000000 Binary files a/docs/media/credentials/list-light.png and /dev/null differ diff --git a/docs/media/credentials/phone-details-light.png b/docs/media/credentials/phone-details-light.png deleted file mode 100644 index 9d07941..0000000 Binary files a/docs/media/credentials/phone-details-light.png and /dev/null differ diff --git a/docs/media/credentials/phone-editor-light.png b/docs/media/credentials/phone-editor-light.png deleted file mode 100644 index 836ac23..0000000 Binary files a/docs/media/credentials/phone-editor-light.png and /dev/null differ diff --git a/docs/media/credentials/phone-list-dark.png b/docs/media/credentials/phone-list-dark.png deleted file mode 100644 index e7ea016..0000000 Binary files a/docs/media/credentials/phone-list-dark.png and /dev/null differ diff --git a/docs/media/credentials/ssh-details-light.png b/docs/media/credentials/ssh-details-light.png deleted file mode 100644 index 273dee1..0000000 Binary files a/docs/media/credentials/ssh-details-light.png and /dev/null differ diff --git a/docs/media/credentials/ssh-editor-dark.png b/docs/media/credentials/ssh-editor-dark.png deleted file mode 100644 index 568ca76..0000000 Binary files a/docs/media/credentials/ssh-editor-dark.png and /dev/null differ diff --git a/docs/media/vault/add-light.png b/docs/media/vault/add-light.png new file mode 100644 index 0000000..d09c71f Binary files /dev/null and b/docs/media/vault/add-light.png differ diff --git a/docs/media/vault/list-light.png b/docs/media/vault/list-light.png new file mode 100644 index 0000000..48f1bc3 Binary files /dev/null and b/docs/media/vault/list-light.png differ diff --git a/docs/vault.md b/docs/vault.md new file mode 100644 index 0000000..b500719 --- /dev/null +++ b/docs/vault.md @@ -0,0 +1,204 @@ +# Vault + +The Vault saves passwords, keys, tokens and other credentials on this server, so +every agent can use them by name without the secrets going into chats or skill +files. You can add them in **Setup → Vault**, or an agent can ask you for one in +chat and save it there. + +![The Vault page, listing credentials of several types with who added each one and when it was last used](media/vault/list-light.png) + +## In a chat + +Agents check the Vault before asking you for a secret. If what they need isn't +there, they ask with T3 Code's secret card and save what you enter. The value +stays out of the chat, and no agent has to ask for it again, in any thread: + +```text +You: Deploy this to Cloudflare +Agent: t3-vault list (nothing for Cloudflare) + request_secret card: "I'll save it in your Vault as cloudflare-token" +You: paste the token +Agent: t3-vault add cloudflare-token --env CLOUDFLARE_API_TOKEN --secret-file + t3-vault run cloudflare-token -- wrangler deploy +``` + +If a token gets rejected later, the agent asks again and replaces it, so you can +fix it from the chat. If you only want a secret used once, say so and the agent +won't save it. + +The card has a single-line box. Tokens, passwords, SSH keys and JSON key files +fit. Kubeconfigs and `.env` files don't, so agents ask you to paste those in +**Setup → Vault**. + +Agents learn about the Vault from a skill called `t3-vault`, installed for every +agent when Setup starts. It behaves like any other skill: your edits stay, a +removed copy stays removed, and only an untouched copy gets updated by a new +image. + +## In Setup + +Choose **Add credential…**, then paste a key, token, connection URL or config +file, or drop a file. Setup works out what it is and fills in the form. You can +also pick a type yourself: + +| Type | Saved | What a command gets | +| --- | --- | --- | +| SSH server | Host, user, and a key or password | Plain `ssh NAME`, `scp` and `git` with a key, and `t3-vault ssh NAME` with either | +| API token | Token, and optionally an API address | A variable you name, and `t3-vault http` with an address | +| Username and password | Both, and an address if you like | `NAME_USERNAME`, `NAME_PASSWORD` | +| Database | Host, user, password, database | `DATABASE_URL`, and `PG*` or `MYSQL_*` variables | +| AWS | Access key ID, secret key, session token, region | The standard `AWS_*` variables | +| Google Cloud | Service account key file | `GOOGLE_APPLICATION_CREDENTIALS`, a temporary file | +| Kubernetes | Kubeconfig | `KUBECONFIG`, a temporary file | +| Git host | Token, host | `GIT_TOKEN` or a name you choose, and `git` over HTTPS to that host | +| Package registry | Token, username | Variables you name | +| File | Any text file | A variable holding the path of a temporary file | +| Custom | Any variables | Each one under its own name | + +Names use up to 64 lowercase letters, numbers and single hyphens. The name and +type can't change after creation, because skills depend on them. + +**Test** works before saving where a test is possible, which means SSH servers +and anything with an API address. A draft test doesn't save anything. For an API, +choose a relative test path such as `account` that needs authentication: a public +endpoint answers even with a bad key. Tests only run when someone asks for them, +and report the HTTP status without showing the response. + +![Adding a credential: a box to paste into, and a grid of types](media/vault/add-light.png) + +## SSH servers + +Choose how agents sign in: + +- **Generate a key.** Made on this server, so there's nothing to paste. You add + one line to the remote server, shown in the form. +- **Use my password once.** Signs in with your password to add a new key, checks + that the key works, and doesn't save the password. +- **Use an existing key.** Paste or upload it. A passphrase is used once to open + the key and isn't kept, because agents couldn't answer a prompt for it. +- **Keep using a password.** For servers that don't allow keys. + +A saved password can also move to a key later, with **Switch to a key…**. + +Saved servers work with the normal tools. The Vault keeps one generated file and +adds a single `Include` line at the top of `~/.ssh/config`, so `ssh prod uptime`, +`scp app.tar prod:/srv/` and `git clone prod:repo.git` just work. Everything else +in that file stays as you left it. If the line can't be added, for example because +that file is a link, use `t3-vault ssh prod -- uptime` instead. + +Plain `ssh` can't answer a password prompt, so a server that signs in with a password +uses `t3-vault ssh prod -- uptime`, and `t3-vault run prod -- scp app.tar prod:/srv/` +for other tools. + +### Trusting a server + +Each credential pins one server key, and a different key later blocks it: + +- **Key sign-in** pins the key the first time the server is reached. An impostor + can't get your private key this way, but commands would still run on it, so + check the fingerprint if that matters to you. +- **Password sign-in** waits for you to confirm the fingerprint, because sending a + password to an impostor would give it away. Compare it with the server's own, + using the command shown, then choose **Trust this host key**. + +If a key changes, **Review…** shows both fingerprints, and **Keep blocking** is the +safe default. A reinstall may explain a new key, but check through a trusted +channel before choosing **Trust new key**. Changing a credential's host or port +forgets its pin, and neither trust nor testing changes your `~/.ssh/known_hosts`. + +## From a terminal or a skill + +```sh +t3-vault list # What's saved, and how to use each one +t3-vault show NAME # Details of one credential +t3-vault run NAME... -- COMMAND [ARG...] # Run a command with those secrets in its environment +t3-vault ssh NAME [-- COMMAND [ARG...]] # Run a command on a saved SSH server +t3-vault http NAME METHOD PATH [--stdin] # Call a saved API +t3-vault add NAME [--type TYPE] [OPTIONS] # Save a credential (see t3-vault types) +t3-vault replace NAME --secret-file FILE # Replace its secret +t3-vault test NAME # Check that it still works +t3-vault trust NAME [FINGERPRINT] # Review or confirm a server's identity +t3-vault use-key NAME # Move a password sign-in to a new key +t3-vault rm NAME --yes # Delete it +``` + +`run` hands the secrets to that one command. `NAME=VAR` gives the main secret a +different variable name, for tools that expect one. Files such as kubeconfigs +become temporary files that are removed when the command ends. + +`t3-vault http` accepts GET, HEAD, POST, PUT, PATCH and DELETE, with paths relative +to the saved address. `--stdin` sends a JSON body of up to 1 MiB. Requests stay +under the address, verify HTTPS certificates and never follow redirects. + +No command prints a saved secret, because whatever an agent prints ends up in its +transcript. Secrets can't go on the command line either, for the same reason. +They come from a file, stdin or a hidden prompt at a terminal. + +To use a credential from a skill, open it and choose **Copy snippet**, or adapt the +[example skills](../examples/skills). If an old login appeared in a chat or a +skill's history, rotate it: removing the text doesn't remove earlier copies. + +## What's protected, and what isn't + +Secrets are saved in **plain text** on the state volume, in an owner-only folder. +Agents run as the same user, so **any agent can read them** if it sets out to. The +Vault keeps secrets out of chats, skill files and command lines. It doesn't lock +them away from a misbehaving agent, or from text that tricks one. + +Output from `run`, `ssh` and `http` hides saved values in their literal, +JSON-escaped, URL-encoded and Base64 forms, and long lines of a key or file. That +catches accidents, such as a tool repeating its own token. It can't stop a command +that sets out to reveal one, such as `t3-vault run x -- sh -c 'echo "$TOKEN" | rev'`. + +Protect the volume and its backups. Deleting a credential erases it from this +server, but older backups still hold it, and it keeps working at the service until +you revoke it there. + +## Understand a saved credential + +The list shows each credential's type, target, last test, who added it and when it +was last used. Plain `ssh NAME` counts as a use. `scp` and `sftp` turn off the +OpenSSH feature this relies on, so they don't. Failed tests and untrusted servers +appear in **Needs attention**, Overview and Setup's count in T3 Code. A single +timeout doesn't count; two in a row do. + +**Used in** scans global skills for all seven providers and shared `AGENTS.md`. It +looks for `t3-vault` commands and `ssh NAME`, and doesn't follow shell variables, +run files or scan project-local skills. If a folder is unreadable or a scan limit +is reached, the page says usage is incomplete rather than claiming something is +unused. + +## Edit or delete + +Saved secrets are write-only. **Replace…** takes a new value, and **Keep saved** or +an empty replacement keeps the current one. Show/hide only reveals a value you just +typed. Closing the editor clears it. Unsaved changes ask before they're discarded. + +If another window or an agent changes the same credential while you're editing, the +editor shows what changed. **Use theirs** adopts the current details, **Keep mine** +retries your draft, and a credential deleted elsewhere can be **Saved as new**. + +## Storage and recovery + +Everything lives in `$T3CODE_HOME/server-credentials/`, normally +`/home/t3/.t3/server-credentials/`. The folder is owner-only (0700), and so are its +files (0600). + +| File | Purpose | +| --- | --- | +| `credentials.json` | Saved credentials, including their secrets in plain text | +| `state.json` | Disposable test results and host key details, without secrets | +| `known_hosts/` | The one pinned key for that SSH credential | +| `keys/`, `ssh_config` | Generated for OpenSSH from `credentials.json` | +| `usage/.json` | Disposable record of when it was last used | +| `skill.json` | Which version of the `t3-vault` skill was last installed | + +Older stores are upgraded automatically when Setup starts, and a failed upgrade +leaves the original untouched. `state.json`, `usage/` and the generated files can be +lost without losing a credential. An unreadable `credentials.json` shows an error +and stops writes rather than being treated as empty: restore it privately from a +backup, with its ownership and permissions. Don't paste it into an issue or a chat. + +The page's API uses Setup's authentication and same-origin protections, and saved +secrets never appear in its responses, in `/status` or in browser storage. Serve +Setup over HTTPS or a trusted private connection. diff --git a/examples/skills/server-ssh/SKILL.md b/examples/skills/server-ssh/SKILL.md index cd777e0..6a1b458 100644 --- a/examples/skills/server-ssh/SKILL.md +++ b/examples/skills/server-ssh/SKILL.md @@ -3,14 +3,15 @@ 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: +Run commands on `build-server` with plain ssh. Its address, user and key are saved +in the Vault: ```sh -t3-ssh build-server -- '' +ssh build-server '' +scp app.tar build-server:/srv/ ``` -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. +Change the name in this example to match your server. If sign-in fails, run +`t3-vault test build-server` and follow what it says. If the server's key has +changed, stop and show the user what `t3-vault trust build-server` prints. Never +get past it with `StrictHostKeyChecking=no`. Don't read or print credential files. diff --git a/examples/skills/service-api/SKILL.md b/examples/skills/service-api/SKILL.md index 0ad5d7b..80a10bf 100644 --- a/examples/skills/service-api/SKILL.md +++ b/examples/skills/service-api/SKILL.md @@ -1,16 +1,16 @@ --- name: service-api -description: Use the saved project-api connection when asked to work with that service's API. +description: Use the saved project-api credential when asked to work with that service's API. --- -Use the saved `project-api` connection for HTTP API requests: +Call the API through the Vault, which adds the saved key: ```sh -t3-api project-api GET projects -printf '%s' '{"name":"demo-project"}' | t3-api project-api POST projects --stdin +t3-vault http project-api GET projects +printf '%s' '{"name":"demo-project"}' | t3-vault http 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. +Change the name and paths in this example to match your service. Paths are relative +to the saved API address, without a leading slash. For a tool that reads its token +from the environment, use `t3-vault run project-api -- `. Don't read or +print credential files. diff --git a/scripts/credentials-smoke.mjs b/scripts/credentials-smoke.mjs index 0b5e2fe..968eed5 100644 --- a/scripts/credentials-smoke.mjs +++ b/scripts/credentials-smoke.mjs @@ -24,15 +24,18 @@ 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/); + assert.match((await run('/usr/local/bin/t3-vault', ['--help'])).stdout, /Setup → Vault/); + assert.match(await readFile('/opt/t3-setup/credentials/SKILL.md', 'utf8'), /^---\nname: t3-vault\n/); 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 } }); + await request('/credentials/create', { credential: { id, type: 'token', fields: { baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/', authType: 'header', authName: 'X-API-Key', testPath: 'projects' }, secrets: { token: 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']); + const { stdout, stderr } = await run('/usr/local/bin/t3-vault', ['http', 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'); + const viaRun = await run('/usr/local/bin/t3-vault', ['run', id, '--', 'sh', '-c', 'printf "%s" "$' + id.toUpperCase().replace(/-/g, '_') + '_TOKEN"']); + assert.equal(viaRun.stdout, '[redacted]'); + console.log('PASS saved API credentials and the installed t3-vault'); } 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 index 65ed63f..dcc0409 100644 --- a/scripts/credentials-ui-audit.mjs +++ b/scripts/credentials-ui-audit.mjs @@ -1,8 +1,10 @@ #!/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. +// Real Setup API, private disposable home and loopback API. SSH scans, SSH draft +// tests and host trust are intercepted, every address saved here is loopback, and +// a guard fails the run if the page reaches for anything else. import assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { once } from 'node:events'; import { createServer } from 'node:http'; @@ -16,42 +18,73 @@ try { ({ chromium } = require('playwright')); } catch { ({ chromium } = require( 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 }); + +// The real server looks up releases and provider lists in the background. Those +// lookups meet a closed loopback port here, not the internet. +const nowhere = 'http://127.0.0.1:9', direct = '127.0.0.1,localhost'; +Object.assign(process.env, { HTTP_PROXY: nowhere, HTTPS_PROXY: nowhere, ALL_PROXY: nowhere, http_proxy: nowhere, https_proxy: nowhere, all_proxy: nowhere, NO_PROXY: direct, no_proxy: direct, NODE_USE_ENV_PROXY: '1' }); + +// Nothing here may touch the person's own home, whatever $HOME says. +const outsideFiles = ['.ssh/config', '.claude/skills/t3-vault/SKILL.md', '.agents/skills/t3-vault/SKILL.md', '.gemini/config/skills/t3-vault/SKILL.md'].map(file => path.join(os.userInfo().homedir, file)); +const outside = () => Promise.all(outsideFiles.map(file => fs.readFile(file, 'utf8').catch(() => null))); +const outsideBefore = await outside(); + 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'; +assert.ok(path.resolve(f.home).startsWith(path.resolve(os.tmpdir()) + path.sep), 'The audit home must be a temporary folder.'); +// The store's Include line goes under `home`, so it is always given explicitly. +let clock = Date.now(); const store = createCredentialStore({ directory, home: f.home, clock: () => clock }); +const secret = 'fixture-browser-password-123', token = 'fixture-browser-token-456', replacement = 'fixture-replacement-private-789', external = 'external-replacement-synthetic'; +const pasted = 'ghp_' + 'fixturetoken'.repeat(4), customSecret = 'fixture-custom-secret-321', kubeconfig = 'fixture-kubeconfig-654', passphrase = 'fixture-passphrase-987', typedKey = 'fixture-existing-key-210', onceSecret = 'fixture-once-password-543', saveToken = 'fixture-saved-token-876'; 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; +let checks = 0, openPage = null; const check = (condition, message) => { assert.ok(condition, message); checks++; }; +const same = (actual, expected, message) => { assert.deepEqual(actual, expected, 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)); +const sshPort = 1; // Nothing listens here, so a real first-use scan stops at "unreachable". +const isLoopback = value => /^(?:127\.\d+\.\d+\.\d+|localhost|\[::1\]|::1)$/.test(value); +// A password SSH row for the draft and conflict checks, so the seven fixture rows keep their states. +const editRow = { id: 'deploy-box', type: 'ssh', description: '', fields: { host: '127.0.0.30', port: sshPort, username: 'deploy', auth: 'password' }, env: {}, secretNames: ['password'], hostKey: { firstUse: false }, test: { ok: true, code: 'ok', ms: 120, detail: { user: 'deploy' } }, usage: null, origin: { by: 'setup' } }; + +// The preview fixture's rows, with every address moved to loopback. +const localised = (profile, index) => { + const fields = { ...profile.fields }; + if (fields.host) fields.host = '127.0.0.' + (10 + index); + if (profile.type === 'ssh') fields.port = sshPort; + if (fields.baseUrl) fields.baseUrl = localApi; + return fields; +}; 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) { + for (const [index, profile] of [...fixture('credentials.json').credentials, editRow].entries()) { 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 }); + const secrets = Object.fromEntries(profile.secretNames.map(name => [name, ['password', 'privateKey'].includes(name) ? secret : token])); + const saved = (await store.save({ create: true, origin: profile.origin, credential: { id: profile.id, type: profile.type, description: profile.description, fields: profile === editRow ? profile.fields : localised(profile, index), env: profile.env, secrets } })).credential; + if (profile.hostKey) await store.trust({ id: profile.id, revision: saved.revision, key, firstUse: profile.hostKey.firstUse === true }); if (profile.test) { - clock = Date.now() - (profile.id === 'build-server' ? 120000 : profile.id === 'home-assistant' ? 86400000 : 3600000); + clock = Date.now() - (profile.id === 'build-server' ? 120000 : profile.id === 'github' ? 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 }); + await store.recordTest({ id: profile.id, revision: saved.revision, hostFingerprint: profile.hostKey ? key.fingerprint : undefined, result }); } + if (profile.usage) { clock = Date.parse(profile.usage.at); await store.usage.record(profile.id, { command: profile.usage.command || 'ssh', env: {} }); } } clock = Date.now(); return { key, next }; } -async function geometry(target, width, height, label) { +async function geometry(page, 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'); + if (document.documentElement.scrollWidth > innerWidth + 1) errors.push('document overflows: ' + document.documentElement.scrollWidth + ' > ' + innerWidth); + if (document.body.scrollWidth > innerWidth + 1) errors.push('body overflows: ' + document.body.scrollWidth + ' > ' + innerWidth); 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'); + if (panel.scrollWidth > panel.clientWidth + 2) errors.push('panel content overflows: ' + panel.scrollWidth + ' > ' + panel.clientWidth); } for (const row of document.querySelectorAll('.tc-cred-row')) { const parts = [...row.children].map(rect).filter(r => r.width > 0 && r.height > 0); @@ -59,112 +92,227 @@ async function geometry(target, width, height, label) { } return errors; }, { width, height }); - assert.deepEqual(problems, [], label); checks++; + same(problems, [], label); + check(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'outer page does not overflow: ' + label); } +// The old helpers and the old page name are gone from everything a person can read or hear. +async function naming(target, label) { + const found = await target.evaluate(() => { + const clone = document.documentElement.cloneNode(true); + for (const skip of clone.querySelectorAll('script,style')) skip.remove(); + const attributes = [...document.querySelectorAll('[aria-label],[title],[placeholder],[alt],[href]')].flatMap(el => ['aria-label', 'title', 'placeholder', 'alt', 'href'].map(name => el.getAttribute(name)).filter(Boolean)); + const words = [clone.textContent, document.documentElement.innerText || '', ...attributes].join('\n'); + const titles = [document.title, ...[...document.querySelectorAll('h1,h2,h3,.tc-page-title,.tc-sheet-title,.tc-dialog-title,nav a,nav button,[aria-current=page],[role=menuitemradio],[role=option]')].map(el => (el.textContent || '').trim())]; + return { commands: words.match(/\bt3-(?:ssh|api)\b/g) || [], titles: titles.filter(text => /^credentials(?:$|\s*[·|—-])/i.test(text)), links: attributes.filter(value => /^#\/?credentials/.test(value)) }; + }); + same(found, { commands: [], titles: [], links: [] }, 'old names gone: ' + label); +} + try { + same((await (await f.request('/credentials')).json()).types, fixture('credentials.json').types, 'preview fixture describes the same types as the real API'); + const types = fixture('credentials.json').types; 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 = []; + const keys = await seed(), phone = width < (mode === 'tab' ? 640 : 1024); + const page = openPage = await browser.newPage({ viewport: { width, height }, colorScheme: theme, reducedMotion: 'reduce' }); + const pageErrors = [], responseBodies = [], unexpected = []; 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(() => '')); }); + page.on('response', response => { const route = new URL(response.url()).pathname; if (/\/credentials(?:\/|$)/.test(route)) responseBodies.push(response.text().catch(() => '').then(body => ({ route, body }))); }); 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; } } })); + // Lets a case hold one status poll, so it can land exactly when a link changes the address. + await page.addInitScript(() => { + const real = window.fetch; + window.fetch = (input, init) => { + const answer = real(input, init); + if (!window.__holdStatus || !/\/status(\?|$)/.test(String(input?.url || input))) return answer; + window.__holdStatus = false; + return new Promise((resolve, reject) => answer.then(r => r.json().then(data => { window.__releaseStatus = () => resolve({ ok: r.ok, status: r.status, headers: r.headers, json: async () => data, text: async () => JSON.stringify(data) }); }), reject)); + }; + }); await page.route('**/*', async route => { - const url = new URL(route.request().url()); + const request = route.request(), url = new URL(request.url()); + if (!isLoopback(url.hostname)) { unexpected.push('browser request to ' + url.href); return route.abort(); } 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') }); + // Each case below intercepts what it needs. Anything left would reach a real host. + const refuse = what => { unexpected.push(what); return route.fulfill({ status: 500, json: { ok: false, error: 'The audit refused this: ' + what } }); }; + if (/\/credentials\/(?:scan|trust|install-key|forget-host-key)$/.test(url.pathname)) return refuse('unintercepted ' + url.pathname); + if (/\/credentials\/(?:test|create|save)$/.test(url.pathname)) { + const body = request.postDataJSON(), draft = body.draft || body.credential; + if (body.install) return refuse('a password install that was not intercepted'); + if (url.pathname.endsWith('/test')) { + const saved = !draft && await store.public(body.id).catch(() => null), type = draft?.type || saved?.type, fields = draft?.fields || saved?.fields || {}; + if (type === 'ssh') return refuse('an SSH test that was not intercepted'); + if (fields.baseUrl && !isLoopback(new URL(fields.baseUrl).hostname)) return refuse('a test of ' + fields.baseUrl); + } else if (draft?.type === 'ssh' && draft.fields?.auth === 'key' && !isLoopback(draft.fields.host)) return refuse('a first-use scan of ' + draft.fields.host); + } return route.continue(); }); - const embedded = mode !== 'standalone', url = f.base + '/' + (embedded ? '?embed=t3&theme=' + theme + (mode === 'tab' ? '&host=tab&layout=wide' : '') : '') + '#credentials'; + const embedded = mode !== 'standalone', url = f.base + '/' + (embedded ? '?embed=t3&theme=' + theme + (mode === 'tab' ? '&host=tab&layout=wide' : '') : '') + '#vault'; 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 fits = label => geometry(page, target, width, height, label); + const rowCount = () => target.locator('.tc-cred-row,.tc-cred-prow').count(); + const row = id => target.locator('[data-cred-action="inspect"][data-id="' + id + '"]'); + const refresh = () => target.evaluate(() => window.dispatchEvent(new Event('focus'))); + const copied = () => target.evaluate(() => window.__credentialCopy); + const markup = () => target.content(); + const pageText = () => target.evaluate(() => document.documentElement.innerText); + const go = hash => target.evaluate(value => { location.hash = value; }, hash); + const closeDetails = async () => { await target.locator('.tc-cred-inspector').getByRole('button', { name: 'Close credential details' }).click(); await target.locator('.tc-cred-inspector').waitFor({ state: 'hidden' }); }; + const addDialog = async () => { + await target.getByRole('button', { name: 'Add credential', exact: true }).click(); + const editor = target.locator('.tc-cred-editor'); await editor.locator('#cred-paste').waitFor(); return editor; + }; + const pick = async (editor, type) => { await editor.locator('[data-cred-editor="pick-type"][data-value="' + type + '"]').click(); await editor.locator('#cred-id').waitFor(); }; + const paste = (editor, text) => editor.locator('#cred-paste').evaluate((el, value) => { el.value = value; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste' })); }, text); + // Leaving the empty Name field shows its error, which moves these options: the click must still land. + const signIn = async (editor, choice) => { + await editor.locator('label.tc-cred-option', { has: target.locator('input[data-cred-editor="signin"][value="' + choice + '"]') }).click(); + check(await editor.locator('input[data-cred-editor="signin"][value="' + choice + '"]').isChecked(), 'a click on ' + choice + ' lands'); + }; + const discard = async editor => { + await editor.getByRole('button', { name: 'Close credential editor' }).click(); + const prompt = target.getByRole('alertdialog'); + await Promise.race([editor.waitFor({ state: 'hidden' }), prompt.waitFor()]); + if (await prompt.count()) await prompt.getByRole('button', { name: 'Discard changes', exact: true }).click(); + await editor.waitFor({ state: 'hidden' }); + }; + // A test's answer can't be below the dialog's fold, on any width. + const answerShows = async (editor, label) => { + const place = await editor.locator('.tc-cred-test-line').evaluate(el => { const r = el.getBoundingClientRect(), panel = document.querySelector('.tc-cred-editor'); return { top: r.top, bottom: r.bottom, above: panel.querySelector('.tc-wizard-head').getBoundingClientRect().bottom, actions: panel.querySelector('.tc-wizard-foot').getBoundingClientRect().top }; }); + check(place.top >= place.above - 1 && place.bottom <= place.actions + 1, label + ' is visible above the pinned actions: ' + JSON.stringify(place)); + }; const edit = async id => { - await target.locator('[data-cred-action="inspect"][data-id="' + id + '"]').click(); + await row(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'); + + await target.getByLabel('Setup key', { exact: true }).fill('credential-fixture-key'); await target.getByRole('button', { name: 'Unlock', exact: true }).click(); + await row('build-server').waitFor(); + await fits('list'); await capture('list'); + + // ---- The page is called Vault, and nothing mentions the old helpers. -------- + check((await target.locator('#page-vault .tc-page-title').textContent()).trim() === 'Vault', 'page title is Vault'); + check(await target.evaluate(() => document.body.dataset.consoleRoute === 'vault'), 'route is vault'); + // ---- A toast's mark matches what it says. ------------------------------------ + const toasts = await target.evaluate(() => { + const shown = ['ok', 'warn', 'info', 'danger'].map(tone => { + const handle = Kit.toast('Fixture ' + tone, tone === 'ok' ? {} : { tone }); + const node = [...document.querySelectorAll('.tc-toast')].at(-1), mark = node.querySelector('.tc-icon'); + const result = { tone, tag: node.dataset.tone, shape: mark.innerHTML, color: getComputedStyle(mark).color }; + handle?.close?.(); node.remove(); + return result; + }); + // A fresh probe for each colour with no transition, or it reads a colour mid-change. + const color = name => { const probe = document.createElement('span'); probe.style.cssText = 'transition:none;color:var(--' + name + ')'; document.querySelector('.tc-toaster, #toaster').append(probe); const value = getComputedStyle(probe).color; probe.remove(); return value; }; + const expected = { ok: color('ok'), warn: color('warn'), info: color('info'), danger: color('danger') }; + return { shown, expected }; + }); + for (const row of toasts.shown) check(row.color === toasts.expected[row.tone], 'a ' + row.tone + ' toast uses its own colour'); + check(new Set(toasts.shown.map(row => row.shape)).size === 4, 'each toast tone has its own icon'); + await naming(target, 'list'); + if (phone && !embedded) { await go('more'); await target.waitForFunction(() => document.body.dataset.consoleRoute === 'more'); await naming(target, 'phone More page'); } + await go('overview'); await target.waitForFunction(() => document.body.dataset.consoleRoute === 'overview'); + if (!embedded) { await page.keyboard.press('Control+k'); await target.locator('.tc-layer .tc-palette').waitFor(); await naming(target, 'command palette'); await page.keyboard.press('Escape'); await target.locator('.tc-layer .tc-palette').waitFor({ state: 'hidden' }); } + await go('vault'); await target.locator('#page-vault:not([hidden]) .tc-page-title').waitFor(); await row('build-server').waitFor(); + + // ---- Search, filters and details. ------------------------------------------- + const search = target.getByRole('searchbox', { name: 'Search credentials' }); + await search.fill('X-API-Key'); check(await rowCount() === 1, 'search matches secret kind'); await search.fill(''); + await target.getByRole('button', { name: 'Needs attention 2', exact: true }).click(); check(await rowCount() === 2, 'attention filter'); + await target.getByRole('button', { name: 'Needs attention 2', exact: true }).click(); + await row('project-api').click(); + const inspector = target.locator('.tc-cred-inspector'); await inspector.waitFor(); await fits('inspector'); await capture('inspect-api'); + check((await inspector.locator('.tc-cred-request').textContent()).includes('••••••••'), 'request has mask'); + const sections = async () => (await inspector.locator('.tc-insp-label').allInnerTexts()).map(text => text.split('\n')[0].trim()); + for (const name of ['Details', 'Use it in a skill', 'Used in', 'Activity']) check((await sections()).includes(name), 'inspector has ' + name); + await inspector.getByRole('button', { name: 'Copy snippet', exact: true }).click(); + check((await copied()).includes('t3-vault http project-api'), 'snippet uses name'); + await naming(target, 'API details'); + await closeDetails(); + await row('build-server').click(); await inspector.waitFor(); await capture('inspect-ssh'); await fits('SSH details'); + check((await sections()).includes('Add this key to a server'), 'a key sign-in offers its install line'); + await inspector.locator('[data-cred-inspect="copy-install"]').click(); + check((await copied()).includes('authorized_keys') && (await copied()).includes('t3-vault:build-server'), 'install line copies the public key'); + check(!(await inspector.innerText()).includes('Not trusted yet'), 'a pinned host is not offered for trust'); + await naming(target, 'SSH details'); + await closeDetails(); + + // ---- Secret replacement and concurrent edits. ------------------------------- + await edit('deploy-box'); 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 }); + const typed = editor.locator('#cred-secrets-password'); + check(await typed.inputValue() === '', 'replacement starts empty'); + await typed.fill(replacement); await editor.getByRole('button', { name: 'Show newly typed password' }).click(); + check(await typed.getAttribute('type') === 'text', 'reveal only typed password'); + check(!(await markup()).includes(replacement), 'typed secret absent from markup'); + await fits('editor'); await capture('edit-ssh'); + const current = await store.public('deploy-box'); + await store.save({ credential: { ...current, fields: { ...current.fields, 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'); + check(await typed.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 }); + check(await typed.inputValue() === replacement, 'use theirs keeps typed secret when saved secret unchanged'); + const newer = await store.public('deploy-box'); + await store.save({ credential: { ...newer, secrets: { password: external } }, 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'); + check(await typed.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 edit('deploy-box'); editor = target.locator('.tc-cred-editor'); + await editor.locator('#cred-fields-username').fill('draft-user'); + const latest = await store.public('deploy-box'); + await store.save({ credential: { ...latest, fields: { ...latest.fields, 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'); + check((await store.forUse('deploy-box')).fields.username === 'draft-user', 'Keep mine saves the draft against the reviewed revision'); + check((await store.forUse('deploy-box')).secrets.password === external, 'Keep mine without a replacement keeps the current saved secret'); + + // ---- The add dialog, a draft test and a repeated save. ---------------------- + editor = await addDialog(); await fits('add dialog first step'); await capture('add-start'); await naming(target, 'add dialog first step'); + check(await editor.locator('[data-cred-editor="pick-type"]').count() === types.length, 'every type has a button'); + check((await editor.locator('#cred-paste').evaluate(el => getComputedStyle(el).webkitTextSecurity)) === 'disc', 'paste box stays masked'); + await pick(editor, 'ssh'); + await editor.locator('#cred-id').fill('Fixture Login'); + check(await editor.locator('#cred-id').inputValue() === 'fixture-login', 'name normalized as typed'); + await editor.locator('#cred-fields-host').fill('127.0.0.31'); await editor.locator('#cred-fields-username').fill('deploy'); + await signIn(editor, 'password'); await editor.locator('#cred-secrets-password').fill(secret); + await editor.getByRole('button', { name: 'Choose another type', exact: true }).click(); await pick(editor, 'token'); + check(await editor.locator('#cred-id').inputValue() === 'fixture-login', 'type switch retains name'); check(await editor.locator('#cred-secrets-token').inputValue() === '', 'type switch clears password'); + await editor.locator('#cred-fields-baseUrl').fill(localApi); await editor.getByRole('button', { name: 'Header', exact: true }).click(); + await editor.locator('#cred-fields-authName').fill('X-API-Key'); await editor.locator('#cred-secrets-token').fill(token); + await editor.getByRole('button', { name: 'Query parameter', exact: true }).click(); check(await editor.locator('#cred-secrets-token').inputValue() === token, 'auth switch retains newly typed key'); + await editor.getByRole('button', { name: 'Header', exact: true }).click(); await editor.locator('#cred-fields-testPath').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)); - } + same(await store.list(), beforeTest, 'draft testing writes nothing'); + await capture('draft-tested'); await answerShows(editor, 'API test 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'); + check(await editor.locator('#cred-secrets-token').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 row('fixture-login').waitFor(); + check((await store.forUse('fixture-login')).secrets.token === 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.locator('#cred-fields-baseUrl').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(); + + // ---- Review a key that changes again while the person decides. No SSH auth. -- + await row('old-box').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; @@ -172,78 +320,346 @@ try { 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'); + const saved = await store.public('old-box'); if (trustCalls === 2) { - const current = (await store.save({ credential: { ...saved, host: '192.0.2.31' }, revision: saved.revision })).credential; + const current = (await store.save({ credential: { ...saved, fields: { ...saved.fields, host: '127.0.0.32' } }, 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(); + await 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 fits('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'); + const current = await store.public('old-box'); 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 closeDetails(); + await page.unroute('**/credentials/scan'); await page.unroute('**/credentials/trust'); + + // ---- A credential deleted elsewhere can be saved again. --------------------- 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); + await editor.getByRole('button', { name: 'Replace…', exact: true }).click(); await editor.locator('#cred-secrets-token').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'); + check((await store.forUse('fixture-login')).secrets.token === replacement, 'gone conflict can save as new with the typed key'); + + // ---- Failed reads. ----------------------------------------------------------- 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 page.route('**/credentials', failRead); await refresh(); 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'); + check(await rowCount() === 0, 'read failure does not expose stale actionable rows'); await capture('read-error'); await fits('read failure'); 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 row('fixture-login').waitFor(); + + // ---- Deletion focus. --------------------------------------------------------- + await row('fixture-login').click(); + await 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 row('fixture-login').waitFor({ state: 'hidden' }); + await fits('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. + + // ---- 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(); + const response = await route.fetch(), data = await response.json(), base = data.credentials.find(row => row.id === 'project-api'); 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' }] }); + data.credentials.push({ ...base, id: longId, description: 'A long description that keeps going. '.repeat(6).slice(0, 200), fields: { ...base.fields, 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 page.route('**/credentials', partialRead); await refresh(); 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)`); + await fits('long metadata list'); + await go('vault/' + longId); + await inspector.getByRole('heading', { name: longId, exact: true }).waitFor(); + check((await inspector.innerText()).includes('At least 2 sources'), 'partial usage does not claim completeness'); + await fits('long metadata inspector'); await capture('long-partial'); + await closeDetails(); + await page.unroute('**/credentials', partialRead); await refresh(); + await target.getByText('Some skills couldn’t be read. Usage may be incomplete.', { exact: true }).waitFor({ state: 'hidden' }); + + // ---- Every type draws its own form from the server's description. ------------ + for (const type of types) { + editor = await addDialog(); await pick(editor, type.id); + check((await editor.locator('.tc-wizard-head .tc-small').textContent()).trim() === type.label, type.id + ' form names its type'); + const defaults = Object.fromEntries(type.fields.filter(entry => entry.default !== undefined).map(entry => [entry.key, entry.default])); + const applies = entry => !entry.needs && (!entry.when || Object.entries(entry.when).every(([key, values]) => values.includes(defaults[key]))); + for (const entry of type.fields.filter(entry => entry.kind !== 'derived' && applies(entry) && !(type.id === 'ssh' && entry.key === 'auth'))) { + const control = editor.locator(entry.kind === 'select' ? '[data-cred-editor="select"][data-field="' + entry.key + '"]' : '#cred-fields-' + entry.key); + check(await control.first().isVisible(), type.id + ' shows ' + entry.key); + } + if (type.id === 'ssh') check(await editor.locator('input[data-cred-editor="signin"]').count() === 4, 'ssh offers four ways to sign in'); + else for (const entry of type.secrets.filter(applies)) check(await editor.locator('#cred-secrets-' + entry.key).isVisible(), type.id + ' asks for ' + entry.key); + for (const entry of [...type.fields, ...type.secrets].filter(entry => entry.editableEnv)) check(await editor.locator('#cred-env-' + entry.key).isVisible(), type.id + ' lets the variable name change for ' + entry.key); + if (type.dynamic) check(await editor.locator('[data-cred-field="vars.0.name"]').isVisible(), 'custom starts with one variable'); + await fits(type.id + ' form'); await capture('type-' + type.id); await naming(target, type.id + ' form'); + check(await editor.locator('#cred-editor-title').evaluate(el => el.getBoundingClientRect().height < parseFloat(getComputedStyle(el).lineHeight) * 1.5), 'the form title stays on one line'); + same(pageErrors, [], type.id + ' form has no page errors'); + await editor.getByRole('button', { name: 'Close credential editor' }).click(); + await editor.waitFor({ state: 'hidden' }); check(true, 'an untouched ' + type.id + ' form closes without asking'); + } + + // ---- The side panel's toggle does something wherever it shows. --------------- + editor = await addDialog(); await pick(editor, 'token'); + const railToggle = editor.locator('.tc-wizard-rail-toggle'), railWrap = editor.locator('.tc-wizard-rail-wrap'); + check(await railWrap.evaluate(el => el.open) === !phone, 'the side panel starts collapsed only on a phone'); + if (await railToggle.isVisible()) { + const start = await railWrap.evaluate(el => el.open); + // Focus starts in the Name field, so this press also leaves a field. A late repaint must not undo it. + const settled = () => railWrap.evaluate(el => new Promise(resolve => setTimeout(() => resolve(el.open), 100))); + await railToggle.click(); check(await settled() === !start, 'the side panel toggle changes it'); + await editor.locator('#cred-fields-baseUrl').fill(localApi); check(await settled() === !start, 'the side panel stays as it was left while typing'); + await railToggle.click(); check(await settled() === start, 'the side panel toggle changes it back'); + } else { + check(await editor.locator('.tc-wizard-rail').isVisible(), 'without a toggle the side panel is simply shown'); + } + await discard(editor); + + // ---- Saving a token and a custom credential, end to end. --------------------- + editor = await addDialog(); await pick(editor, 'token'); + await editor.locator('#cred-id').fill('fixture-token'); await editor.locator('#cred-secrets-token').fill(saveToken); + check(!(await markup()).includes(saveToken), 'typed token absent from markup'); + await editor.locator('[data-cred-editor="save"]').click(); await editor.waitFor({ state: 'hidden' }); await row('fixture-token').waitFor(); + check((await store.forUse('fixture-token')).secrets.token === saveToken, 'token saved through the real API'); + same((await store.public('fixture-token')).delivers, ['FIXTURE_TOKEN'], 'token reaches commands as NAME_TOKEN'); + check(!(await pageText()).includes(saveToken), 'saved token not on the page'); + await row('fixture-token').click(); await inspector.waitFor(); + check((await inspector.innerText()).includes('FIXTURE_TOKEN'), 'details show its variable'); + await closeDetails(); + + editor = await addDialog(); await pick(editor, 'custom'); await capture('add-custom'); + await editor.locator('#cred-id').fill('fixture-custom'); + await editor.locator('[data-cred-field="vars.0.name"]').fill('FIXTURE_SECRET'); await editor.locator('[data-cred-field="vars.0.value"]').fill(customSecret); + check(!(await markup()).includes(customSecret), 'typed custom secret absent from markup'); + await editor.getByRole('button', { name: 'Add variable', exact: true }).click(); + await editor.locator('[data-cred-field="vars.1.name"]').fill('FIXTURE_PLAIN'); await editor.locator('[data-cred-editor="var-secret"][data-index="1"]').click(); await editor.locator('[data-cred-field="vars.1.value"]').fill('plain-value'); + check(await editor.locator('[data-cred-field="vars.0.value"]').inputValue() === customSecret, 'adding a variable keeps the typed secret'); + await fits('custom form with values'); + await editor.locator('[data-cred-editor="save"]').click(); await editor.waitFor({ state: 'hidden' }); await row('fixture-custom').waitFor(); + const custom = await store.forUse('fixture-custom'); + check(custom.secrets.FIXTURE_SECRET === customSecret && custom.fields.FIXTURE_PLAIN === 'plain-value', 'custom credential saved through the real API'); + await row('fixture-custom').click(); await inspector.waitFor(); + const customDetails = await inspector.innerText(); + check(customDetails.includes('plain-value') && customDetails.includes('FIXTURE_SECRET') && !customDetails.includes(customSecret), 'details show the plain value and only the name of the secret'); + await closeDetails(); + + // ---- Paste detection fills the form, and the token stays out of sight. ------- + editor = await addDialog(); await paste(editor, pasted); + const detected = editor.locator('.tc-cred-detect'); await detected.waitFor(); + check((await detected.innerText()).includes('Looks like a GitHub token'), 'detection names what it found'); + check(await editor.locator('#cred-id').inputValue() === 'github-2', 'a detected name that is taken gets a free one'); + await detected.getByRole('button', { name: 'Change type', exact: true }).click(); + await editor.locator('#cred-paste').waitFor(); check(await editor.locator('#cred-paste').inputValue() === '', 'Change type does not put the secret back in view'); + await discard(editor); + const github = await store.public('github'); await store.remove({ id: github.id, revision: github.revision }); + await refresh(); await row('github').waitFor({ state: 'hidden' }); + editor = await addDialog(); await paste(editor, pasted); await detected.waitFor(); + check((await editor.locator('.tc-wizard-head .tc-small').textContent()).trim() === 'Git host', 'a GitHub token becomes the git type'); + check(await editor.locator('#cred-id').inputValue() === 'github', 'a GitHub token gets the name github'); + check(await editor.locator('#cred-env-token').inputValue() === 'GH_TOKEN', 'a GitHub token reaches commands as GH_TOKEN'); + check(await editor.locator('#cred-fields-host').inputValue() === 'github.com', 'detection fills the host'); + check(await editor.locator('#cred-secrets-token').inputValue() === pasted && await editor.locator('#cred-secrets-token').getAttribute('type') === 'password', 'the token lands in a hidden field'); + check(await editor.locator('#cred-paste').count() === 0, 'the paste box is gone'); + check((await editor.locator('.tc-cred-gives').textContent()).includes('GH_TOKEN'), 'the side panel names GH_TOKEN'); + check(!(await pageText()).includes(pasted) && !(await markup()).includes(pasted), 'the pasted token is not in the page text or markup'); + await fits('detected form'); await capture('add-detected'); await naming(target, 'detected form'); + await editor.locator('[data-cred-editor="save"]').click(); await editor.waitFor({ state: 'hidden' }); await row('github').waitFor(); + const savedGithub = await store.forUse('github'); + check(savedGithub.type === 'git' && savedGithub.secrets.token === pasted && savedGithub.env.token === 'GH_TOKEN', 'detected credential saved as detected'); + const listed = await (await f.request('/credentials')).text(); + check(listed.includes('"github"') && !listed.includes(pasted), 'the list response leaves the token out'); + check(!(await pageText()).includes(pasted) && !(await markup()).includes(pasted), 'the token is not on the page after saving'); + // A private key goes to the SSH form, with its own sign-in choice already made. + editor = await addDialog(); await paste(editor, '-----BEGIN OPENSSH PRIVATE KEY-----\n' + typedKey + '\n-----END OPENSSH PRIVATE KEY-----'); await detected.waitFor(); + check((await detected.innerText()).includes('an SSH private key'), 'detection recognizes a private key'); + check(await editor.locator('input[data-cred-editor="signin"][value="existing"]').isChecked(), 'a pasted key selects Use an existing key'); + await discard(editor); + + // ---- Deep links. ------------------------------------------------------------- + await go('vault/build-server'); await inspector.getByRole('heading', { name: 'build-server', exact: true }).waitFor(); + check(await target.evaluate(() => location.hash) === '#vault/build-server', 'details keep their address'); + await closeDetails(); check(await target.evaluate(() => location.hash) === '#vault', 'closing details restores the address'); + await page.route('**/credentials/scan', route => route.fulfill({ json: { ok: true, hostKey: { state: 'changed', trusted: { type: keys.key.type, fingerprint: keys.key.fingerprint }, presented } } })); + await store.recordTest({ id: 'old-box', revision: (await store.public('old-box')).revision, hostFingerprint: third.fingerprint, result: { ok: false, code: 'host-key-changed', ms: 5, detail: { trusted: presented, presented } } }); + await refresh(); await go('vault/old-box'); await trustDialog.getByRole('button', { name: 'Keep blocking', exact: true }).waitFor(); + check(await inspector.count() === 1, 'a changed key opens its review from a link'); + await trustDialog.getByRole('button', { name: 'Keep blocking', exact: true }).click(); await trustDialog.waitFor({ state: 'hidden' }); await closeDetails(); + await page.unroute('**/credentials/scan'); + await go('vault/no-such-credential'); await target.getByText('Credential not found', { exact: true }).first().waitFor(); check(await inspector.count() === 0, 'a missing name opens nothing'); + check(await target.locator('.tc-toast', { hasText: 'Credential not found' }).first().getAttribute('data-tone') !== 'ok', 'a missing name is not reported as a success'); + await go('vault'); + await go('vault/new'); await editor.locator('#cred-paste').waitFor(); check(true, 'vault/new opens the first step'); await discard(editor); + await go('vault/new/git'); await editor.locator('#cred-id').waitFor(); + check((await editor.locator('.tc-wizard-head .tc-small').textContent()).trim() === 'Git host' && await editor.locator('#cred-id').inputValue() === '', 'vault/new/ opens that form'); + await discard(editor); + await go('vault/new/not-a-type'); await editor.locator('#cred-paste').waitFor(); check(true, 'an unknown type falls back to the first step'); await discard(editor); + // A status poll that lands right after a link must not swallow it. + const linkDuringPoll = async hash => { + await target.evaluate(() => { window.__holdStatus = true; }); + await target.waitForFunction(() => window.__releaseStatus, null, { timeout: 40000 }); + await target.evaluate(value => { const release = window.__releaseStatus; window.__releaseStatus = null; location.hash = value; release(); }, hash); + }; + await linkDuringPoll('vault/new/git'); await editor.locator('#cred-id').waitFor(); + await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 300))); + check(await editor.isVisible() && await target.getByRole('alertdialog').count() === 0, 'a form link survives a status poll, without asking to discard anything'); + await discard(editor); + await linkDuringPoll('vault/build-server'); await inspector.getByRole('heading', { name: 'build-server', exact: true }).waitFor(); + await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 300))); + check(await inspector.isVisible(), 'a details link survives a status poll'); + await closeDetails(); + // The row exists, so the form says why it can't save rather than going quiet. + await go('vault/new/kubernetes/kube-staging'); await editor.locator('#cred-id').waitFor(); + check(await editor.locator('#cred-id').inputValue() === 'kube-staging', 'deep link fills the name'); + check((await editor.locator('.tc-wizard-head .tc-small').textContent()).trim() === 'Kubernetes', 'deep link picks the type'); + check(await target.evaluate(() => location.hash) === '#vault', 'a form link does not stay in the address'); + await editor.getByText('kube-staging is taken.', { exact: true }).waitFor(); check(await editor.locator('[data-cred-editor="save"]').isDisabled(), 'a taken name from a link cannot save and says why'); + await discard(editor); + const kube = await store.public('kube-staging'); await store.remove({ id: kube.id, revision: kube.revision }); + await refresh(); await row('kube-staging').waitFor({ state: 'hidden' }); + // T3 Code's Setup tab navigates by message rather than by address. + if (mode === 'tab') await page.evaluate(() => document.querySelector('iframe').contentWindow.postMessage({ source: 't3-code', type: 'navigate', route: 'vault/new/kubernetes/kube-staging' }, location.origin)); + else await go('vault/new/kubernetes/kube-staging'); + await editor.locator('#cred-id').waitFor(); + check(await editor.locator('#cred-id').inputValue() === 'kube-staging', 'a free name from a link is filled in' + (mode === 'tab' ? ' through the host' : '')); + check(await editor.locator('#cred-secrets-kubeconfig').evaluate(el => document.activeElement === el), 'a filled-in name moves focus to what is missing'); + check(await editor.locator('[data-cred-editor="save"]').isDisabled(), 'a form from a link still needs its secret'); + await fits('deep-linked form'); await capture('deep-link'); + await editor.locator('#cred-secrets-kubeconfig').fill(kubeconfig); + await editor.locator('[data-cred-editor="save"]').click(); await editor.waitFor({ state: 'hidden' }); await row('kube-staging').waitFor(); + check((await store.forUse('kube-staging')).secrets.kubeconfig === kubeconfig, 'a form from a link saves'); + + // ---- SSH sign-in choices. ---------------------------------------------------- + const hostKey = { state: 'new', presented: { type: third.type, fingerprint: third.fingerprint } }, tests = []; + const answerTest = async route => { + const body = route.request().postDataJSON(), { draft } = body; tests.push(body); + check(draft?.type === 'ssh' && isLoopback(draft.fields.host), 'SSH draft tests stay on loopback'); + const at = new Date().toISOString(), user = draft.fields.username; + if (draft.fields.auth === 'key' || body.trust?.fingerprint === third.fingerprint) return route.fulfill({ json: { ok: true, result: { ok: true, code: 'ok', ms: 130, detail: { user }, hostKey, at } } }); + return route.fulfill({ json: { ok: true, result: { ok: false, code: 'host-key-untrusted', ms: 40, detail: {}, hostKey, at } } }); + }; + await page.route('**/credentials/test', answerTest); + const sshDraft = async (editor, id) => { + await editor.locator('#cred-id').fill(id); await editor.locator('#cred-fields-host').fill('127.0.0.40'); + await editor.locator('#cred-fields-port').fill(String(sshPort)); await editor.locator('#cred-fields-username').fill('deploy'); + }; + editor = await addDialog(); await pick(editor, 'ssh'); + check(await editor.locator('input[data-cred-editor="signin"][value="generate"]').isChecked(), 'generate is the first choice'); + const install = editor.locator('.tc-cred-install code'); await install.waitFor(); + check(await editor.locator('.tc-cred-install').count() === 1, 'the install line appears once'); + const installLine = await install.textContent(); + check(installLine.includes('ssh-ed25519') && installLine.includes('authorized_keys'), 'generate shows an install line with the new public key'); + check(await install.isVisible() && await install.evaluate(el => !el.closest('details:not([open])')), 'the install line needs nothing opened'); + if (phone) { + check(!(await editor.locator('.tc-wizard-rail-wrap').evaluate(el => el.open)), 'the side panel stays collapsed on a phone'); + check(await editor.locator('.tc-wizard-main .tc-cred-install').count() === 1, 'a phone shows the install line in the form'); + await install.scrollIntoViewIfNeeded(); + const box = await install.evaluate(el => { const r = el.getBoundingClientRect(), foot = document.querySelector('.tc-cred-editor .tc-wizard-foot').getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right, height: r.height, limit: foot.top }; }); + check(box.height > 0 && box.top >= -1 && box.top < box.limit && box.left >= -1 && box.right <= width + 1, 'the install line is on screen on a phone: ' + JSON.stringify(box)); + } + await fits('generated key form'); await capture('ssh-generate'); await naming(target, 'generated key form'); + await sshDraft(editor, 'fixture-ssh'); + await editor.locator('[data-cred-editor="test"]').click(); + const identity = editor.locator('.tc-cred-hostkey--first'); await identity.waitFor(); + check((await identity.innerText()).includes('Pinned on first use'), 'a key draft says it pins on first use'); + check(await editor.locator('[data-cred-editor="trust"]').count() === 0, 'a key draft has no trust checkbox'); + check(tests.at(-1).generatedKeyId && !tests.at(-1).trust, 'a generated key test names its key and trusts nothing'); + await editor.getByText('Signed in as deploy', { exact: true }).waitFor(); await answerShows(editor, 'key test feedback'); + await fits('key draft tested'); await capture('ssh-key-tested'); + + await signIn(editor, 'password'); + await editor.locator('#cred-secrets-password').waitFor(); + check(await identity.count() === 0, 'changing how it signs in forgets the last test'); + await editor.locator('#cred-secrets-password').fill(secret); + await editor.locator('[data-cred-editor="test"]').click(); + await identity.waitFor(); + check((await identity.innerText()).includes('First connection to 127.0.0.40'), 'a password draft asks about the first connection'); + check(!(await identity.innerText()).includes('Pinned on first use'), 'a password is never pinned on first use'); + const trustBox = editor.locator('[data-cred-editor="trust"]'); + check(await trustBox.isVisible() && !(await trustBox.isChecked()), 'a password draft has an unticked trust checkbox'); + await fits('password draft tested'); await capture('ssh-password-untrusted'); + await trustBox.check(); await editor.getByText('Signed in as deploy', { exact: true }).waitFor(); await answerShows(editor, 'trusted test feedback'); + check(tests.at(-1).trust?.fingerprint === third.fingerprint, 'ticking it retests with that exact key'); + await capture('ssh-password-trusted'); + await discard(editor); + + // A password must not leave before someone has looked at who's receiving it. + let installs = 0; + const answerCreate = async route => { + const body = route.request().postDataJSON(); installs++; + check(body.install?.password === onceSecret && body.trust?.fingerprint === third.fingerprint && !body.credential.secrets.password, 'the one-time password goes only as an install, for the key that was reviewed'); + return route.fulfill({ status: 409, json: { ok: false, code: 'install-failed', error: 'The server couldn’t be reached.' } }); + }; + await page.route('**/credentials/create', answerCreate); + editor = await addDialog(); await pick(editor, 'ssh'); await signIn(editor, 'once'); await sshDraft(editor, 'fixture-once'); + await editor.locator('#cred-secrets-password').fill(onceSecret); + await editor.locator('[data-cred-editor="save"]').click(); + await editor.getByText('Check the server’s identity first, then save.', { exact: true }).waitFor(); + check(installs === 0, 'Save tests first and sends no password'); + await fits('one-time password needs review'); await capture('ssh-once'); + await trustBox.check(); await editor.getByText('Signed in as deploy', { exact: true }).waitFor(); + await editor.locator('[data-cred-editor="save"]').click(); await editor.getByText('The server couldn’t be reached.', { exact: true }).waitFor(); + check(installs === 1, 'after review, Save sends the password once'); + check(await editor.locator('#cred-secrets-password').inputValue() === onceSecret, 'a failed install keeps the draft'); + await discard(editor); + await page.unroute('**/credentials/create', answerCreate); + + // An existing key, with a passphrase that stays out of the markup. + const answerKey = route => route.fulfill({ status: 400, json: { ok: false, code: 'invalid', field: 'passphrase', error: 'This key needs its passphrase.' } }); + await page.route('**/credentials/create', answerKey); + editor = await addDialog(); await pick(editor, 'ssh'); await signIn(editor, 'existing'); await sshDraft(editor, 'fixture-existing'); + const keyBox = editor.locator('#cred-secrets-privateKey'); await keyBox.fill(typedKey); + check(await keyBox.evaluate(el => el.classList.contains('tc-cred-masked')) && !(await markup()).includes(typedKey), 'a pasted private key stays masked and out of the markup'); + await editor.locator('[data-cred-editor="save"]').click(); await editor.locator('#cred-passphrase').waitFor(); + await editor.locator('#cred-passphrase').fill(passphrase); + check(!(await markup()).includes(passphrase), 'a typed passphrase stays out of the markup'); + await fits('existing key with passphrase'); await capture('ssh-existing'); + await discard(editor); + await page.unroute('**/credentials/create', answerKey); + await page.unroute('**/credentials/test', answerTest); + + // A generated key, saved for real. Its first-use scan finds a closed loopback port. + editor = await addDialog(); await pick(editor, 'ssh'); await install.waitFor(); await sshDraft(editor, 'fixture-ssh'); + await editor.locator('[data-cred-editor="save"]').click(); await editor.waitFor({ state: 'hidden' }); + await inspector.getByRole('heading', { name: 'fixture-ssh', exact: true }).waitFor(); + const savedSsh = await store.forUse('fixture-ssh'); + check(savedSsh.fields.auth === 'key' && savedSsh.secrets.privateKey.includes('PRIVATE KEY') && savedSsh.fields.publicKey.startsWith('ssh-ed25519 '), 'a generated key saves its pair'); + check((await inspector.innerText()).includes('Add this key to a server') && !(await inspector.innerText()).includes('PRIVATE KEY'), 'details offer the install line and never the private key'); + await fits('saved generated key'); await capture('ssh-saved'); + await closeDetails(); + + const final = await markup(), neverSent = [secret, token, replacement, external, customSecret, kubeconfig, passphrase, onceSecret, saveToken], pastedValues = [pasted, typedKey]; + for (const value of [...neverSent, ...pastedValues]) check(!final.includes(value), 'no saved or typed secrets remain in markup'); + // The paste box answers with what was pasted, to sort it into the form. Nothing else may. + for (const { route, body } of await Promise.all(responseBodies)) for (const value of route.endsWith('/detect') ? neverSent : [...neverSent, ...pastedValues]) check(!body.includes(value), 'no credential response echoes a secret: ' + route); + same(pageErrors, [], 'no page errors'); + same(unexpected, [], 'nothing left loopback or reached the real SSH paths'); + await fits('end of the run'); + await page.close(); + console.log(`PASS vault ${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'); + check(!f.logs().includes(secret) && !f.logs().includes(token) && !f.logs().includes(pasted) && !f.logs().includes(customSecret), 'service logs do not expose fixture credentials'); + check((await fs.readFile(path.join(f.home, '.ssh/config'), 'utf8')).includes('Include'), 'the Include line went into the temporary home'); + same(await outside(), outsideBefore, 'the real home was not touched'); + console.log('PASS ' + checks + ' Vault browser checks'); +} catch (error) { + await openPage?.screenshot({ path: path.join(output, 'failure.png') }).catch(() => {}); + throw error; } 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 index 2b9ec05..c564f33 100644 --- a/scripts/credentials-visual-audit.mjs +++ b/scripts/credentials-visual-audit.mjs @@ -9,64 +9,102 @@ 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 isLoopback = host => ['127.0.0.1', 'localhost'].includes(host); +assert.ok(isLoopback(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 }); +// Only the preview answers these without a key, and only with the fixture's names. +const identity = await (await fetch(new URL('/.well-known/t3/environment', base))).json(); +assert.equal(identity.environmentId, 'fixture-preview', 'Run console-preview.mjs. This never runs against a real Setup.'); const fixture = await (await fetch(new URL('credentials', base))).json(); -assert.equal(fixture.credentials.length, 5, 'Use the five-row console fixture.'); +assert.deepEqual(fixture.credentials.map(row => row.id).sort(), ['app-db', 'build-server', 'cloudflare-token', 'github', 'kube-staging', 'old-box', 'project-api'], 'Use the seven-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']; +const cases = ['list', 'empty', 'inspect-ssh', 'inspect-api', 'inspect-rejected', 'add-start', 'add-detected', 'add-ssh', 'add-ssh-password', 'add-api', 'add-custom', 'conflict', 'host-key', 'delete']; +const MODES = { phone: { width: 390, height: 844, layout: 'compact', scale: 2 }, narrow: { width: 640, height: 800, layout: 'wide' }, tab: { width: 1040, height: 820, layout: 'wide' }, desktop: { width: 1280, height: 900, standalone: true } }; +const requestedModes = (process.env.T3_CREDENTIALS_VISUAL_MODES || Object.keys(MODES).join(',')).split(','); +const fakeToken = 'ghp_' + 'syntheticfixture'.repeat(3); +const DETAILS = { 'inspect-ssh': 'build-server', 'inspect-api': 'project-api', 'inspect-rejected': 'github', 'host-key': 'old-box', delete: 'project-api', conflict: 'build-server' }; +// Each add case hides the row it would otherwise collide with. +const HIDDEN = { 'add-ssh': 'build-server', 'add-ssh-password': 'old-box', 'add-api': 'project-api', 'add-detected': 'github' }; 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) { + for (const theme of themes) for (const mode of requestedModes) for (const state of 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)); + const { width, height, layout, scale = 1, standalone } = MODES[mode]; + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: scale, reducedMotion: 'reduce', colorScheme: theme }); + const errors = [], outside = []; 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('**/*', route => { const { hostname, href } = new URL(route.request().url()); if (isLoopback(hostname)) return route.fallback(); outside.push(href); return route.abort(); }); 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')); + const credentials = state === 'empty' ? [] : fixture.credentials.filter(row => row.id !== HIDDEN[state]); 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(); + const url = new URL(base); url.hash = 'vault'; + if (!standalone) url.search = 'embed=t3&host=tab&layout=' + layout + '&theme=' + theme; + const target = standalone ? (await page.goto(url.href), page) : await framed(page, url.href); + await target.locator('#page-vault:not([hidden]) .tc-page-title').waitFor(); + assert.equal((await target.locator('#page-vault .tc-page-title').textContent()).trim(), 'Vault'); 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(); - } + await target.locator('#page-vault .tc-page-title').click(); + if (DETAILS[state]) { await target.locator('[data-cred-action="inspect"][data-id="' + DETAILS[state] + '"]').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'); + await editor.locator('#cred-paste').waitFor(); + const pick = async type => { await editor.locator('[data-cred-editor="pick-type"][data-value="' + type + '"]').click(); await editor.locator('#cred-id').waitFor(); }; + const signIn = choice => editor.locator('label.tc-cred-option', { has: target.locator('input[data-cred-editor="signin"][value="' + choice + '"]') }).click(); + if (state === 'add-detected') { + await editor.locator('#cred-paste').evaluate((el, value) => { el.value = value; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste' })); }, fakeToken); + await editor.locator('.tc-cred-detect').waitFor(); + assert.equal(await editor.locator('#cred-id').inputValue(), 'github'); + } + if (state === 'add-ssh') { + await pick('ssh'); await editor.locator('#cred-id').fill('build-server'); + await editor.locator('#cred-fields-host').fill('127.0.0.10'); await editor.locator('#cred-fields-username').fill('deploy'); + await editor.locator('.tc-cred-install code').waitFor(); + await editor.locator('[data-cred-editor="test"]').click(); await editor.getByText('Pinned on first use. A different key later blocks it.', { exact: true }).waitFor(); + } + if (state === 'add-ssh-password') { + await pick('ssh'); await editor.locator('#cred-id').fill('old-box'); await signIn('password'); + await editor.locator('#cred-fields-host').fill('127.0.0.20'); await editor.locator('#cred-fields-username').fill('root'); await editor.locator('#cred-secrets-password').fill('synthetic-visual-fixture'); + await editor.locator('[data-cred-editor="test"]').click(); + await editor.getByLabel('Trust this host key', { exact: true }).check(); await editor.getByText('Signed in as root', { exact: true }).waitFor(); + } 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(); + await pick('token'); await editor.locator('#cred-id').fill('project-api'); await editor.locator('#cred-secrets-token').fill('synthetic-visual-fixture'); + await editor.locator('#cred-fields-baseUrl').fill('http://127.0.0.1:8787/v1/'); + await editor.getByRole('button', { name: 'Header', exact: true }).click(); await editor.locator('#cred-fields-authName').fill('X-API-Key'); await editor.locator('#cred-fields-testPath').fill('account'); + await editor.locator('[data-cred-editor="test"]').click(); await editor.getByText('Connected · HTTP 200', { exact: true }).waitFor(); + } + if (state === 'add-custom') { + await pick('custom'); await editor.locator('#cred-id').fill('deploy-env'); + await editor.locator('[data-cred-field="vars.0.name"]').fill('DEPLOY_TOKEN'); await editor.locator('[data-cred-field="vars.0.value"]').fill('synthetic-visual-fixture'); + await editor.getByRole('button', { name: 'Add variable', exact: true }).click(); + await editor.locator('[data-cred-field="vars.1.name"]').fill('DEPLOY_REGION'); await editor.locator('[data-cred-editor="var-secret"][data-index="1"]').click(); await editor.locator('[data-cred-field="vars.1.value"]').fill('eu-west-1'); + } } 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 details = target.locator('.tc-cred-inspector'), editor = target.locator('.tc-cred-editor'); + 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 editor.getByRole('button', { name: 'Replace…', exact: true }).click(); await editor.locator('#cred-secrets-privateKey').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 page.route('**/credentials/save', route => route.fulfill({ status: 409, json: { ok: false, code: 'changed', error: 'Changed elsewhere.', current: { ...original, fields: { ...original.fields, host: '127.0.0.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); + const geometry = await target.evaluate(() => ({ overflow: document.documentElement.scrollWidth > innerWidth + 1 || document.body.scrollWidth > innerWidth + 1, text: document.documentElement.innerText, 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, 'horizontal overflow: ' + state + ' at ' + mode); 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)); + assert.doesNotMatch(geometry.text, /\bt3-(?:ssh|api)\b/, 'old helper names: ' + state + ' at ' + mode); + assert.ok(!geometry.text.includes(fakeToken) && !geometry.text.includes('synthetic-visual-fixture') && !geometry.text.includes('synthetic-replacement'), 'a typed secret is on the page: ' + state + ' at ' + mode); + const file = path.join(output, `${theme}-${mode}-${state}.png`); + if (standalone) await page.screenshot({ path: file }); else await page.locator('iframe').screenshot({ path: file }); + captures++; + assert.deepEqual(errors, []); assert.deepEqual(outside, [], 'the preview must not reach outside loopback'); await page.close(); console.log('PASS visual ' + theme + ' ' + mode + ' ' + state); } - console.log('PASS ' + captures + ' aligned Credentials visual captures'); + console.log('PASS ' + captures + ' aligned Vault visual captures'); } finally { await browser.close(); } diff --git a/scripts/fixtures/console/credentials.json b/scripts/fixtures/console/credentials.json index 60a8303..0576ec1 100644 --- a/scripts/fixtures/console/credentials.json +++ b/scripts/fixtures/console/credentials.json @@ -1,163 +1,940 @@ { - "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" - } + "ok": true, + "usageAt": "2026-10-30T22:00:05Z", + "usageWarnings": [], + "sshAliases": true, + "environmentId": "fixture-environment", + "types": [ + { + "id": "ssh", + "label": "SSH server", + "badge": "SSH", + "icon": "terminal", + "order": 1, + "fields": [ + { + "key": "host", + "label": "Host", + "kind": "host", + "max": 253, + "message": "Enter a hostname or IP address, without a username or port.", + "placeholder": "192.0.2.10" + }, + { + "key": "port", + "label": "Port", + "kind": "number", + "default": 22, + "min": 1, + "max": 65535, + "message": "Use a port from 1 to 65535." + }, + { + "key": "username", + "label": "Username", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]*\\$?$", + "max": 64, + "message": "Enter a valid username.", + "placeholder": "deploy" + }, + { + "key": "auth", + "label": "Sign-in", + "kind": "select", + "default": "key", + "options": [ + [ + "key", + "Key" + ], + [ + "password", + "Password" + ] + ] + }, + { + "key": "publicKey", + "label": "Public key", + "kind": "derived", + "optional": true, + "max": 16384, + "when": { + "auth": [ + "key" + ] + } + } + ], + "secrets": [ + { + "key": "privateKey", + "label": "Private key", + "multiline": true, + "primary": true, + "when": { + "auth": [ + "key" + ] + }, + "flag": "key-file", + "placeholder": "Paste a private key" + }, + { + "key": "password", + "label": "Password", + "primary": true, + "when": { + "auth": [ + "password" + ] + }, + "flag": "password-file" + } + ] + }, + { + "id": "token", + "label": "API token", + "badge": "Token", + "icon": "globe", + "order": 2, + "secretFirst": true, + "fields": [ + { + "key": "baseUrl", + "label": "API address", + "kind": "url", + "optional": true, + "max": 2048, + "placeholder": "https://api.example.com/v1/", + "hint": "Lets agents call it with t3-vault http, and lets Setup test it." + }, + { + "key": "authType", + "label": "Authentication", + "kind": "select", + "default": "bearer", + "needs": "baseUrl", + "options": [ + [ + "bearer", + "Bearer token" + ], + [ + "header", + "Header" + ], + [ + "query", + "Query parameter" + ] + ] + }, + { + "key": "authName", + "label": "Header or parameter name", + "optional": true, + "max": 128, + "needs": "baseUrl", + "when": { + "authType": [ + "header", + "query" + ] + }, + "placeholder": "X-API-Key" + }, + { + "key": "authPrefix", + "label": "Value prefix", + "optional": true, + "max": 128, + "needs": "baseUrl", + "when": { + "authType": [ + "header" + ] + }, + "placeholder": "Token" + }, + { + "key": "testPath", + "label": "GET test path", + "optional": true, + "max": 2048, + "needs": "baseUrl", + "placeholder": "account", + "hint": "A relative path that needs authentication." + } + ], + "secrets": [ + { + "key": "token", + "label": "Token", + "primary": true, + "env": "{NAME}_TOKEN", + "editableEnv": true + } + ] + }, + { + "id": "login", + "label": "Username and password", + "badge": "Login", + "icon": "user-round", + "order": 3, + "fields": [ + { + "key": "username", + "label": "Username", + "max": 256, + "env": "{NAME}_USERNAME", + "editableEnv": true + }, + { + "key": "url", + "label": "Address", + "optional": true, + "max": 2048, + "placeholder": "https://example.com", + "env": "{NAME}_URL" + } + ], + "secrets": [ + { + "key": "password", + "label": "Password", + "primary": true, + "env": "{NAME}_PASSWORD", + "editableEnv": true + } + ] + }, + { + "id": "database", + "label": "Database", + "badge": "Database", + "icon": "database", + "order": 4, + "fields": [ + { + "key": "engine", + "label": "Engine", + "kind": "select", + "default": "postgres", + "options": [ + [ + "postgres", + "PostgreSQL" + ], + [ + "mysql", + "MySQL" + ], + [ + "mongodb", + "MongoDB" + ], + [ + "redis", + "Redis" + ] + ] + }, + { + "key": "host", + "label": "Host", + "kind": "host", + "max": 253, + "message": "Enter a hostname or IP address, without a username or port.", + "placeholder": "db.example.net" + }, + { + "key": "port", + "label": "Port", + "kind": "number", + "optional": true, + "min": 1, + "max": 65535, + "message": "Use a port from 1 to 65535." + }, + { + "key": "database", + "label": "Database", + "optional": true, + "max": 256 + }, + { + "key": "username", + "label": "Username", + "optional": true, + "max": 256 + }, + { + "key": "options", + "label": "Connection options", + "optional": true, + "max": 1024, + "placeholder": "sslmode=require" + }, + { + "key": "scheme", + "label": "URL scheme", + "kind": "derived", + "optional": true, + "max": 32, + "pattern": "^[a-z][a-z0-9+]*$", + "message": "Use a URL scheme, such as rediss." + } + ], + "secrets": [ + { + "key": "password", + "label": "Password", + "primary": true, + "optional": true + } + ] + }, + { + "id": "aws", + "label": "AWS", + "badge": "AWS", + "icon": "cloud", + "order": 5, + "fields": [ + { + "key": "accessKeyId", + "label": "Access key ID", + "max": 128, + "pattern": "^[A-Z0-9]+$", + "message": "Enter an access key ID, such as AKIA followed by letters and numbers.", + "env": "AWS_ACCESS_KEY_ID" + }, + { + "key": "region", + "label": "Region", + "optional": true, + "max": 64, + "placeholder": "us-east-1", + "env": "AWS_REGION" + } + ], + "secrets": [ + { + "key": "secretAccessKey", + "label": "Secret access key", + "primary": true, + "env": "AWS_SECRET_ACCESS_KEY" + }, + { + "key": "sessionToken", + "label": "Session token", + "optional": true, + "env": "AWS_SESSION_TOKEN" + } + ] + }, + { + "id": "gcp", + "label": "Google Cloud", + "badge": "Google Cloud", + "icon": "cloud", + "order": 6, + "fields": [ + { + "key": "project", + "label": "Project", + "optional": true, + "max": 128, + "env": "GOOGLE_CLOUD_PROJECT" + }, + { + "key": "clientEmail", + "label": "Service account", + "optional": true, + "max": 256 + } + ], + "secrets": [ + { + "key": "serviceAccount", + "label": "Service account key", + "multiline": true, + "primary": true, + "file": "service-account.json", + "env": "GOOGLE_APPLICATION_CREDENTIALS", + "placeholder": "Paste the JSON key file" + } + ] + }, + { + "id": "kubernetes", + "label": "Kubernetes", + "badge": "Kubernetes", + "icon": "box", + "order": 7, + "fields": [], + "secrets": [ + { + "key": "kubeconfig", + "label": "Kubeconfig", + "multiline": true, + "primary": true, + "file": "kubeconfig", + "env": "KUBECONFIG", + "placeholder": "Paste the kubeconfig file" + } + ] + }, + { + "id": "git", + "label": "Git host", + "badge": "Git", + "icon": "git-branch", + "order": 8, + "secretFirst": true, + "fields": [ + { + "key": "host", + "label": "Host", + "kind": "host", + "max": 253, + "message": "Enter a hostname or IP address, without a username or port.", + "default": "github.com", + "placeholder": "github.com", + "hint": "Commands run with this credential can use git over HTTPS to this host." + }, + { + "key": "username", + "label": "Username", + "optional": true, + "max": 256, + "placeholder": "x-access-token" + }, + { + "key": "baseUrl", + "label": "API address", + "kind": "url", + "optional": true, + "max": 2048, + "placeholder": "https://api.example.com/v1/", + "hint": "Lets agents call it with t3-vault http, and lets Setup test it." + }, + { + "key": "authType", + "label": "Authentication", + "kind": "select", + "default": "bearer", + "needs": "baseUrl", + "options": [ + [ + "bearer", + "Bearer token" + ], + [ + "header", + "Header" + ], + [ + "query", + "Query parameter" ] + ] }, { - "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" - } + "key": "authName", + "label": "Header or parameter name", + "optional": true, + "max": 128, + "needs": "baseUrl", + "when": { + "authType": [ + "header", + "query" ] + }, + "placeholder": "X-API-Key" }, { - "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" - } + "key": "authPrefix", + "label": "Value prefix", + "optional": true, + "max": 128, + "needs": "baseUrl", + "when": { + "authType": [ + "header" + ] + }, + "placeholder": "Token" + }, + { + "key": "testPath", + "label": "GET test path", + "optional": true, + "max": 2048, + "needs": "baseUrl", + "placeholder": "account", + "hint": "A relative path that needs authentication." + } + ], + "secrets": [ + { + "key": "token", + "label": "Token", + "primary": true, + "env": "GIT_TOKEN", + "editableEnv": true + } + ] + }, + { + "id": "registry", + "label": "Package registry", + "badge": "Registry", + "icon": "package", + "order": 9, + "secretFirst": true, + "fields": [ + { + "key": "username", + "label": "Username", + "optional": true, + "max": 256, + "env": "{NAME}_USERNAME" + }, + { + "key": "baseUrl", + "label": "API address", + "kind": "url", + "optional": true, + "max": 2048, + "placeholder": "https://api.example.com/v1/", + "hint": "Lets agents call it with t3-vault http, and lets Setup test it." + }, + { + "key": "authType", + "label": "Authentication", + "kind": "select", + "default": "bearer", + "needs": "baseUrl", + "options": [ + [ + "bearer", + "Bearer token" ], - "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" - } + [ + "header", + "Header" ], - "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": "" - } - ] + [ + "query", + "Query parameter" + ] + ] + }, + { + "key": "authName", + "label": "Header or parameter name", + "optional": true, + "max": 128, + "needs": "baseUrl", + "when": { + "authType": [ + "header", + "query" + ] + }, + "placeholder": "X-API-Key" + }, + { + "key": "authPrefix", + "label": "Value prefix", + "optional": true, + "max": 128, + "needs": "baseUrl", + "when": { + "authType": [ + "header" + ] + }, + "placeholder": "Token" + }, + { + "key": "testPath", + "label": "GET test path", + "optional": true, + "max": 2048, + "needs": "baseUrl", + "placeholder": "account", + "hint": "A relative path that needs authentication." + } + ], + "secrets": [ + { + "key": "token", + "label": "Token or password", + "primary": true, + "env": "{NAME}_TOKEN", + "editableEnv": true + } + ] + }, + { + "id": "file", + "label": "File", + "badge": "File", + "icon": "file-text", + "order": 10, + "secretFirst": true, + "fields": [ + { + "key": "filename", + "label": "File name", + "default": "credential", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "max": 128, + "message": "Use letters, numbers, dots, hyphens and underscores.", + "hint": "The temporary file gets this name, for tools that care." + } + ], + "secrets": [ + { + "key": "content", + "label": "Contents", + "multiline": true, + "primary": true, + "file": true, + "env": "{NAME}_FILE", + "editableEnv": true, + "placeholder": "Paste the file" + } + ] + }, + { + "id": "custom", + "label": "Custom", + "badge": "Custom", + "icon": "list", + "order": 11, + "dynamic": true, + "fields": [], + "secrets": [] + } + ], + "credentials": [ + { + "id": "app-db", + "type": "database", + "description": "", + "fields": { + "engine": "postgres", + "host": "db.example.net", + "database": "app", + "username": "app" + }, + "env": {}, + "delivers": [ + "DATABASE_URL", + "PGHOST", + "PGPORT", + "PGUSER", + "PGPASSWORD", + "PGDATABASE" + ], + "secretNames": [ + "password" + ], + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "5c59237a00000000", + "test": null, + "hostKey": null, + "usage": { + "at": "2026-10-27T10:00:00Z", + "count": 4, + "command": "psql" + }, + "usedIn": [], + "origin": { + "by": "setup" + } + }, + { + "id": "build-server", + "type": "ssh", + "description": "", + "fields": { + "host": "192.0.2.10", + "port": 22, + "username": "deploy", + "auth": "key", + "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMockPublicKeyForTheFixtureOnly0000000000000 t3-vault:build-server" + }, + "env": {}, + "delivers": [], + "secretNames": [ + "privateKey" + ], + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "45cf3f1300000000", + "test": { + "at": "2026-10-30T21:58:00Z", + "ok": true, + "code": "ok", + "ms": 1180, + "detail": { + "user": "deploy" + } + }, + "hostKey": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:p2QkL0n8sT4vYc1bZr7eWq9uKx3mHf6dJg2aNo5Xf7c", + "trustedAt": "2026-10-26T18:04:30Z", + "carried": false, + "firstUse": true + }, + "usage": { + "at": "2026-10-30T21:55:00Z", + "count": 41, + "viaSsh": true + }, + "usedIn": [ + { + "kind": "skill", + "name": "deploy", + "where": "Claude Code, Codex" + }, + { + "kind": "skill", + "name": "server-health", + "where": "All agents" + } + ], + "origin": { + "by": "setup" + } + }, + { + "id": "cloudflare-token", + "type": "token", + "description": "", + "fields": {}, + "env": { + "token": "CLOUDFLARE_API_TOKEN" + }, + "delivers": [ + "CLOUDFLARE_API_TOKEN" + ], + "secretNames": [ + "token" + ], + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "7debb8b600000000", + "test": null, + "hostKey": null, + "usage": { + "at": "2026-10-30T21:58:30Z", + "count": 2, + "command": "wrangler", + "threadId": "thread-demo-1" + }, + "usedIn": [], + "origin": { + "by": "agent", + "threadId": "thread-demo-1" + } + }, + { + "id": "github", + "type": "git", + "description": "", + "fields": { + "host": "github.com", + "username": "x-access-token", + "baseUrl": "https://api.github.com/", + "authType": "bearer", + "testPath": "user" + }, + "env": { + "token": "GH_TOKEN" + }, + "delivers": [ + "GH_TOKEN" + ], + "secretNames": [ + "token" + ], + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "318bf60400000000", + "test": { + "at": "2026-10-29T21:00:00Z", + "ok": false, + "code": "rejected", + "ms": 212, + "detail": { + "status": 401 + } + }, + "hostKey": null, + "usage": { + "at": "2026-10-29T20:00:00Z", + "count": 12, + "command": "gh" + }, + "usedIn": [ + { + "kind": "skill", + "name": "release", + "where": "All agents" + } + ], + "origin": { + "by": "setup" + } + }, + { + "id": "kube-staging", + "type": "kubernetes", + "description": "", + "fields": {}, + "env": { + "kubeconfig": "KUBECONFIG" + }, + "delivers": [ + "KUBECONFIG" + ], + "secretNames": [ + "kubeconfig" + ], + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "f27ca68200000000", + "test": null, + "hostKey": null, + "usage": null, + "usedIn": [], + "origin": { + "by": "terminal" + } + }, + { + "id": "old-box", + "type": "ssh", + "description": "", + "fields": { + "host": "192.0.2.20", + "port": 2222, + "username": "root", + "auth": "password" + }, + "env": {}, + "delivers": [], + "secretNames": [ + "password" + ], + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "e55c71c900000000", + "test": { + "at": "2026-10-30T20:00:00Z", + "ok": false, + "code": "host-key-changed", + "ms": 310, + "detail": { + "trusted": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:Zk3vT1cQ9mW7yHf2Lr8PaN4oXs6dJg0uEb5iKq1Rt2M" + }, + "presented": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:Bn7xV2eR4tY8uI1oP6aS3dF9gH0jK5lZ2xC7vB4nM1q" + } + } + }, + "hostKey": { + "type": "ssh-ed25519", + "fingerprint": "SHA256:Zk3vT1cQ9mW7yHf2Lr8PaN4oXs6dJg0uEb5iKq1Rt2M", + "trustedAt": "2026-10-26T18:04:30Z", + "carried": false, + "firstUse": false + }, + "usage": { + "at": "2026-10-24T10:00:00Z", + "count": 3, + "command": "ssh" + }, + "usedIn": [], + "origin": { + "by": "setup" + } + }, + { + "id": "project-api", + "type": "token", + "description": "", + "fields": { + "baseUrl": "https://api.example.com/v1/", + "authType": "header", + "authName": "X-API-Key", + "testPath": "projects" + }, + "env": { + "token": "PROJECT_API_KEY" + }, + "delivers": [ + "PROJECT_API_KEY" + ], + "secretNames": [ + "token" + ], + "hasSecret": true, + "createdAt": "2026-10-26T18:04:11Z", + "updatedAt": "2026-10-26T18:04:11Z", + "secretChangedAt": "2026-10-26T18:04:11Z", + "revision": "f28f0fff00000000", + "test": { + "at": "2026-10-30T21:00:00Z", + "ok": true, + "code": "ok", + "ms": 94, + "detail": { + "status": 200 + } + }, + "hostKey": null, + "usage": { + "at": "2026-10-30T20:00:00Z", + "count": 9, + "command": "http" + }, + "usedIn": [ + { + "kind": "skill", + "name": "project-sync", + "where": "Claude Code" + } + ], + "origin": { + "by": "setup" + } + } + ] } diff --git a/scripts/lib/credentials-fixtures.cjs b/scripts/lib/credentials-fixtures.cjs index 8d9ab06..eddeb8c 100644 --- a/scripts/lib/credentials-fixtures.cjs +++ b/scripts/lib/credentials-fixtures.cjs @@ -3,29 +3,41 @@ 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'); +// Enough of the real detection for the paste box in a preview. +const detectFixture = text => { + const value = String(text || '').trim(); + if (/^-----BEGIN/.test(value)) return { type: 'ssh', label: 'an SSH private key', id: '', fields: { auth: 'key' }, secrets: { privateKey: value }, env: {}, confident: true }; + if (/^gh[pousr]_/.test(value)) return { type: 'git', label: 'a GitHub token', id: 'github', fields: { host: 'github.com', username: 'x-access-token', baseUrl: 'https://api.github.com/', authType: 'bearer', testPath: 'user' }, secrets: { token: value }, env: { token: 'GH_TOKEN' }, confident: true }; + if (/^postgres(ql)?:\/\//.test(value)) return { type: 'database', label: 'a PostgreSQL connection URL', id: 'app-db', fields: { engine: 'postgres', host: 'db.example.net', database: 'app', username: 'app' }, secrets: { password: 'fixture' }, env: {}, confident: true }; + return value ? { type: 'token', label: 'a token', id: '', fields: {}, secrets: { token: value }, env: {}, confident: false } : null; +}; 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 publicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMockPublicKeyForTheFixtureOnly0000000000000 t3-vault'; 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 untrusted = row => row.type === 'ssh' && row.fields?.auth === 'password' && !row.hostKey; + const status = () => ({ count: data.credentials.length, attention: data.credentials.filter(row => untrusted(row) || row.test?.ok === false).map(row => ({ id: row.id, code: untrusted(row) ? '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; + const presented = 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() }); + if (route.endsWith('/generate-key')) return body({ keyId: 'fixture-key', publicKey, install: 'fixture' }); + if (route.endsWith('/detect')) return body({ detected: detectFixture(input.text) }); 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 }; + const { secrets = {}, ...visible } = input.credential, changedSecret = Object.values(secrets).some(Boolean); + const next = { ...profile, ...visible, fields: { ...visible.fields, ...(input.generatedKeyId || input.generate ? { publicKey } : {}) }, secretNames: [...new Set([...(profile?.secretNames || []), ...Object.keys(secrets), ...(input.generatedKeyId || input.generate ? ['privateKey'] : [])])], delivers: profile?.delivers || [], env: { ...profile?.env, ...visible.env }, revision: revision(), hasSecret: true, createdAt: profile?.createdAt || now(), updatedAt: now(), secretChangedAt: changedSecret ? 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 }); } @@ -38,9 +50,10 @@ function createCredentialsFixtures() { 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 + const firstUse = hostKey?.state === 'new' && target.fields?.auth === 'key'; + const result = firstUse ? { ok: true, code: 'ok', detail: { user: target.fields.username }, hostKey } : 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 } }; + : target.id === 'home-assistant' ? { ok: false, code: 'rejected', detail: { status: 401 } } : { ok: true, code: 'ok', detail: target.type === 'ssh' ? { user: target.fields?.username } : { status: 200 } }; Object.assign(result, { ms: 184, at: now() }); if (!input.draft) profile.test = result; return body({ result }); } diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 2da3a30..cf3906e 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -161,8 +161,8 @@ 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" +check "Vault CLI ships" "docker exec $NAME t3-vault --help" +check "old credential helpers are gone" "! docker exec $NAME sh -c 'command -v t3-ssh || command -v t3-api'" 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 @@ -854,7 +854,7 @@ credentials_browser_works() { 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 +check "Vault drafts, host trust, conflicts and recovery work on desktop, phone and Settings" credentials_browser_works files_browser_works() { [ "$HAS_BROWSER" -eq 1 ] || return 0 diff --git a/tests/connect-api.test.mjs b/tests/connect-api.test.mjs index de63fc0..239c1e9 100644 --- a/tests/connect-api.test.mjs +++ b/tests/connect-api.test.mjs @@ -54,7 +54,7 @@ else process.exit(1); const portProbe = createServer().listen(0, '127.0.0.1'); await once(portProbe, 'listening'); const port = portProbe.address().port; await new Promise(resolve => portProbe.close(resolve)); const home = path.join(root, 'home'); await fs.mkdir(home); - const env = { ...process.env, HOME: home, T3CODE_HOME: path.join(home, '.t3'), T3_WORKSPACE: root, + const env = { ...process.env, HOME: home, T3CODE_HOME: path.join(home, '.t3'), CLAUDE_CONFIG_DIR: path.join(home, '.claude'), T3_WORKSPACE: root, T3_SETUP_KEY: 'fixture', T3_SETUP_KEY_SOURCE: 'env', T3_SETUP_PORT: String(port), T3CODE_PORT: String(native.address().port), T3_INFRA_LAUNCHER: launcher, T3_HARNESS_MODULE: fileURLToPath(new URL('../docker/harness/index.mjs', import.meta.url)), MISE_DATA_DIR: path.join(root, 'mise-data'), MISE_STATE_DIR: path.join(root, 'mise-state'), MISE_CONFIG_DIR: path.join(root, 'mise-config') }; diff --git a/tests/console-model.test.mjs b/tests/console-model.test.mjs index 9d97a87..733d18b 100644 --- a/tests/console-model.test.mjs +++ b/tests/console-model.test.mjs @@ -1517,15 +1517,34 @@ test("update check failures can be read for every source at once", () => { assert.equal(M.updateCheckView(status, "harness").failures.length, 1); }); -test('Credentials attention feeds Overview, navigation and host badges without counting untested credentials', () => { +test('Vault 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 }); + assert.deepEqual(plain(M.navBadges(base).vault), { tone: 'warn', count: 2 }); + assert.equal(M.navBadges(base).credentials, undefined); 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'); + assert.equal(items[1].action.route, 'vault/build-server'); assert.equal(items[1].tone, 'warn'); + assert.equal(M.navBadges({ credentials: { count: 3, attention: [] } }).vault, null); + const api = { type: 'token', fields: { baseUrl: 'https://api.example.test/v1/' } }; + assert.equal(M.credentialState({ type: 'ssh', fields: { auth: 'password' }, hostKey: null, test: { code: 'ok' } }).code, 'host-key-untrusted'); + assert.equal(M.credentialState({ ...api, test: { code: 'rejected', detail: { status: 401 } } }).label, 'Rejected · HTTP 401'); + assert.equal(M.credentialState(api).code, 'untested'); + // A key waits for its first connection, and a credential without an address has nothing to test. + assert.equal(M.credentialState({ type: 'ssh', fields: { auth: 'key' }, hostKey: null }).code, 'not-connected'); + assert.equal(M.credentialState({ type: 'token', fields: {} }).code, 'no-test'); + assert.equal(M.credentialState({ type: 'ssh', fields: { auth: 'key', host: 'example.net', username: 'deploy' }, hostKey: { fingerprint: 'x' }, test: { code: 'auth-rejected' } }).code, 'auth-rejected'); +}); + +test('every SSH and API test result has words for the row and for the sheet', () => { + const when = '2026-10-30T10:00:00Z', fields = { ssh: { auth: 'key', host: 'example.net', port: 22, username: 'deploy' }, api: { baseUrl: 'https://api.example.test/v1/', testPath: 'account' } }; + const results = [['ssh', ['ok', 'auth-rejected', 'host-key-changed', 'unreachable', 'timeout', 'scan-failed', 'failed']], ['token', ['ok', 'rejected', 'not-found', 'http-error', 'redirect', 'certificate', 'unreachable', 'timeout']]]; + for (const [type, codes] of results) for (const code of codes) { + const state = M.credentialState({ id: 'row', type, fields: type === 'ssh' ? fields.ssh : fields.api, hostKey: type === 'ssh' ? { fingerprint: 'x' } : null, test: { code, at: when, detail: { status: 500 } } }, { now: Date.parse(when) + 1000 }); + assert.equal(state.code, code); + assert.ok(state.label && state.title, type + ' ' + code); + assert.doesNotMatch(state.label + state.sub + state.title + state.desc, /\{\w+\}|undefined/, type + ' ' + code); + assert.ok(M.credentialProblem(code), code); + } }); test("Pi signs in with a key per provider or a subscription, from one sheet", () => { diff --git a/tests/credential-model.test.mjs b/tests/credential-model.test.mjs index 274b6ae..8e99342 100644 --- a/tests/credential-model.test.mjs +++ b/tests/credential-model.test.mjs @@ -2,34 +2,156 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; +import { publicTypes, envNames, resolveEnvName } from '../docker/setup/credentials/types.mjs'; +import { normalize } from '../docker/setup/credentials/model.mjs'; 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' }; +// Templates reach the page over HTTP, so they have to survive JSON. +S.setTypes(plain(publicTypes())); +const api = { id: 'project-api', type: 'token', fields: { baseUrl: 'https://api.example.test/v1', authType: 'header', authName: 'X-Key', authPrefix: 'Token', testPath: 'account' } }; +const setField = (draft, key, value) => ({ ...draft, fields: { ...draft.fields, [key]: value } }); + 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' }); + const draft = S.draftOf({ ...api, secret: 'never-copy-me', secrets: { token: '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.request(setField(setField(draft, 'authType', 'query'), 'authName', 'api key')), /api\+key=••••••••/); + assert.deepEqual(plain(S.commands(draft)), ['t3-vault run project-api -- ', 't3-vault http project-api GET account']); assert.match(S.snippet(draft), /Don’t read or print credential files/); + assert.equal(S.snippet(draft).includes('never-copy-me'), false); }); + +test('commands and snippets follow what the server can actually do for each credential', () => { + const box = { id: 'build-server', type: 'ssh', fields: { host: 'example.net', username: 'deploy', auth: 'key' } }; + assert.deepEqual(plain(S.commands(box)), ['ssh build-server uptime', 'scp app.tar build-server:/srv/']); + assert.equal(S.commands(box, { aliases: false })[0], 't3-vault ssh build-server -- uptime'); + assert.match(S.snippet(box), /ssh build-server ''/); + assert.match(S.snippet(box, { aliases: false }), /t3-vault ssh build-server -- ''/); + assert.deepEqual(plain(S.commands({ id: 'plain', type: 'token', fields: {} })), ['t3-vault run plain -- ']); + assert.match(S.snippet({ id: 'plain', type: 'token', delivers: ['PLAIN_TOKEN'], fields: {} }), /The command gets PLAIN_TOKEN/); + assert.match(S.snippet({ ...api, fields: { ...api.fields, baseUrl: 'https://api.example.test/v1/' } }), /t3-vault http project-api POST --stdin/); +}); + 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); + const valid = S.draftOf(api), secrets = { token: 'fixture' }; + assert.deepEqual(plain(S.validate(valid, { secrets })), {}); + assert.ok(S.validate({ ...valid, id: 'Bad ID' }, { secrets }).id); + assert.match(S.validate({ ...valid, id: 'new' }, { secrets }).id, /reserved/); + for (const [field, value] of [['baseUrl', 'https://user:pass@example.com/v1/'], ['authName', 'Host'], ['authPrefix', 'Token\r\n'], ['testPath', '../escape'], ['testPath', '%2e%2e/escape']]) { + assert.ok(S.validate(setField(valid, field, value), { secrets })['fields.' + field], field); + } + assert.match(S.validate(valid, { secrets, names: ['project-api'] }).id, /taken/); + assert.equal(S.validate(valid, { savedSecrets: ['token'], editing: true })['secrets.token'], undefined); + assert.ok(S.validate(valid)['secrets.token']); + assert.equal(S.validate(valid, { needSecret: false })['secrets.token'], undefined); + assert.ok(S.validate({ ...valid, type: 'nothing-like-this' }).type); +}); + +test('the page and the server agree about what a credential may hold', () => { + const server = input => { try { normalize(input); return true; } catch { return false; } }; + const page = (input, secrets) => Object.keys(S.validate({ ...S.draftOf(input), id: input.id }, { secrets })).length === 0; + const cases = [ + [{ id: 'fine', type: 'token', fields: {} }, { token: 'fixture-value' }, true], + [{ id: 'bad-env', type: 'token', fields: {}, env: { token: 'PATH' } }, { token: 'fixture-value' }, false], + [{ id: 'box', type: 'ssh', fields: { host: '2001:db8::1', username: 'deploy', auth: 'password' } }, { password: 'fixture-value' }, true], + [{ id: 'box', type: 'ssh', fields: { host: '-oProxyCommand=bad', username: 'deploy', auth: 'password' } }, { password: 'fixture-value' }, false], + [{ id: 'box', type: 'ssh', fields: { host: 'example.net', username: 'user;bad', auth: 'password' } }, { password: 'fixture-value' }, false], + [{ id: 'box', type: 'ssh', fields: { host: 'example.net', port: 70000, username: 'deploy', auth: 'password' } }, { password: 'fixture-value' }, false], + [{ id: 'cloud', type: 'aws', fields: { accessKeyId: 'lowercase' } }, { secretAccessKey: 'fixture-value' }, false], + [{ id: 'cloud', type: 'aws', fields: { accessKeyId: 'AKIAFIXTURE' } }, { secretAccessKey: 'fixture-value' }, true], + [{ id: 'file', type: 'file', fields: { filename: '../escape' } }, { content: 'fixture-value' }, false], + [{ id: 'long', type: 'token', fields: {} }, { token: 'x'.repeat(8193) }, false], + [{ id: 'long', type: 'file', fields: {} }, { content: 'x'.repeat(48 * 1024 + 1) }, false], + ]; + for (const [input, secrets, expected] of cases) { + assert.equal(server({ ...input, secrets }), expected, 'server: ' + JSON.stringify(input)); + assert.equal(page(input, secrets), expected, 'page: ' + JSON.stringify(input)); + } +}); + +test('variables are named the same way on the page and on the server', () => { + for (const type of publicTypes()) for (const entry of [...type.fields, ...type.secrets]) { + if (!entry.env) continue; + for (const id of ['cloudflare-token', 'prod', 'token', 'db-password']) { + assert.equal(S.resolveEnvName(entry.env, id), resolveEnvName(entry.env, id)); + assert.equal(S.envFor({ id, env: {} }, entry), envNames({ id, type: type.id })[entry.key], type.id + ' ' + entry.key); + } } - 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); + assert.equal(resolveEnvName('{NAME}_TOKEN', 'cloudflare-token'), 'CLOUDFLARE_TOKEN'); + assert.equal(resolveEnvName('{NAME}_TOKEN', 'token'), 'TOKEN'); + assert.equal(resolveEnvName('{NAME}_TOKEN', 'stripe'), 'STRIPE_TOKEN'); + assert.equal(S.envFor({ id: 'stripe', env: { token: 'STRIPE_API_KEY' } }, { key: 'token', env: '{NAME}_TOKEN' }), 'STRIPE_API_KEY'); +}); + +test('drafts and payloads hold only what each type needs, and a saved secret is never asked for again', () => { + assert.deepEqual(plain(S.typeList()).map(type => type.id), ['ssh', 'token', 'login', 'database', 'aws', 'gcp', 'kubernetes', 'git', 'registry', 'file', 'custom']); + assert.equal(S.typeLabel('nothing-like-this'), 'Credential'); assert.equal(S.badge('ssh'), 'SSH'); assert.equal(S.iconFor('nothing-like-this'), 'key-round'); + assert.equal(S.primarySecret('ssh', { auth: 'key' }).key, 'privateKey'); assert.equal(S.primarySecret('ssh', { auth: 'password' }).key, 'password'); + assert.equal(S.primarySecret('custom'), null); + assert.deepEqual(plain(S.defaults('ssh').fields), { port: 22, auth: 'key' }); + const box = S.draftOf({ id: 'box', type: 'ssh', fields: { host: 'example.net', port: 22, username: 'deploy', auth: 'key', publicKey: 'ssh-ed25519 AAAA' } }); + const sent = plain(S.payload(box, { privateKey: '', password: 'ignored-empty-is-dropped' })); + assert.equal(sent.fields.publicKey, undefined, 'a derived field is the server\'s to fill in'); + assert.equal(sent.fields.port, 22); + assert.equal(sent.secrets.privateKey, undefined, 'an empty box keeps what is saved'); + const custom = S.draftOf({ id: 'mixed', type: 'custom', fields: { REGION: 'eu' }, secretNames: ['API_KEY'] }); + assert.deepEqual(plain(custom.vars), [{ name: 'REGION', value: 'eu', secret: false }, { name: 'API_KEY', value: '', secret: true, saved: true }]); + assert.deepEqual(plain(S.validate(custom)), {}); + assert.deepEqual(plain(S.payload(custom)), { id: 'mixed', type: 'custom', description: '', fields: { REGION: 'eu' }, secretNames: ['API_KEY'], secrets: {} }); + assert.match(S.validate({ ...custom, vars: [] }).vars, /at least one/); + assert.match(S.validate({ ...custom, vars: [{ name: 'PATH', value: 'x', secret: true }] }).vars, /letters, numbers/); + assert.match(S.validate({ ...custom, vars: [{ name: 'SAME', value: 'x' }, { name: 'SAME', value: 'y' }] }).vars, /twice/); + assert.match(S.validate({ ...custom, vars: [{ name: 'EMPTY', value: '', secret: true }] }).vars, /Enter a value/); + assert.deepEqual(plain(S.givesPreview(S.draftOf({ id: 'app-db', type: 'database', fields: { engine: 'postgres', host: 'x' } }))), [{ name: 'DATABASE_URL' }, { name: 'PGPASSWORD' }]); + assert.deepEqual(plain(S.givesPreview({ ...S.defaults('kubernetes'), id: 'kube' })), [{ name: 'KUBECONFIG', file: true }]); + assert.deepEqual(plain(S.givesPreview({ ...S.defaults('custom'), vars: [{ name: 'ONE' }, { name: '' }] })), [{ name: 'ONE' }]); }); + 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({ id: 'server', type: 'ssh', fields: { auth: 'password' }, 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']); + assert.deepEqual(plain(S.changes(api, { ...api, fields: { ...api.fields, authName: 'X-Other' }, secretChangedAt: 'changed' })), ['authName']); + assert.deepEqual(plain(S.changes({ fields: { auth: 'key', publicKey: 'a' } }, { fields: { auth: 'key', publicKey: 'b' } })), [], 'a derived key is not an edit'); +}); + +test('a key sign-in that hasn\'t connected is not a problem, a password that hasn\'t been confirmed is', () => { + const key = { id: 'key-box', type: 'ssh', fields: { auth: 'key', host: 'example.net' }, hostKey: null }; + assert.equal(S.state(key).code, 'not-connected'); assert.equal(Boolean(S.needsAttention(key, [])), false); + const password = { id: 'old-box', type: 'ssh', fields: { auth: 'password', host: 'example.net' }, hostKey: null }; + assert.equal(S.state(password).code, 'host-key-untrusted'); assert.equal(Boolean(S.needsAttention(password, [])), true); + assert.equal(Boolean(S.needsAttention({ ...key, hostKey: { fingerprint: 'x' }, test: { ok: false, code: 'host-key-changed' } }, [])), true); + assert.equal(S.state({ id: 'plain', type: 'token', fields: {} }).code, 'no-test'); + assert.equal(S.state({ id: 'api', type: 'token', fields: { baseUrl: 'https://example.net/' } }).code, 'untested'); + assert.equal(S.state({ id: 'api', type: 'token', fields: { baseUrl: 'https://example.net/' } }, { busy: true }).code, 'testing'); + assert.equal(S.canTest({ type: 'ssh' }), true); assert.equal(S.canTest({ type: 'token', fields: {} }), false); assert.equal(S.canTest({ type: 'git', fields: { baseUrl: 'https://x/' } }), true); +}); + +test('each credential describes its target without revealing anything it shouldn\'t', () => { + const rows = [ + [{ type: 'ssh', fields: { host: 'example.net', port: 22, username: 'deploy' } }, 'deploy@example.net'], + [{ type: 'ssh', fields: { host: 'example.net', port: 2222, username: 'deploy' } }, 'deploy@example.net:2222'], + [{ type: 'database', fields: { host: 'db.example.net', username: 'app', database: 'main' } }, 'app@db.example.net/main'], + [{ type: 'aws', fields: { accessKeyId: 'AKIAFIXTUREFIXTURE', region: 'us-east-1' } }, 'AKIA…TURE · us-east-1'], + [{ type: 'gcp', fields: { clientEmail: 'fixture@example.iam.gserviceaccount.com' } }, 'fixture@example.iam.gserviceaccount.com'], + [{ type: 'token', env: { token: 'API_TOKEN' }, fields: { baseUrl: 'https://api.example.net/v1/' } }, 'API_TOKEN · api.example.net'], + [{ type: 'git', env: { token: 'GH_TOKEN' }, fields: { host: 'github.com' } }, 'GH_TOKEN · github.com'], + [{ type: 'custom', delivers: ['A', 'B', 'C', 'D', 'E', 'F'], fields: {} }, 'A, B, C, D and 2 more'], + [{ type: 'kubernetes', delivers: ['KUBECONFIG'], fields: {} }, 'KUBECONFIG'], + ]; + for (const [credential, text] of rows) assert.equal(S.target(credential), text); + assert.equal(S.delivery({ type: 'kubernetes', delivers: ['KUBECONFIG'] }), 'Given to each command as a temporary file'); + assert.equal(S.delivery({ type: 'token', delivers: ['API_TOKEN'] }), 'API_TOKEN'); + assert.equal(S.kind({ type: 'ssh', fields: { auth: 'key' } }), 'Key'); assert.equal(S.kind({ type: 'ssh', fields: { auth: 'password' } }), 'Password'); + assert.equal(S.kind({ type: 'token', fields: {} }), null); + assert.equal(S.kind({ type: 'token', fields: { baseUrl: 'x', authType: 'query', authName: 'key' } }), '?key'); + assert.equal(S.secretKind({ type: 'ssh', fields: { auth: 'key' } }), 'private key'); + assert.equal(S.originText({ origin: { by: 'agent' } }), 'Added by an agent'); assert.equal(S.originText({ origin: { by: 'terminal' } }), 'Added from a terminal'); assert.equal(S.originText({ origin: { by: 'setup' } }), null); + assert.equal(S.threadUrl({ threadId: 'thread/1' }, 'env one'), '/env%20one/thread%2F1'); + assert.equal(S.threadUrl({ threadId: 'thread' }, null), null); assert.equal(S.threadUrl({}, 'env'), null); + assert.equal(S.installLine('ssh-ed25519 AAAA'), "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo 'ssh-ed25519 AAAA' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"); }); diff --git a/tests/credentials-api.test.mjs b/tests/credentials-api.test.mjs index 2797e79..75b7c04 100644 --- a/tests/credentials-api.test.mjs +++ b/tests/credentials-api.test.mjs @@ -1,40 +1,50 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; import { createServer } from 'node:http'; import { once } from 'node:events'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { publicTypes } from '../docker/setup/credentials/types.mjs'; +import { CREDENTIAL_ROUTES } from '../docker/setup/credentials/service.mjs'; +import { vaultSkillFolders } from '../docker/setup/credentials/skill.mjs'; 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']) { + for (const route of CREDENTIAL_ROUTES) { const method = route === '/credentials' ? 'GET' : 'POST'; - assert.equal((await fetch(f.base + route, { method })).status, 401); + assert.equal((await fetch(f.base + route, { method })).status, 401, route); } 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, []); + assert.deepEqual(initial.types, JSON.parse(JSON.stringify(publicTypes()))); + assert.equal(initial.sshAliases, false); 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 profile = { id: 'staging-server', label: 'Staging server', type: 'ssh', fields: { host: '192.0.2.2', username: 'root', port: 22, auth: 'password' }, secrets: { password: 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.deepEqual(snapshot.credentials[0].secretNames, ['password']); + assert.equal(Object.hasOwn(snapshot.credentials[0], 'secrets'), false); + assert.equal(snapshot.sshAliases, true, 'the fixture home now reads the generated ssh config'); 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); + assert.equal((await f.request('/credentials/save', { credential: { ...profile, secrets: {} }, 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/test', { id: profile.id, draft: { ...profile, id: 'different', secrets: {} } })).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); @@ -52,24 +62,39 @@ test('saved API token works through the CLI and connection test without returnin }); 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 }); + const saved = await (await f.request('/credentials/create', { credential: { id: 'project-api', label: 'Project API', type: 'token', fields: { baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/' }, secrets: { token: secret } } })).json(); + const probe = await f.request('/credentials/test', { id: 'project-api', revision: saved.credential.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 }); + const rejected = await f.request('/credentials/test', { id: 'project-api', revision: saved.credential.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'] }); + const helperEnv = { ...f.env, T3_INFRA_NODE: process.execPath, T3_VAULT_CLI: fileURLToPath(new URL('../docker/setup/credentials/cli.mjs', import.meta.url)) }; + const vault = fileURLToPath(new URL('../docker/bin/t3-vault', import.meta.url)); + const child = spawn('/bin/bash', [vault, 'http', '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'] }); + const help = spawn('/bin/bash', [vault, '--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((await once(help, 'close'))[0], 0); assert.match(helpText, /t3-vault run NAME/); assert.equal(f.logs().includes(secret), false); }); + +test('starting Setup finishes an upgrade and teaches every agent about the Vault', { timeout: 20000 }, async t => { + const f = await credentialsFixture(); t.after(f.close); + assert.doesNotMatch(f.logs(), /could not be installed/); + for (const folder of vaultSkillFolders({ home: f.home, env: f.env })) assert.match(await fs.readFile(path.join(folder, 'SKILL.md'), 'utf8'), /^name: t3-vault$/m); + assert.deepEqual(Object.keys(JSON.parse(await fs.readFile(path.join(f.env.T3CODE_HOME, 'server-credentials', 'skill.json'), 'utf8'))), ['digest']); +}); + +test('the page learns which environment it is in, so a credential can link to the thread that added it', { timeout: 20000 }, async t => { + const f = await credentialsFixture(); t.after(f.close); + assert.equal((await (await f.request('/credentials')).json()).environmentId, null, 'no id yet is not an error'); + await fs.mkdir(path.join(f.env.T3CODE_HOME, 'userdata'), { recursive: true }); + await fs.writeFile(path.join(f.env.T3CODE_HOME, 'userdata', 'environment-id'), 'fixture-environment\n'); + assert.equal((await (await f.request('/credentials')).json()).environmentId, 'fixture-environment'); +}); diff --git a/tests/credentials-cli.test.mjs b/tests/credentials-cli.test.mjs new file mode 100644 index 0000000..c93075d --- /dev/null +++ b/tests/credentials-cli.test.mjs @@ -0,0 +1,490 @@ +// t3-vault as agents and people run it: a real subprocess, with a home of its +// own. It gets a clean environment rather than this one, so a thread id or a real +// T3CODE_HOME around the test can't change what it does or where it writes. +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 { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { createServer as createNetServer } from 'node:net'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { createCredentialStore } from '../docker/setup/credentials.mjs'; + +const CLI = fileURLToPath(new URL('../docker/setup/credentials/cli.mjs', import.meta.url)); +const secret = 'fixture-cli-secret-value-123'; +const NODE = process.execPath; + +async function sandbox(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-cli-')), home = path.join(root, 'home'), t3 = path.join(home, '.t3'), files = path.join(root, 'files'); + await fs.mkdir(home, { recursive: true }); await fs.mkdir(files, { mode: 0o700 }); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const baseEnv = { PATH: process.env.PATH, HOME: home, T3CODE_HOME: t3 }; + const cli = (args, { env = {}, input } = {}) => new Promise((resolve, reject) => { + const child = spawn(NODE, [CLI, ...args], { env: { ...baseEnv, ...env }, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] }); + const out = [], err = []; + child.stdout.on('data', chunk => out.push(chunk)); child.stderr.on('data', chunk => err.push(chunk)); + child.once('error', reject); + child.once('close', code => resolve({ code, out: Buffer.concat(out).toString(), err: Buffer.concat(err).toString(), bytes: Buffer.concat(out) })); + if (input !== undefined) child.stdin.end(input); + }); + let count = 0; + const file = async (text, name = 'secret-' + ++count) => { const target = path.join(files, name); await fs.writeFile(target, text, { mode: 0o600 }); return target; }; + /** A file where T3 Code's request_secret leaves one. */ + const card = async (text, name = '0123456789abcdef0123456789abcdef') => { const folder = path.join(home, 'secrets/files'); await fs.mkdir(folder, { recursive: true, mode: 0o700 }); const target = path.join(folder, name); await fs.writeFile(target, text, { mode: 0o600 }); return target; }; + const directory = path.join(t3, 'server-credentials'); + const rows = async () => JSON.parse(await fs.readFile(path.join(directory, 'credentials.json'), 'utf8').catch(error => { if (error.code === 'ENOENT') return '{"credentials":[]}'; throw error; })).credentials; + const row = async id => (await rows()).find(entry => entry.id === id); + const saved = async () => createCredentialStore({ directory, home, readOnly: true }); + return { root, home, directory, cli, file, card, rows, row, saved }; +} +const ok = answer => { assert.equal(answer.code, 0, answer.err); return answer; }; +const never = (answer, value = secret) => { assert.equal((answer.out + answer.err).includes(value), false, 'the secret was printed'); return answer; }; +const ask = /request_secret/; +const compare = (name, value) => `process.stdout.write(process.env.${name} === ${JSON.stringify(value)} ? 'matched' : 'different')`; +/** A port with nothing listening on it. */ +async function closedPort() { + const server = createNetServer().listen(0, '127.0.0.1'); await once(server, 'listening'); + const { port } = server.address(); await new Promise(resolve => server.close(resolve)); + return port; +} +async function apiServer(t, respond) { + const server = createServer(respond).listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); })); + return 'http://127.0.0.1:' + server.address().port + '/'; +} + +test('add from a secret file, then run, list and show, and no output ever holds the secret', async t => { + const s = await sandbox(t), source = await s.file(secret + '\n'); + const added = never(ok(await s.cli(['add', 'demo-token', '--secret-file', source, '--env', 'DEMO_TOKEN', '--description', 'A fixture']))); + assert.match(added.out, /^Saved demo-token\.$/m); assert.match(added.out, /Commands get: DEMO_TOKEN/); assert.match(added.out, /t3-vault run demo-token -- /); + assert.equal(await fs.readFile(source, 'utf8'), secret + '\n', 'a file that isn\'t T3\'s one-use card is kept'); + const stored = await s.row('demo-token'); + assert.deepEqual(stored.secrets, { token: secret }, 'the line break at the end of the file isn\'t part of it'); + assert.equal(stored.description, 'A fixture'); assert.deepEqual(stored.env, { token: 'DEMO_TOKEN' }); + assert.equal(ok(await s.cli(['run', 'demo-token', '--', NODE, '-e', compare('DEMO_TOKEN', secret)])).out, 'matched'); + const echoed = never(ok(await s.cli(['run', 'demo-token', '--', NODE, '-e', 'process.stdout.write(process.env.DEMO_TOKEN); process.stderr.write(process.env.DEMO_TOKEN);']))); + assert.equal(echoed.out, '[redacted]'); assert.equal(echoed.err, '[redacted]'); + const listed = never(ok(await s.cli(['list']))); + assert.match(listed.out, /^demo-token\s+Token\s+DEMO_TOKEN - A fixture$/m); assert.match(listed.out, /use: t3-vault run demo-token -- /); + never(ok(await s.cli(['ls']))); + const json = JSON.parse(never(ok(await s.cli(['list', '--json']))).out); + assert.deepEqual(json.credentials, [{ name: 'demo-token', type: 'token', description: 'A fixture', gives: ['DEMO_TOKEN'], use: ['t3-vault run demo-token -- '] }]); + const shown = never(ok(await s.cli(['show', 'demo-token']))); + assert.match(shown.out, /^demo-token \(API token\)$/m); assert.match(shown.out, /gives commands: DEMO_TOKEN/); assert.match(shown.out, /t3-vault run demo-token -- /); + const usage = await (await s.saved()).usage.read('demo-token'); + assert.equal(usage.count, 2); assert.equal(usage.command, path.basename(NODE), 'the command\'s name, never its arguments'); +}); + +test('an empty vault says what to do next', async t => { + const s = await sandbox(t), listed = ok(await s.cli(['list'])); + assert.match(listed.out, /The vault is empty/); assert.match(listed.out, ask); assert.match(listed.out, /t3-vault add NAME --secret-file/); + assert.deepEqual(JSON.parse(ok(await s.cli(['list', '--json'])).out), { credentials: [] }); + await assert.rejects(fs.stat(s.directory), { code: 'ENOENT' }, 'reading creates nothing'); +}); + +test('origin records who added it: an agent\'s thread, or a terminal', async t => { + const s = await sandbox(t); + ok(await s.cli(['add', 'from-agent', '--secret-file', await s.file(secret)], { env: { T3CODE_THREAD_ID: 'thread-fixture-1' } })); + assert.deepEqual((await s.row('from-agent')).origin, { by: 'agent', threadId: 'thread-fixture-1' }); + ok(await s.cli(['add', 'from-terminal', '--secret-file', await s.file(secret)])); + assert.deepEqual((await s.row('from-terminal')).origin, { by: 'terminal' }); + ok(await s.cli(['add', 'odd-thread', '--secret-file', await s.file(secret)], { env: { T3CODE_THREAD_ID: 'not a valid id' } })); + assert.deepEqual((await s.row('odd-thread')).origin, { by: 'terminal' }, 'a value that can\'t be a thread id isn\'t believed'); + ok(await s.cli(['replace', 'from-terminal', '--secret-file', await s.file(secret + '-new')], { env: { T3CODE_THREAD_ID: 'thread-fixture-2' } })); + const changed = await s.row('from-terminal'); + assert.deepEqual(changed.origin, { by: 'terminal' }); assert.deepEqual(changed.changedBy, { by: 'agent', threadId: 'thread-fixture-2' }); + ok(await s.cli(['run', 'from-agent', '--', NODE, '-e', ''], { env: { T3CODE_THREAD_ID: 'thread-fixture-3' } })); + assert.equal((await (await s.saved()).usage.read('from-agent')).threadId, 'thread-fixture-3'); +}); + +test('a secret on the command line is refused, and nothing is saved', async t => { + const s = await sandbox(t); + for (const args of [['--token', secret], ['--token=' + secret], ['--type', 'token', '--token', secret], ['--type', 'login', '--username', 'me', '--password', secret], ['--type', 'ssh', '--host', 'example.test', '--user', 'me', '--password', secret], ['--type', 'ssh', '--host', 'example.test', '--user', 'me', '--private-key', secret], ['--type', 'aws', '--access-key-id', 'AKIAFIXTURE', '--secret-access-key', secret], ['--type', 'kubernetes', '--kubeconfig', secret]]) { + const refused = never(await s.cli(['add', 'refused', ...args])); + assert.equal(refused.code, 1, args.join(' ')); + assert.match(refused.err, /can't go on the command line/, args.join(' ')); + assert.match(refused.err, /FILE/, 'and it says what to use instead'); + } + assert.equal(ok(await s.cli(['list', '--json'])).out.includes('refused'), false); + // An option no type knows, which a secret might be sitting in, is refused too. + const unknown = never(await s.cli(['add', 'refused', '--secret', secret])); + assert.equal(unknown.code, 1); assert.match(unknown.err, /Unknown option --secret/); +}); + +test('every command that names a missing credential says how to get it', async t => { + const s = await sandbox(t); + for (const args of [['show', 'nothing'], ['run', 'nothing', '--', NODE, '-e', ''], ['ssh', 'nothing', '--', 'true'], ['http', 'nothing', 'GET', 'x'], ['replace', 'nothing', '--secret-file', await s.file(secret)], ['test', 'nothing'], ['trust', 'nothing'], ['use-key', 'nothing'], ['rm', 'nothing', '--yes']]) { + const answer = await s.cli(args); + assert.equal(answer.code, 1, args.join(' ')); + assert.match(answer.err, /No saved credential named nothing/, args.join(' ')); + assert.match(answer.err, ask, args.join(' ')); + assert.match(answer.err, /t3-vault add nothing --secret-file/, args.join(' ')); + } +}); + +test('having nothing to save, or a file that has expired, also says how to get it', async t => { + const s = await sandbox(t); + const nothing = await s.cli(['add', 'demo']); + assert.equal(nothing.code, 1); assert.match(nothing.err, /Nothing to save yet/); assert.match(nothing.err, ask); + const expired = await s.cli(['add', 'demo', '--secret-file', path.join(s.root, 'gone')]); + assert.equal(expired.code, 1); assert.match(expired.err, /may have expired/); assert.match(expired.err, ask); + const folder = await s.cli(['add', 'demo', '--secret-file', s.root]); + assert.equal(folder.code, 1); assert.match(folder.err, /isn't a regular file/); + const big = await s.cli(['add', 'demo', '--secret-file', await s.file('x'.repeat(48 * 1024 + 1))]); + assert.equal(big.code, 1); assert.match(big.err, /48 KiB/); + assert.deepEqual(JSON.parse(ok(await s.cli(['list', '--json'])).out).credentials, []); +}); + +test('T3\'s one-use card file is deleted once saved, and any other file is kept', async t => { + const s = await sandbox(t); + const card = await s.card(secret + '\n'); + const added = never(ok(await s.cli(['add', 'from-card', '--secret-file', card]))); + assert.match(added.out, /Saved from-card and deleted the file\./); + await assert.rejects(fs.stat(card), { code: 'ENOENT' }); + assert.equal((await s.row('from-card')).secrets.token, secret); + const kept = await s.file(secret + '\n'); + assert.doesNotMatch(ok(await s.cli(['add', 'from-file', '--secret-file', kept])).out, /deleted/); + assert.equal(await fs.readFile(kept, 'utf8'), secret + '\n'); + // Nearly the right place or nearly the right name isn't T3's file. + const folder = path.join(s.home, 'secrets/files'); + for (const name of ['0123456789ABCDEF0123456789ABCDEF', '0123456789abcdef', '0123456789abcdef0123456789abcdef0', 'notes.txt']) { + const other = path.join(folder, name); await fs.writeFile(other, secret, { mode: 0o600 }); + ok(await s.cli(['add', 'near-' + name.slice(0, 8).toLowerCase().replace(/[^a-z0-9]/g, 'x') + '-' + name.length, '--secret-file', other])); + assert.equal(await fs.readFile(other, 'utf8'), secret, name); + } + const elsewhere = path.join(s.home, 'files', '0123456789abcdef0123456789abcdef'); await fs.mkdir(path.dirname(elsewhere)); + await fs.writeFile(elsewhere, secret, { mode: 0o600 }); + ok(await s.cli(['add', 'elsewhere', '--secret-file', elsewhere])); + assert.equal(await fs.readFile(elsewhere, 'utf8'), secret); +}); + +test('a card file stays when the save fails, so the user isn\'t asked again', async t => { + const s = await sandbox(t), card = await s.card(secret); + ok(await s.cli(['add', 'taken', '--secret-file', await s.file(secret)])); + const refused = await s.cli(['add', 'taken', '--secret-file', card]); + assert.equal(refused.code, 1); assert.match(refused.err, /already saved/); + assert.equal(await fs.readFile(card, 'utf8'), secret); + assert.equal((await s.cli(['add', 'Bad Name', '--secret-file', card])).code, 1); + assert.equal(await fs.readFile(card, 'utf8'), secret); +}); + +test('replace changes only the secret, and takes it from a file or stdin', async t => { + const s = await sandbox(t); + ok(await s.cli(['add', 'demo', '--secret-file', await s.file(secret), '--env', 'DEMO_TOKEN', '--description', 'Kept'])); + const before = await s.row('demo'), next = secret + '-replaced'; + const answer = never(never(ok(await s.cli(['replace', 'demo', '--secret-file', await s.file(next + '\n')]))), next); + assert.match(answer.out, /^Saved demo\.$/m); + const after = await s.row('demo'); + assert.equal(after.secrets.token, next); assert.deepEqual(after.env, before.env); assert.equal(after.description, 'Kept'); + assert.notEqual(after.revision, before.revision); assert.equal(after.createdAt, before.createdAt); assert.notEqual(after.secretChangedAt, before.secretChangedAt); + assert.equal(ok(await s.cli(['run', 'demo', '--', NODE, '-e', compare('DEMO_TOKEN', next)])).out, 'matched'); + const card = await s.card(secret + '-card\n'); + assert.match(ok(await s.cli(['replace', 'demo', '--secret-file', card])).out, /deleted the file/); + await assert.rejects(fs.stat(card), { code: 'ENOENT' }); + ok(await s.cli(['replace', 'demo', '--stdin'], { input: secret + '-stdin\n' })); + assert.equal((await s.row('demo')).secrets.token, secret + '-stdin'); + const empty = await s.cli(['replace', 'demo']); + assert.equal(empty.code, 1); assert.match(empty.err, ask); + assert.equal((await s.row('demo')).secrets.token, secret + '-stdin', 'a failed replace changes nothing'); + ok(await s.cli(['add', 'many', '--type', 'custom', '--var', 'A=1'])); + assert.match((await s.cli(['replace', 'many', '--secret-file', await s.file(secret)])).err, /more than one secret/); +}); + +test('replacing a recognized value of the same type unwraps it, instead of saving the wrapper', async t => { + const s = await sandbox(t); + ok(await s.cli(['add', 'app-db', '--secret-file', await s.file('postgres://app:fixture%40first@db.example.test/app')])); + assert.equal((await s.row('app-db')).type, 'database'); + ok(await s.cli(['replace', 'app-db', '--secret-file', await s.file('postgres://app:fixture%40second@db.example.test/app')])); + assert.equal((await s.row('app-db')).secrets.password, 'fixture@second'); + ok(await s.cli(['replace', 'app-db', '--secret-file', await s.file('fixture-plain-password')])); + assert.equal((await s.row('app-db')).secrets.password, 'fixture-plain-password'); +}); + +test('a passphrase never goes on the command line for replace either, and a file works', async t => { + const s = await sandbox(t), port = await closedPort(), passphrase = 'fixture-key-passphrase', keyFile = path.join(s.root, 'made-key'); + ok(await s.cli(['add', 'box', '--host', '127.0.0.1', '--port', String(port), '--user', 'deploy', '--generate-key'])); + const before = await s.row('box'); + assert.equal(spawnSync('/usr/bin/ssh-keygen', ['-q', '-t', 'ed25519', '-N', passphrase, '-C', 'fixture', '-f', keyFile]).status, 0); + const refused = never(await s.cli(['replace', 'box', '--secret-file', keyFile, '--passphrase', passphrase]), passphrase); + assert.equal(refused.code, 1); assert.match(refused.err, /can't go on the command line/); assert.match(refused.err, /--passphrase-file/); + assert.equal((await s.row('box')).revision, before.revision); + const card = await s.card(passphrase + '\n'); + never(ok(await s.cli(['replace', 'box', '--secret-file', keyFile, '--passphrase-file', card])), passphrase); + await assert.rejects(fs.stat(card), { code: 'ENOENT' }, 'T3\'s one-use file goes once it\'s been used'); + const after = await s.row('box'); + assert.notEqual(after.fields.publicKey, before.fields.publicKey); + assert.equal(after.fields.publicKey.split(' ')[1], (await fs.readFile(keyFile + '.pub', 'utf8')).split(' ')[1]); + assert.equal(spawnSync('/usr/bin/ssh-keygen', ['-y', '-P', '', '-f', path.join(s.directory, 'keys/box')]).status, 0, 'and the saved key needs no passphrase'); +}); + +test('rm needs --yes, and says what it doesn\'t do', async t => { + const s = await sandbox(t); + ok(await s.cli(['add', 'demo', '--secret-file', await s.file(secret)])); + const refused = await s.cli(['rm', 'demo']); + assert.equal(refused.code, 1); assert.match(refused.err, /--yes/); assert.match(refused.err, /Only do this if the user asked/); + assert.ok(await s.row('demo'), 'still there'); + const removed = ok(await s.cli(['rm', 'demo', '--yes'])); + assert.match(removed.out, /Deleted demo/); assert.match(removed.out, /doesn't revoke it/); + assert.equal(await s.row('demo'), undefined); + assert.equal((await fs.readFile(path.join(s.directory, 'credentials.json'), 'utf8')).includes(secret), false, 'the value is gone from the store'); +}); + +test('--var repeats for the custom type, holds its own = signs, and isn\'t secret', async t => { + const s = await sandbox(t); + const added = ok(await s.cli(['add', 'settings', '--type', 'custom', '--var', 'REGION=eu-west-1', '--var', 'FLAGS=a=1,b=2', '--var', 'EMPTY_OK=x'])); + assert.match(added.out, /Commands get: REGION, FLAGS, EMPTY_OK/); + const stored = await s.row('settings'); + assert.deepEqual(stored.fields, { REGION: 'eu-west-1', FLAGS: 'a=1,b=2', EMPTY_OK: 'x' }); assert.deepEqual(stored.secrets, {}); + assert.equal(ok(await s.cli(['run', 'settings', '--', NODE, '-e', 'process.stdout.write(process.env.REGION + " " + process.env.FLAGS)'])).out, 'eu-west-1 a=1,b=2', 'not secret, so not hidden'); + // Alongside secrets from a file. + ok(await s.cli(['add', 'mixed', '--type', 'custom', '--var', 'REGION=eu', '--secret-file', await s.file(`API_KEY=${secret}\nOTHER=fixture-other\n`)])); + const mixed = await s.row('mixed'); + assert.deepEqual([mixed.fields, mixed.secrets], [{ REGION: 'eu' }, { API_KEY: secret, OTHER: 'fixture-other' }]); + for (const [args, message] of [[['--type', 'custom', '--var', 'NO_EQUALS'], /--var NAME=VALUE/], [['--type', 'custom', '--var', '=x'], /--var NAME=VALUE/], [['--type', 'custom', '--var'], /--var NAME=VALUE/], [['--type', 'custom', '--var', 'PATH=/tmp'], /can't be used as a variable name/], [['--type', 'token', '--var', 'A=1', '--secret-file', await s.file(secret)], /only applies to the custom type/], [['--type', 'custom'], /at least one variable/]]) { + const refused = await s.cli(['add', 'refused', ...args]); + assert.equal(refused.code, 1, args.join(' ')); assert.match(refused.err, message, args.join(' ')); + } + assert.equal(await s.row('refused'), undefined); +}); + +test('a .env file becomes a custom credential whose values are all secret', async t => { + const s = await sandbox(t), card = await s.card(`# a fixture\nexport API_KEY=${secret}\nOTHER_VALUE="fixture other value"\n`); + const added = never(ok(await s.cli(['add', 'app-env', '--secret-file', card]))); + assert.match(added.out, /Commands get: API_KEY, OTHER_VALUE/); assert.match(added.out, /deleted the file/); + const stored = await s.row('app-env'); + assert.equal(stored.type, 'custom'); assert.deepEqual(stored.fields, {}); assert.deepEqual(stored.secrets, { API_KEY: secret, OTHER_VALUE: 'fixture other value' }); + const used = never(ok(await s.cli(['run', 'app-env', '--', NODE, '-e', 'process.stdout.write(process.env.API_KEY + " | " + process.env.OTHER_VALUE)']))); + assert.equal(used.out, '[redacted] | [redacted]'); + const refused = await s.cli(['add', 'not-env', '--type', 'custom', '--secret-file', await s.file(secret)]); + assert.equal(refused.code, 1); assert.match(refused.err, /NAME=value lines/); +}); + +test('a .env file with an empty value can still be saved', async t => { + const s = await sandbox(t); + ok(await s.cli(['add', 'app-env', '--secret-file', await s.file(`API_KEY=${secret}\nEMPTY=\n`)])); + assert.equal((await s.row('app-env')).secrets.API_KEY, secret); +}); + +test('several commands saving at once all land, through the store\'s lock', async t => { + const s = await sandbox(t), names = Array.from({ length: 6 }, (_, index) => 'parallel-' + index); + const sources = await Promise.all(names.map(name => s.file(secret + name))); + const answers = await Promise.all(names.map((name, index) => s.cli(['add', name, '--secret-file', sources[index]]))); + for (const answer of answers) ok(answer); + assert.deepEqual((await s.rows()).map(row => row.id).sort(), names); + for (const name of names) assert.equal((await s.row(name)).secrets.token, secret + name); +}); + +test('a recognized secret picks its own type, and --type overrides it', async t => { + const s = await sandbox(t), github = 'ghp_' + 'fixture0000000000000000fixture'; + const added = never(ok(await s.cli(['add', 'github', '--secret-file', await s.file(github)])), github); + assert.match(added.out, /Commands get: GH_TOKEN/); assert.match(added.out, /t3-vault http github GET user/); + const stored = await s.row('github'); + assert.equal(stored.type, 'git'); assert.equal(stored.fields.host, 'github.com'); assert.deepEqual(stored.env, { token: 'GH_TOKEN' }); + ok(await s.cli(['add', 'plain', '--type', 'token', '--secret-file', await s.file(github)])); + assert.deepEqual([(await s.row('plain')).env, (await s.row('plain')).fields, (await s.row('plain')).secrets], [{}, {}, { token: github }], 'asking for another type keeps the value but none of the guess'); + const anthropic = 'sk-ant-' + 'fixture0000000000000000fixture'; + ok(await s.cli(['add', 'claude-key', '--type', 'token', '--secret-file', await s.file(anthropic), '--env', 'MY_KEY'])); + const agreed = await s.row('claude-key'); + assert.equal(agreed.fields.baseUrl, 'https://api.anthropic.com/v1/', 'a type that agrees keeps the guess'); + assert.deepEqual(agreed.env, { token: 'MY_KEY' }, 'and an explicit option beats it'); + ok(await s.cli(['add', 'as-login', '--type', 'login', '--username', 'me', '--secret-file', await s.file(github)])); + assert.deepEqual((await s.row('as-login')).secrets, { password: github }); + ok(await s.cli(['add', 'aws', '--secret-file', await s.file('aws_access_key_id = AKIAFIXTURE000FIXT\naws_secret_access_key = fixtureSecretAccessKey0000\n')])); + assert.equal((await s.row('aws')).type, 'aws'); + const unknown = await s.cli(['add', 'x', '--type', 'magic', '--secret-file', await s.file(secret)]); + assert.equal(unknown.code, 1); assert.match(unknown.err, /no type called magic/); + for (const [name, message] of [['Bad Name', /lowercase/], ['a_b', /lowercase/], ['new', /reserved/]]) assert.match((await s.cli(['add', name, '--secret-file', await s.file(secret)])).err, message, name); +}); + +test('a secret can come from stdin, and a file credential keeps its line breaks and lands as a real file', async t => { + const s = await sandbox(t), content = 'first line of a fixture\nsecond line of a fixture\n'; + ok(await s.cli(['add', 'from-stdin', '--stdin'], { input: secret + '\n' })); + assert.equal((await s.row('from-stdin')).secrets.token, secret); + ok(await s.cli(['add', 'cert', '--type', 'file', '--filename', 'cert.pem', '--secret-file', await s.file(content)])); + assert.equal((await s.row('cert')).secrets.content, content); + const used = ok(await s.cli(['run', 'cert', '--', NODE, '-e', `const fs = require('node:fs'), file = process.env.CERT_FILE; process.stdout.write(JSON.stringify([require('node:path').basename(file), fs.readFileSync(file, 'utf8') === ${JSON.stringify(content)}, file]))`])); + const [name, same, file] = JSON.parse(used.out); + assert.deepEqual([name, same], ['cert.pem', true]); + await assert.rejects(fs.stat(file), { code: 'ENOENT' }); + const large = 'x'.repeat(48 * 1024 + 1), tooBig = await s.cli(['add', 'too-big', '--stdin'], { input: large }); + assert.equal(tooBig.code, 1); assert.match(tooBig.err, /48 KiB/); +}); + +test('run carries exit codes, reports a missing command, and supports renames and several credentials', async t => { + const s = await sandbox(t); + ok(await s.cli(['add', 'one', '--secret-file', await s.file(secret + '-one')])); + ok(await s.cli(['add', 'two', '--secret-file', await s.file(secret + '-two'), '--env', 'ONE_TOKEN'])); + assert.equal((await s.cli(['run', 'one', '--', NODE, '-e', 'process.exit(7)'])).code, 7); + assert.equal((await s.cli(['run', 'one', '--', NODE, '-e', 'process.kill(process.pid, "SIGTERM"); setTimeout(() => {}, 5000)'])).code, 143); + const missing = await s.cli(['run', 'one', '--', path.join(s.root, 'no-such-command')]); + assert.equal(missing.code, 127); assert.match(missing.err, /Command not found/); + const clash = await s.cli(['run', 'one', 'two', '--', NODE, '-e', '']); + assert.equal(clash.code, 1); assert.match(clash.err, /both set ONE_TOKEN/); + const renamed = ok(await s.cli(['run', 'one', 'two=SECOND', '--', NODE, '-e', `process.stdout.write(JSON.stringify([process.env.ONE_TOKEN === ${JSON.stringify(secret + '-one')}, process.env.SECOND === ${JSON.stringify(secret + '-two')}]))`])); + assert.deepEqual(JSON.parse(renamed.out), [true, true]); + for (const args of [['run'], ['run', 'one'], ['run', 'one', '--'], ['run', '--', 'true']]) assert.match((await s.cli(args)).err, /Use t3-vault run NAME/, args.join(' ')); + assert.match((await s.cli(['run', 'one=not-a-var', '--', 'true'])).err, /can't be used as a variable name/); +}); + +test('run gives a command binary output unchanged', async t => { + const s = await sandbox(t); + ok(await s.cli(['add', 'demo', '--secret-file', await s.file(secret)])); + const { bytes } = ok(await s.cli(['run', 'demo', '--', NODE, '-e', 'process.stdout.write(Buffer.from(Array.from({ length: 256 }, (_, i) => i)))'])); + assert.ok(bytes.equals(Buffer.from(Array.from({ length: 256 }, (_, i) => i)))); +}); + +test('--generate-key saves even when the host can\'t be reached, and prints the line to run on it', async t => { + const s = await sandbox(t), port = await closedPort(); + const added = ok(await s.cli(['add', 'new-box', '--host', '127.0.0.1', '--port', String(port), '--user', 'deploy', '--generate-key', '--description', 'A fixture'])); + assert.doesNotMatch(added.out + added.err, /PRIVATE KEY/); + assert.match(added.out, /^Saved new-box\.$/m); assert.match(added.out, /Couldn't reach 127\.0\.0\.1 yet \(unreachable\)/); + const stored = await s.row('new-box'); + assert.equal(stored.fields.auth, 'key'); assert.match(stored.fields.publicKey, /^ssh-ed25519 \S+ t3-vault:new-box$/); assert.match(stored.secrets.privateKey, /^-----BEGIN OPENSSH PRIVATE KEY-----/); + const install = added.out.split('\n').find(line => line.includes('authorized_keys')).trim(); + assert.equal(install, `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '${stored.fields.publicKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`); + assert.deepEqual(stored.origin, { by: 'terminal' }); + assert.equal(await fs.readFile(path.join(s.directory, 'keys/new-box'), 'utf8'), stored.secrets.privateKey); + assert.equal((await fs.stat(path.join(s.directory, 'keys/new-box'))).mode & 0o777, 0o600); + // Plain ssh now reaches it, through the home this subprocess was given. + const ssh = spawnSync('/usr/bin/ssh', ['-G', '-F', path.join(s.home, '.ssh/config'), 'new-box'], { encoding: 'utf8' }); + assert.equal(ssh.status, 0, ssh.stderr); assert.match(ssh.stdout, /^hostname 127\.0\.0\.1$/m); assert.match(ssh.stdout, new RegExp(`^port ${port}$`, 'm')); assert.match(ssh.stdout, /^user deploy$/m); + assert.match(ssh.stdout, new RegExp('^identityfile ' + path.join(s.directory, 'keys/new-box').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$', 'm')); + const listed = ok(await s.cli(['list'])); + assert.match(listed.out, /deploy@127\.0\.0\.1:\d+, key \(not connected yet: run t3-vault test new-box\)/); + assert.match(listed.out, /use: ssh new-box /); + const shown = ok(await s.cli(['show', 'new-box'])); + assert.match(shown.out, /public key: ssh-ed25519/); assert.doesNotMatch(shown.out, /PRIVATE KEY/); + const tested = await s.cli(['test', 'new-box']); + assert.equal(tested.code, 1); assert.match(tested.out, /new-box failed: The server couldn't be reached/); + assert.doesNotMatch((await s.cli(['ssh', 'new-box', '--', 'true'])).err, /PRIVATE KEY/); + assert.equal(JSON.stringify(JSON.parse(ok(await s.cli(['list', '--json'])).out)).includes('PRIVATE KEY'), false); +}); + +test('a private key file gets its own type and its public half, and the passphrase isn\'t kept', async t => { + const s = await sandbox(t), keyFile = path.join(s.root, 'made-key'), passphrase = 'fixture-key-passphrase', port = await closedPort(); + assert.equal(spawnSync('/usr/bin/ssh-keygen', ['-q', '-t', 'ed25519', '-N', passphrase, '-C', 'fixture', '-f', keyFile]).status, 0); + const wanted = (await fs.readFile(keyFile + '.pub', 'utf8')).split(' ').slice(0, 2).join(' '); + const base = ['--host', '127.0.0.1', '--port', String(port), '--user', 'deploy', '--key-file', keyFile]; + const missing = await s.cli(['add', 'with-key', ...base]); + assert.equal(missing.code, 1); assert.match(missing.err, /needs its passphrase/); + assert.equal(await s.row('with-key'), undefined, 'a key that can\'t be opened leaves nothing behind'); + const phrase = await s.file(passphrase + '\n'); + const added = ok(await s.cli(['add', 'with-key', ...base, '--passphrase-file', phrase])); + assert.equal(added.out.includes(passphrase), false); + const stored = await s.row('with-key'); + assert.match(stored.fields.publicKey, new RegExp('^' + wanted.replace(/[+/]/g, '\\$&') + ' t3-vault:with-key$')); + assert.equal(JSON.stringify(stored).includes(passphrase), false); + assert.equal(spawnSync('/usr/bin/ssh-keygen', ['-y', '-P', '', '-f', path.join(s.directory, 'keys/with-key')], { encoding: 'utf8' }).status, 0, 'the saved key opens without one'); + const detected = ok(await s.cli(['add', 'detected-key', '--host', '127.0.0.1', '--port', String(port), '--user', 'deploy', '--secret-file', keyFile, '--passphrase-file', phrase])); + assert.match(detected.out, /Couldn't reach/); + assert.equal((await s.row('detected-key')).fields.auth, 'key'); + const flat = await s.file((await fs.readFile(keyFile, 'utf8')).replace(/\n/g, '')); + ok(await s.cli(['add', 'flat-key', '--host', '127.0.0.1', '--port', String(port), '--user', 'deploy', '--secret-file', flat, '--passphrase-file', phrase])); + assert.equal((await s.row('flat-key')).fields.publicKey.split(' ')[1], wanted.split(' ')[1], 'a key that lost its line breaks in a one-line box still works'); +}); + +test('--user works like --username, and a password sign-in waits for its server to be confirmed', async t => { + const s = await sandbox(t), password = await s.file(secret + '\n'); + ok(await s.cli(['add', 'box-one', '--host', '192.0.2.1', '--username', 'deploy', '--password-file', password])); + const added = never(ok(await s.cli(['add', 'box-two', '--host', '192.0.2.1', '--user', 'deploy', '--password-file', password]))); + for (const name of ['box-one', 'box-two']) { + const stored = await s.row(name); + assert.deepEqual(stored.fields, { host: '192.0.2.1', port: 22, username: 'deploy', auth: 'password' }); + assert.deepEqual(stored.secrets, { password: secret }); + } + assert.equal(await fs.readFile(password, 'utf8'), secret + '\n'); + assert.match(added.out, /can't be used until the server's identity is confirmed/); assert.match(added.out, /t3-vault trust box-two/); + assert.match(ok(await s.cli(['list'])).out, /deploy@192\.0\.2\.1, password \(blocked: run t3-vault trust box-two\)/); + const blocked = never(await s.cli(['ssh', 'box-two', '--', 'true'])); + assert.equal(blocked.code, 1); assert.match(blocked.err, /t3-vault trust box-two/); + assert.match((await s.cli(['add', 'box-three', '--host', '192.0.2.1', '--password-file', password])).err, /valid username/); + assert.match((await s.cli(['add', 'box-three', '--user', 'deploy', '--password-file', password])).err, /hostname or IP/); + assert.match((await s.cli(['add', 'box-three', '--host', '-oProxyCommand=bad', '--user', 'deploy', '--password-file', password])).err, /hostname or IP/); +}); + +test('installing with a password checks the server first, so the password never goes to an unknown one', async t => { + const s = await sandbox(t), port = await closedPort(), password = await s.card(secret); + const unreachable = never(await s.cli(['add', 'box', '--host', '127.0.0.1', '--port', String(port), '--user', 'deploy', '--install-with-password-file', password])); + assert.equal(unreachable.code, 1); assert.match(unreachable.err, /Couldn't reach 127\.0\.0\.1/); + assert.equal(await s.row('box'), undefined); + assert.equal(await fs.readFile(password, 'utf8'), secret, 'the card stays for the next try'); +}); + +test('http calls the saved API, hides the token in what comes back, and reports failure', async t => { + const s = await sandbox(t), seen = []; + const base = await apiServer(t, async (req, res) => { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + seen.push({ url: req.url, method: req.method, auth: req.headers.authorization, body: Buffer.concat(chunks).toString() }); + if (req.url === '/echo') { res.end(JSON.stringify({ auth: req.headers.authorization })); return; } + if (req.url === '/split') { res.write('start ' + secret.slice(0, 9)); setTimeout(() => res.end(secret.slice(9) + ' end'), 40); return; } + if (req.url === '/denied') { res.writeHead(401); res.end('no ' + secret); return; } + if (req.url === '/moved') { res.writeHead(302, { location: 'http://127.0.0.1:1/' }); res.end(); return; } + res.end('ok'); + }); + const added = never(ok(await s.cli(['add', 'demo-api', '--base-url', base, '--test-path', 'echo', '--secret-file', await s.file(secret)]))); + assert.match(added.out, /t3-vault http demo-api GET echo/); + const echoed = never(ok(await s.cli(['http', 'demo-api', 'GET', 'echo']))); + assert.deepEqual(JSON.parse(echoed.out), { auth: 'Bearer [redacted]' }); + assert.equal(seen.at(-1).auth, 'Bearer ' + secret, 'the server did get the real token'); + assert.equal(never(ok(await s.cli(['http', 'demo-api', 'GET', 'split']))).out, 'start [redacted] end'); + const denied = never(await s.cli(['http', 'demo-api', 'GET', 'denied'])); + assert.equal(denied.code, 1); assert.match(denied.err, /HTTP 401/); assert.equal(denied.out, 'no [redacted]'); + const moved = never(await s.cli(['http', 'demo-api', 'GET', 'moved'])); + assert.equal(moved.code, 1); assert.match(moved.err, /redirect/); + ok(await s.cli(['http', 'demo-api', 'POST', 'things', '--stdin'], { input: '{"name":"fixture"}' })); + assert.deepEqual([seen.at(-1).method, seen.at(-1).url, seen.at(-1).body], ['POST', '/things', '{"name":"fixture"}']); + for (const [args, message] of [[['GET', 'x', '--stdin'], /cannot have a body/], [['POST', 'x', '--stdin'], /valid JSON/], [['GET', '/absolute'], /relative/], [['FETCH', 'x'], /Use GET/], [['GET', 'x', '--other'], /Use t3-vault http/]]) { + const refused = await s.cli(['http', 'demo-api', ...args], { input: 'not json' }); + assert.equal(refused.code, 1, args.join(' ')); assert.match(refused.err, message, args.join(' ')); + } + ok(await s.cli(['add', 'scoped-api', '--base-url', base + 'v1', '--secret-file', await s.file(secret)])); + const outside = await s.cli(['http', 'scoped-api', 'GET', '../outside']); + assert.equal(outside.code, 1); assert.match(outside.err, /inside the saved API/); + const tested = never(ok(await s.cli(['test', 'demo-api']))); + assert.match(tested.out, /demo-api works/); + ok(await s.cli(['add', 'no-address', '--secret-file', await s.file(secret)])); + assert.match((await s.cli(['http', 'no-address', 'GET', 'x'])).err, /no API address/); + assert.match(ok(await s.cli(['test', 'no-address'])).out, /no test set up/); +}); + +test('a failed test says what went wrong and what to do', async t => { + const s = await sandbox(t); + const base = await apiServer(t, (_req, res) => { res.writeHead(401); res.end(secret); }); + ok(await s.cli(['add', 'demo-api', '--base-url', base, '--secret-file', await s.file(secret)])); + const tested = never(await s.cli(['test', 'demo-api'])); + assert.equal(tested.code, 1); assert.match(tested.out, /demo-api failed: The service rejected the secret/); assert.match(tested.out, /t3-vault replace demo-api/); assert.match(tested.out, /HTTP 401/); +}); + +test('help, types and mistakes all point somewhere useful', async t => { + const s = await sandbox(t); + for (const args of [[], ['--help'], ['-h'], ['help']]) assert.match(ok(await s.cli(args)).out, /t3-vault run NAME\.\.\. -- COMMAND/); + const types = ok(await s.cli(['types'])).out; + for (const line of ['ssh: SSH server', 'token: API token', 'database: Database', 'git: Git host', 'custom: Custom', '--key-file FILE', '--base-url VALUE', '--var NAME=VALUE']) assert.ok(types.includes(line), line); + const unknown = await s.cli(['nonsense']); + assert.equal(unknown.code, 1); assert.match(unknown.err, /t3-vault --help/); + for (const args of [['show'], ['ssh'], ['http'], ['add'], ['replace'], ['test'], ['trust'], ['use-key'], ['rm']]) { const answer = await s.cli(args); assert.equal(answer.code, 1, args[0]); assert.match(answer.err, /^Use t3-vault /, args[0]); } + assert.match((await s.cli(['ssh', 'x'])).err, /Supply a command/); + ok(await s.cli(['add', 'demo', '--secret-file', await s.file(secret)])); + assert.match((await s.cli(['trust', 'demo'])).err, /isn't an SSH credential/); + assert.match((await s.cli(['ssh', 'demo', '--', 'true'])).err, /Use t3-vault run instead/); + assert.match((await s.cli(['use-key', 'demo'])).err, /isn't an SSH credential/); + const unknownFlag = await s.cli(['add', 'other', '--nonsense', 'x', '--secret-file', await s.file(secret)]); + assert.match(unknownFlag.err, /Unknown option --nonsense/); + assert.match((await s.cli(['add', 'other', '--env', 'ANYTHING', '--type', 'aws', '--access-key-id', 'AKIAFIXTURE', '--secret-file', await s.file(secret)])).err, /fixed variable names/); + assert.match((await s.cli(['add', 'other', '--env', 'PATH', '--secret-file', await s.file(secret)])).err, /name the system relies on/); +}); + +test('reads never change a store, and an unupgraded store says to open Setup', async t => { + const s = await sandbox(t), file = path.join(s.directory, 'credentials.json'); + await fs.mkdir(s.directory, { recursive: true, mode: 0o700 }); + const legacy = JSON.stringify({ version: 2, profiles: [{ id: 'old-api', type: 'api', baseUrl: 'https://example.test/', secret, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', secretChangedAt: null, revision: '0123456789abcdef' }] }); + await fs.writeFile(file, legacy, { mode: 0o600 }); + for (const args of [['list'], ['show', 'old-api'], ['run', 'old-api', '--', NODE, '-e', '']]) { + const answer = await s.cli(args); + assert.equal(answer.code, 1, args[0]); assert.match(answer.err, /Open Setup once/, args[0]); + } + assert.equal(await fs.readFile(file, 'utf8'), legacy); +}); diff --git a/tests/credentials-deliver.test.mjs b/tests/credentials-deliver.test.mjs new file mode 100644 index 0000000..f1e9ace --- /dev/null +++ b/tests/credentials-deliver.test.mjs @@ -0,0 +1,199 @@ +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 { spawnSync } from 'node:child_process'; +import { delivery, deliveredNames, secretValues } from '../docker/setup/credentials/deliver.mjs'; +import { detect } from '../docker/setup/credentials/detect.mjs'; +import { normalize } from '../docker/setup/credentials/model.mjs'; + +const token = 'fixture-token-value-123'; +const given = (input, options) => delivery(normalize(input), { base: {}, ...options }); + +test('a token reaches commands under its own name, a saved override, or a one-off rename', () => { + const base = { id: 'cloudflare-token', type: 'token', secrets: { token } }; + assert.deepEqual(given(base), { env: { CLOUDFLARE_TOKEN: token }, files: [], masks: [token] }, 'not CLOUDFLARE_TOKEN_TOKEN'); + assert.deepEqual(given({ ...base, id: 'stripe' }).env, { STRIPE_TOKEN: token }); + assert.deepEqual(given({ ...base, env: { token: 'CLOUDFLARE_API_TOKEN' } }).env, { CLOUDFLARE_API_TOKEN: token }); + assert.deepEqual(given({ ...base, env: { token: 'CLOUDFLARE_API_TOKEN' } }, { rename: 'ONE_OFF' }).env, { ONE_OFF: token }, 'a rename beats the saved name'); + assert.deepEqual(deliveredNames(normalize({ ...base, env: { token: 'CLOUDFLARE_API_TOKEN' } })), ['CLOUDFLARE_API_TOKEN']); + // The address is only for t3-vault http and Setup's test, so commands don't get it. + assert.deepEqual(given({ ...base, fields: { baseUrl: 'https://api.example.test/', authType: 'header', authName: 'X-Key' } }).env, { CLOUDFLARE_TOKEN: token }); +}); + +test('a login gives a username, a password and, when saved, an address', () => { + const input = { id: 'prod-admin', type: 'login', fields: { username: 'fixture-admin' }, secrets: { password: 'fixture-password' } }; + assert.deepEqual(given(input).env, { PROD_ADMIN_USERNAME: 'fixture-admin', PROD_ADMIN_PASSWORD: 'fixture-password' }); + assert.deepEqual(given({ ...input, fields: { ...input.fields, url: 'https://example.test' } }).env.PROD_ADMIN_URL, 'https://example.test'); + assert.deepEqual(given({ ...input, env: { username: 'ADMIN_USER', password: 'ADMIN_PASS' } }).env, { ADMIN_USER: 'fixture-admin', ADMIN_PASS: 'fixture-password' }); + assert.deepEqual(given(input, { rename: 'ONLY_THE_PASSWORD' }).env, { PROD_ADMIN_USERNAME: 'fixture-admin', ONLY_THE_PASSWORD: 'fixture-password' }); + assert.deepEqual(given(input).masks, ['fixture-password'], 'a username is not secret'); +}); + +test('a database URL has everything percent-encoded, and detection reads it back exactly', () => { + const password = 'p@ss/w:rd#1 x%', input = { id: 'app-db', type: 'database', fields: { engine: 'postgres', host: 'db.example.net', port: 5433, database: 'my db', username: 'app user', options: 'sslmode=require' }, secrets: { password } }; + const { env, files, masks } = given(input); + assert.equal(env.DATABASE_URL, 'postgres://app%20user:p%40ss%2Fw%3Ard%231%20x%25@db.example.net:5433/my%20db?sslmode=require'); + assert.deepEqual(env, { DATABASE_URL: env.DATABASE_URL, PGHOST: 'db.example.net', PGPORT: '5433', PGUSER: 'app user', PGPASSWORD: password, PGDATABASE: 'my db' }); + assert.deepEqual(files, []); assert.deepEqual(masks, [password]); + const parsed = new URL(env.DATABASE_URL); + assert.equal(decodeURIComponent(parsed.password), password); assert.equal(parsed.hostname, 'db.example.net'); + const back = detect(env.DATABASE_URL); + assert.equal(back.type, 'database'); assert.deepEqual(back.secrets, { password }); assert.deepEqual(back.fields, input.fields); + assert.deepEqual(deliveredNames(normalize(input)), ['DATABASE_URL', 'PGHOST', 'PGPORT', 'PGUSER', 'PGPASSWORD', 'PGDATABASE']); +}); + +test('a rediss address stays encrypted through detection and delivery', () => { + const found = detect('rediss://:fixture-pass@cache.example.test:6380/0'), { env } = given({ id: 'cache', type: found.type, fields: found.fields, secrets: found.secrets }); + assert.match(env.DATABASE_URL, /^rediss:/); +}); + +test('a mongodb+srv address stays a DNS seed list through detection and delivery', () => { + const found = detect('mongodb+srv://fixture:fixture-pass@cluster.example.test/app'), { env } = given({ id: 'app', type: found.type, fields: found.fields, secrets: found.secrets }); + assert.match(env.DATABASE_URL, /^mongodb\+srv:/); +}); + +test('each database engine gets the variables its own tools read', () => { + const engine = (name, fields = {}) => given({ id: 'db', type: 'database', fields: { engine: name, host: 'db.example.net', ...fields }, secrets: { password: 'fixture-pass' } }).env; + assert.deepEqual(engine('postgres'), { DATABASE_URL: 'postgres://:fixture-pass@db.example.net/', PGHOST: 'db.example.net', PGPORT: '5432', PGPASSWORD: 'fixture-pass' }); + assert.deepEqual(engine('mysql', { database: 'shop', username: 'root' }), { DATABASE_URL: 'mysql://root:fixture-pass@db.example.net/shop', MYSQL_HOST: 'db.example.net', MYSQL_TCP_PORT: '3306', MYSQL_PWD: 'fixture-pass' }); + assert.deepEqual(engine('redis', { port: 6380 }), { DATABASE_URL: 'redis://:fixture-pass@db.example.net:6380/', REDISCLI_AUTH: 'fixture-pass' }); + assert.deepEqual(engine('mongodb'), { DATABASE_URL: 'mongodb://:fixture-pass@db.example.net/' }); + assert.equal(engine('postgres', { host: '2001:db8::1', port: 5432 }).DATABASE_URL, 'postgres://:fixture-pass@[2001:db8::1]:5432/', 'an IPv6 host needs brackets'); +}); + +test('AWS gives the standard variables, with the region under both names', () => { + const input = { id: 'aws', type: 'aws', fields: { accessKeyId: 'AKIAFIXTURE', region: 'eu-west-1' }, secrets: { secretAccessKey: 'fixture-secret-key', sessionToken: 'fixture-session' } }; + assert.deepEqual(given(input), { env: { AWS_ACCESS_KEY_ID: 'AKIAFIXTURE', AWS_REGION: 'eu-west-1', AWS_SECRET_ACCESS_KEY: 'fixture-secret-key', AWS_SESSION_TOKEN: 'fixture-session', AWS_DEFAULT_REGION: 'eu-west-1' }, files: [], masks: ['fixture-secret-key', 'fixture-session'] }); + assert.deepEqual(given({ id: 'aws', type: 'aws', fields: { accessKeyId: 'AKIAFIXTURE' }, secrets: { secretAccessKey: 'fixture-secret-key' } }).env, { AWS_ACCESS_KEY_ID: 'AKIAFIXTURE', AWS_SECRET_ACCESS_KEY: 'fixture-secret-key' }, 'nothing is invented for what was not saved'); + assert.deepEqual(deliveredNames(normalize(input)).sort(), ['AWS_ACCESS_KEY_ID', 'AWS_DEFAULT_REGION', 'AWS_REGION', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']); +}); + +test('a Google Cloud key is delivered as a file, never as a variable', () => { + const json = JSON.stringify({ type: 'service_account', private_key: 'fixture-private-key' }); + const { env, files, masks } = given({ id: 'gcp', type: 'gcp', fields: { project: 'fixture-project', clientEmail: 'fixture@example.test' }, secrets: { serviceAccount: json } }); + assert.deepEqual(env, { GOOGLE_CLOUD_PROJECT: 'fixture-project' }); + assert.deepEqual(files, [{ env: 'GOOGLE_APPLICATION_CREDENTIALS', name: 'service-account.json', content: json }]); + assert.deepEqual(masks, [json]); + assert.deepEqual(deliveredNames(normalize({ id: 'gcp', type: 'gcp', fields: { project: 'fixture-project' }, secrets: { serviceAccount: json } })), ['GOOGLE_CLOUD_PROJECT', 'GOOGLE_APPLICATION_CREDENTIALS']); +}); + +test('a kubeconfig is delivered as a file', () => { + const config = 'apiVersion: v1\nkind: Config\nclusters: []\n', input = { id: 'kube', type: 'kubernetes', fields: {}, secrets: { kubeconfig: config }, env: {} }; + const { env, files } = delivery(input); + assert.deepEqual(env, {}); + assert.deepEqual(files, [{ env: 'KUBECONFIG', name: 'kubeconfig', content: config }]); + assert.deepEqual(deliveredNames(input), ['KUBECONFIG']); +}); + +test('the file type names its file and its variable', () => { + const content = 'fixture certificate text\nsecond line\n', input = { id: 'tls-cert', type: 'file', fields: { filename: 'cert.pem' }, secrets: { content } }; + assert.deepEqual(given(input), { env: {}, files: [{ env: 'TLS_CERT_FILE', name: 'cert.pem', content }], masks: [content] }); + assert.equal(given({ id: 'tls-cert', type: 'file', secrets: { content } }).files[0].name, 'credential'); + assert.equal(given({ ...input, env: { content: 'CERT_PATH' } }).files[0].env, 'CERT_PATH'); + assert.equal(given(input, { rename: 'ONE_OFF' }).files[0].env, 'ONE_OFF'); + assert.deepEqual(deliveredNames(normalize(input)), ['TLS_CERT_FILE']); +}); + +test('custom gives each variable under its own name and hides only the secret ones', () => { + const input = { id: 'mixed', type: 'custom', fields: { REGION: 'eu-west-1' }, secrets: { API_KEY: 'fixture-key', OTHER: 'fixture-other' } }; + assert.deepEqual(given(input), { env: { REGION: 'eu-west-1', API_KEY: 'fixture-key', OTHER: 'fixture-other' }, files: [], masks: ['fixture-key', 'fixture-other'] }); + assert.deepEqual(deliveredNames(normalize(input)), ['REGION', 'API_KEY', 'OTHER']); +}); + +test('a registry gives its token and optional username', () => { + const input = { id: 'npm', type: 'registry', fields: { username: 'fixture-user' }, secrets: { token }, env: { token: 'NPM_TOKEN' } }; + assert.deepEqual(given(input).env, { NPM_USERNAME: 'fixture-user', NPM_TOKEN: token }); +}); + +test('an SSH password goes through askpass, and an SSH key through nothing a command can see', () => { + const password = { id: 'box', type: 'ssh', fields: { host: 'example.test', username: 'deploy', auth: 'password' }, secrets: { password: 'fixture-pass' } }; + const askpass = '/synthetic/askpass.sh'; + assert.deepEqual(given(password, { askpass }), { env: { SSH_ASKPASS: askpass, SSH_ASKPASS_REQUIRE: 'force', DISPLAY: 't3-vault', T3_CREDENTIAL_PASSWORD: 'fixture-pass' }, files: [], masks: ['fixture-pass'] }); + assert.equal(given(password, { askpass, base: { DISPLAY: ':1' } }).env.DISPLAY, ':1', 'a real display is left alone'); + assert.deepEqual(deliveredNames(normalize(password)), [], 'the plumbing isn\'t something to tell an agent about'); + const key = { id: 'box', type: 'ssh', fields: { host: 'example.test', username: 'deploy', auth: 'key' }, secrets: { privateKey: 'fixture-key-text\n' } }; + assert.deepEqual(given(key, { askpass }), { env: {}, files: [], masks: ['fixture-key-text\n'] }); + assert.deepEqual(deliveredNames(normalize(key)), []); +}); + +test('deliveredNames needs no real secrets, so it can\'t leak any', () => { + const credential = normalize({ id: 'app-db', type: 'database', fields: { engine: 'postgres', host: 'x.test' }, secrets: { password: 'fixture-pass' } }); + assert.equal(JSON.stringify(deliveredNames(credential)).includes('fixture-pass'), false); + assert.deepEqual(secretValues(credential), ['fixture-pass']); + assert.deepEqual(secretValues({ secrets: { a: '', b: undefined, c: 'kept' } }), ['kept']); + assert.deepEqual(secretValues({}), []); + assert.deepEqual(deliveredNames({ id: 'x', type: 'token', fields: {} }), [], 'a public row with no secrets also works'); +}); + +// ---- git, for real ---------------------------------------------------------- +async function gitHome(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-git-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return { cwd: root, env: { PATH: process.env.PATH, HOME: root, XDG_CONFIG_HOME: path.join(root, 'config'), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } }; +} +const git = ({ cwd, env }, extra, args, input) => spawnSync('git', args, { cwd, env: { ...env, ...extra }, input, encoding: 'utf8', timeout: 10000 }); +const fill = (context, extra, host = 'github.com') => git(context, extra, ['credential', 'fill'], `protocol=https\nhost=${host}\n\n`); +const answers = text => Object.fromEntries(text.trim().split('\n').filter(Boolean).map(line => { const at = line.indexOf('='); return [line.slice(0, at), line.slice(at + 1)]; })); +const github = { id: 'github', type: 'git', fields: { host: 'github.com', username: 'fixture-user' }, secrets: { token } }; + +test('git asks the delivered helper and gets the saved username and token', async t => { + const context = await gitHome(t), { env } = given(github); + const answer = fill(context, env); + assert.equal(answer.status, 0, answer.stderr); + assert.deepEqual(answers(answer.stdout), { protocol: 'https', host: 'github.com', username: 'fixture-user', password: token }); + assert.equal(env.GH_TOKEN, undefined); assert.equal(env.GIT_TOKEN, token); + assert.equal(env.GIT_TERMINAL_PROMPT, '0', 'git never stops to ask a person'); + assert.equal(Object.entries(env).filter(([name]) => name.startsWith('GIT_CONFIG_')).some(([, value]) => value.includes(token)), false, 'the token is never in config, which ps could show'); + assert.deepEqual(deliveredNames(normalize(github)), ['GIT_TOKEN'], 'git\'s plumbing is hidden from the list'); +}); + +test('with no username saved, git gets the one every host accepts for a token', async t => { + const context = await gitHome(t), { env } = given({ ...github, fields: { host: 'github.com' } }); + assert.equal(answers(fill(context, env).stdout).username, 'x-access-token'); +}); + +test('the token goes only to the saved host', async t => { + const context = await gitHome(t), { env } = given(github); + const answer = fill(context, env, 'gitlab.com'); + assert.notEqual(answer.status, 0); + assert.equal((answer.stdout + answer.stderr).includes(token), false); + assert.match(answer.stderr, /terminal prompts disabled|could not read/i); + const plain = git(context, env, ['credential', 'fill'], 'protocol=http\nhost=github.com\n\n'); + assert.notEqual(plain.status, 0, 'and only over HTTPS'); + assert.equal((plain.stdout + plain.stderr).includes(token), false); +}); + +test('an existing GIT_CONFIG_COUNT is added to, so the caller\'s settings keep working', async t => { + const context = await gitHome(t); + const base = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'user.name', GIT_CONFIG_VALUE_0: 'Fixture Person' }; + const { env } = given(github, { base }); + assert.equal(env.GIT_CONFIG_COUNT, '3'); + assert.equal(Object.hasOwn(env, 'GIT_CONFIG_KEY_0'), false, 'the caller\'s own entry is left where it is'); + const merged = { ...base, ...env }; + assert.equal(git(context, merged, ['config', '--get', 'user.name']).stdout.trim(), 'Fixture Person'); + assert.equal(answers(fill(context, merged).stdout).password, token); + // A nonsense count is treated as none rather than producing a broken config. + assert.equal(given(github, { base: { GIT_CONFIG_COUNT: 'lots' } }).env.GIT_CONFIG_COUNT, '2'); +}); + +test('a credential helper someone already has doesn\'t get asked for this host', async t => { + const context = await gitHome(t), marker = path.join(context.cwd, 'asked'); + const base = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: `!f() { echo called >> "${marker}"; echo username=wrong; echo password=wrong; }; f` }; + const { env } = given(github, { base }); + const answer = answers(fill(context, { ...base, ...env }).stdout); + assert.deepEqual([answer.username, answer.password], ['fixture-user', token]); + await assert.rejects(fs.stat(marker), { code: 'ENOENT' }); + assert.equal(answers(fill(context, { ...base, ...env }, 'example.test').stdout).password, 'wrong', 'other hosts still use it'); +}); + +test('two git credentials can be used together, each answering for its own host', async t => { + const context = await gitHome(t); + const first = { ...github, id: 'github-one' }, second = { ...github, id: 'gitlab-two', fields: { host: 'gitlab.com', username: 'oauth2' }, secrets: { token: 'fixture-second-git-token' } }; + const firstEnv = delivery(first, { base: {} }).env, secondEnv = delivery(second, { base: firstEnv }).env; + const env = { ...firstEnv, ...secondEnv }; + assert.equal(answers(fill(context, env).stdout).password, first.secrets.token); + assert.equal(answers(fill(context, env, 'gitlab.com').stdout).password, second.secrets.token); + assert.equal(answers(fill(context, env, 'gitlab.com').stdout).username, 'oauth2'); +}); diff --git a/tests/credentials-detect.test.mjs b/tests/credentials-detect.test.mjs new file mode 100644 index 0000000..f03a318 --- /dev/null +++ b/tests/credentials-detect.test.mjs @@ -0,0 +1,142 @@ +// What a pasted value is taken for. Every value here is an obvious fixture, and +// the ones shaped like a real provider's token are assembled at run time so a +// secret scanner reading this file has nothing to match. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { detect } from '../docker/setup/credentials/detect.mjs'; +import { normalize } from '../docker/setup/credentials/model.mjs'; + +const FILL = 'fixture0000000000000000fixture'; +const keyLines = [['-----BEGIN', 'OPENSSH PRIVATE KEY-----'].join(' '), 'Zml4dHVyZS1wcml2YXRlLWtleS1maXJzdC1saW5lLW9mLXRoZS1ib2R5LXRoYXQtaXMtbG9u', 'Zy1lbm91Z2gtdG8td3JhcA==', ['-----END', 'OPENSSH PRIVATE KEY-----'].join(' ')]; +const accessKey = 'AKIA' + 'FIXTURE00000FIXT', secretKey = 'fixtureSecretAccessKey/0000+fixture='; + +const tokens = [ + ['a classic GitHub token', 'ghp_' + FILL, 'git', 'github', 'GH_TOKEN', { host: 'github.com', username: 'x-access-token', baseUrl: 'https://api.github.com/', testPath: 'user', authType: 'bearer' }], + ['a GitHub server token', 'ghs_' + FILL, 'git', 'github', 'GH_TOKEN', { host: 'github.com' }], + ['a fine-grained GitHub token', 'github_pat_' + FILL, 'git', 'github', 'GH_TOKEN', { host: 'github.com' }], + ['a GitLab token', 'glpat-' + FILL, 'git', 'gitlab', 'GITLAB_TOKEN', { host: 'gitlab.com', username: 'oauth2', baseUrl: 'https://gitlab.com/api/v4/', authType: 'header', authName: 'PRIVATE-TOKEN', testPath: 'user' }], + ['an Anthropic key', 'sk-ant-' + FILL, 'token', 'anthropic', 'ANTHROPIC_API_KEY', { baseUrl: 'https://api.anthropic.com/v1/', authType: 'header', authName: 'x-api-key', testPath: 'models' }], + ['an OpenAI key', 'sk-' + FILL, 'token', 'openai', 'OPENAI_API_KEY', { baseUrl: 'https://api.openai.com/v1/', authType: 'bearer', testPath: 'models' }], + ['a Stripe live key', 'sk_' + 'live_' + FILL, 'token', 'stripe', 'STRIPE_API_KEY', { baseUrl: 'https://api.stripe.com/v1/', authType: 'bearer', testPath: 'balance' }], + ['a Stripe restricted test key', 'rk_' + 'test_' + FILL, 'token', 'stripe', 'STRIPE_API_KEY', { baseUrl: 'https://api.stripe.com/v1/' }], + ['a Slack bot token', 'xoxb-' + FILL, 'token', 'slack', 'SLACK_TOKEN', { baseUrl: 'https://slack.com/api/', testPath: 'auth.test' }], + ['a Slack user token', 'xoxp-' + FILL, 'token', 'slack', 'SLACK_TOKEN', { baseUrl: 'https://slack.com/api/' }], + ['an npm token', 'npm_' + FILL, 'registry', 'npm', 'NPM_TOKEN', { baseUrl: 'https://registry.npmjs.org/', testPath: '-/whoami' }], +]; +for (const [name, value, type, id, env, fields] of tokens) test('detects ' + name, () => { + for (const pasted of [value, ' ' + value + '\n']) { + const found = detect(pasted); + assert.equal(found.type, type); assert.equal(found.id, id); assert.equal(found.confident, true); + assert.deepEqual(found.secrets, { token: value }); + assert.deepEqual(found.env, { token: env }); + for (const [key, expected] of Object.entries(fields)) assert.equal(found.fields[key], expected, key); + assert.match(found.label, /^an? /); + } + // Whatever detection suggests has to be something the store accepts as it is. + const { fields: suggested, secrets, env: names } = detect(value), saved = normalize({ id, type, fields: suggested, secrets, env: names }); + assert.equal(saved.secrets.token, value); + assert.equal(saved.env.token, env); +}); + +test('a token too short to be a real one of its kind falls back to a plain token', () => { + for (const value of ['ghp_short', 'sk-short', 'glpat-short', 'npm_short', 'xoxb-short']) { + const result = detect(value); + assert.equal(result.type, 'token'); assert.equal(result.confident, false); assert.equal(result.id, ''); + assert.deepEqual(result.secrets, { token: value }); + } +}); + +test('detects an OpenSSH private key with its line breaks, and with them removed', () => { + const normal = keyLines.join('\n') + '\n'; + for (const [name, pasted] of [['as a file', normal], ['with Windows line ends', keyLines.join('\r\n')], ['with spaces for line breaks', keyLines.join(' ')], ['on one line', keyLines.join('')]]) { + const result = detect(pasted); + assert.equal(result.type, 'ssh', name); assert.equal(result.confident, true, name); assert.equal(result.id, '', name); + assert.deepEqual(result.fields, { auth: 'key' }, name); + assert.deepEqual(Object.keys(result.secrets), ['privateKey'], name); + assert.equal(result.secrets.privateKey.split('\n')[0], keyLines[0], name); + assert.equal(result.secrets.privateKey.split('\n').at(-2), keyLines.at(-1), name); + assert.equal(result.secrets.privateKey.endsWith('\n'), true, name); + assert.ok(result.secrets.privateKey.split('\n').every(line => line.length <= 70 || line.startsWith('-----')), name); + } + assert.equal(detect(keyLines.join('')).secrets.privateKey, detect(keyLines.join(' ')).secrets.privateKey); +}); + +test('detects a Google Cloud service account key and keeps the JSON exactly', () => { + const key = JSON.stringify({ type: 'service_account', project_id: 'Fixture_Project.1', client_email: 'fixture@fixture-project.iam.gserviceaccount.com', private_key: 'fixture-private-key-text' }, null, 2); + const result = detect(key + '\n'); + assert.equal(result.type, 'gcp'); assert.equal(result.confident, true); assert.equal(result.id, 'fixture-project-1'); + assert.deepEqual(result.fields, { project: 'Fixture_Project.1', clientEmail: 'fixture@fixture-project.iam.gserviceaccount.com' }); + assert.deepEqual(result.secrets, { serviceAccount: key }); + assert.equal(normalize({ id: result.id, type: result.type, fields: result.fields, secrets: result.secrets }).secrets.serviceAccount, key); + assert.equal(detect(JSON.stringify({ type: 'service_account', private_key: 'fixture' })).id, 'google-cloud'); + for (const other of [{ type: 'authorized_user', private_key: 'fixture' }, { type: 'service_account' }]) assert.notEqual(detect(JSON.stringify(other, null, 2)).type, 'gcp'); +}); + +test('detects a kubeconfig and leaves other YAML alone', () => { + const config = 'apiVersion: v1\nkind: Config\nclusters:\n- name: fixture\n cluster:\n server: https://example.test\nusers:\n- name: fixture\n user:\n token: fixture-kube-token\n'; + const result = detect(config); + assert.equal(result.type, 'kubernetes'); assert.equal(result.id, 'kubernetes'); assert.equal(result.confident, true); + assert.deepEqual(result.secrets, { kubeconfig: config.trim() }); + for (const other of ['apiVersion: v1\nkind: Pod\nmetadata:\n name: fixture\n', 'apiVersion: v1\nkind: Config\nusers: []\n']) assert.notEqual(detect(other).type, 'kubernetes', other); +}); + +test('detects database URLs and decodes percent-encoded parts', () => { + const postgres = detect('postgres://app%20user:p%40ss%2Fw%3Ard%25@db.example.net:5433/my_db?sslmode=require'); + assert.equal(postgres.type, 'database'); assert.equal(postgres.confident, true); assert.equal(postgres.id, 'my-db-db'); assert.match(postgres.label, /PostgreSQL/); + assert.deepEqual(postgres.fields, { engine: 'postgres', host: 'db.example.net', port: 5433, database: 'my_db', username: 'app user', options: 'sslmode=require' }); + assert.deepEqual(postgres.secrets, { password: 'p@ss/w:rd%' }); + const mysql = detect('mysql://root:fixture%21pass@127.0.0.1:3306/shop'); + assert.equal(mysql.fields.engine, 'mysql'); assert.equal(mysql.fields.host, '127.0.0.1'); assert.equal(mysql.id, 'shop-db'); assert.match(mysql.label, /MySQL/); + assert.equal(mysql.fields.port, 3306, 'even when it is the default'); + assert.deepEqual(mysql.secrets, { password: 'fixture!pass' }); + for (const [url, engine] of [['postgresql://h/d', 'postgres'], ['mariadb://h/d', 'mysql'], ['mongodb://h/d', 'mongodb'], ['mongodb+srv://h/d', 'mongodb'], ['redis://h/0', 'redis'], ['rediss://h/0', 'redis']]) assert.equal(detect(url).fields.engine, engine, url); + assert.equal(detect('redis://:fixture-pass@cache.example.net').id, 'redis', 'no database name means the engine names it'); + assert.deepEqual(detect('postgres://only-host.example.net/db').secrets, {}, 'no password means nothing secret'); + // A bad escape is kept as written rather than lost. + assert.deepEqual(detect('postgres://u:100%pass@h/d').secrets, { password: '100%pass' }); + assert.notEqual(detect('https://example.com/path').type, 'database'); +}); + +test('detects AWS credentials as a credentials block and as variables', () => { + const asFile = detect(`[default]\naws_access_key_id = ${accessKey}\naws_secret_access_key = ${secretKey}\nregion = eu-west-1\n`); + assert.equal(asFile.type, 'aws'); assert.equal(asFile.id, 'aws'); assert.equal(asFile.confident, true); + assert.deepEqual(asFile.fields, { accessKeyId: accessKey, region: 'eu-west-1' }); + assert.deepEqual(asFile.secrets, { secretAccessKey: secretKey }); + const asVariables = detect(`export AWS_ACCESS_KEY_ID="${accessKey}"\nexport AWS_SECRET_ACCESS_KEY="${secretKey}"\nexport AWS_SESSION_TOKEN=fixtureSessionToken\nAWS_DEFAULT_REGION=us-east-1\n`); + assert.deepEqual(asVariables.fields, { accessKeyId: accessKey, region: 'us-east-1' }); + assert.deepEqual(asVariables.secrets, { secretAccessKey: secretKey, sessionToken: 'fixtureSessionToken' }); + const bare = detect(accessKey); + assert.equal(bare.type, 'aws'); assert.deepEqual(bare.fields, { accessKeyId: accessKey }); assert.deepEqual(bare.secrets, {}, 'an access key ID is not the secret half'); + assert.equal(normalize({ id: 'aws', type: 'aws', fields: asVariables.fields, secrets: asVariables.secrets }).secrets.sessionToken, 'fixtureSessionToken'); + assert.notEqual(detect(`aws_access_key_id = ${accessKey}\nnothing else here`).type, 'aws'); +}); + +test('detects .env lines, treating every value as secret', () => { + const result = detect('# a comment\nAPI_KEY=fixture-key\n\nexport REGION="eu-west-1"\nQUOTED=\'with space\'\nURL=https://example.com/?a=b\n'); + assert.equal(result.type, 'custom'); assert.equal(result.confident, true); assert.equal(result.id, ''); + assert.deepEqual(result.fields, {}, 'nothing is shown on the page until someone says it may be'); + assert.deepEqual(result.secrets, { API_KEY: 'fixture-key', REGION: 'eu-west-1', QUOTED: 'with space', URL: 'https://example.com/?a=b' }); + assert.deepEqual(detect('export ONLY_ONE=fixture').secrets, { ONLY_ONE: 'fixture' }); + assert.equal(detect('ONLY_ONE=fixture').type, 'token', 'one line without export is most likely a value that has an equals sign in it'); + assert.equal(detect('fixtureBase64Value==').type, 'token'); + assert.equal(detect('API_KEY=fixture\nnot a variable\n').type, 'file', 'one bad line means this isn\'t an env file'); +}); + +test('anything else gets a low-confidence guess the person can correct', () => { + const token = detect('fixture-opaque-value'); + assert.deepEqual([token.type, token.confident, token.id, token.label], ['token', false, '', 'a token']); assert.deepEqual(token.secrets, { token: 'fixture-opaque-value' }); + const words = detect('correct horse battery fixture'); + assert.deepEqual([words.type, words.confident, words.label], ['login', false, 'a password']); assert.deepEqual(words.secrets, { password: 'correct horse battery fixture' }); + const file = detect('first line of a fixture\nsecond line of a fixture\n'); + assert.deepEqual([file.type, file.confident, file.label], ['file', false, 'a file']); assert.deepEqual(file.secrets, { content: 'first line of a fixture\nsecond line of a fixture\n' }); + for (const result of [token, words, file]) { assert.deepEqual(result.env, {}); assert.deepEqual(result.fields, {}); } +}); + +test('nothing pasted is nothing detected', () => { + for (const empty of ['', ' ', '\n\t\n', null, undefined]) assert.equal(detect(empty), null); +}); + +test('a non-string is read as text instead of throwing', () => { + assert.equal(detect(1234567890).type, 'token'); + assert.equal(detect({}).type, 'login'); +}); diff --git a/tests/credentials-keys.test.mjs b/tests/credentials-keys.test.mjs new file mode 100644 index 0000000..9dbeef5 --- /dev/null +++ b/tests/credentials-keys.test.mjs @@ -0,0 +1,144 @@ +// Real OpenSSH with throwaway keys made in a temporary folder. +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 { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { rebuildPrivateKey, openPrivateKey, generateKey, installCommand } from '../docker/setup/credentials/keys.mjs'; + +const KEYGEN = '/usr/bin/ssh-keygen', run = promisify(execFile); +const passphrase = 'fixture-key-passphrase'; +async function folder(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-keys-test-')); + await fs.chmod(root, 0o700); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} +/** Makes a key with ssh-keygen itself, the way a person would have one. */ +async function makeKey(root, { type = 'ed25519', phrase = '', format } = {}) { + const file = path.join(root, 'made-' + Math.random().toString(16).slice(2)); + await run(KEYGEN, ['-q', '-t', type, '-N', phrase, '-C', 'fixture', ...(format ? ['-m', format] : []), '-f', file]); + return { file, privateKey: await fs.readFile(file, 'utf8'), publicKey: (await fs.readFile(file + '.pub', 'utf8')).trim().split(/\s+/).slice(0, 2).join(' ') }; +} +const publicOf = async (root, text, phrase = '') => { + const file = path.join(root, 'check-' + Math.random().toString(16).slice(2)); + await fs.writeFile(file, text, { mode: 0o600, flag: 'wx' }); + const { stdout } = await run(KEYGEN, ['-y', '-P', phrase, '-f', file]); + return stdout.trim().split(/\s+/).slice(0, 2).join(' '); +}; +const flatten = text => text.replace(/\r?\n/g, ''); + +test('a key whose line breaks were removed rebuilds into one OpenSSH derives the same public key from', async t => { + const root = await folder(t), made = await makeKey(root); + for (const [name, pasted] of [['no breaks', flatten(made.privateKey)], ['spaces for breaks', made.privateKey.replace(/\n/g, ' ')], ['Windows line ends', made.privateKey.replace(/\n/g, '\r\n')]]) { + const rebuilt = rebuildPrivateKey(pasted); + assert.ok(rebuilt, name); + assert.equal(await publicOf(root, rebuilt), made.publicKey, name); + assert.equal(rebuilt, made.privateKey, name + ' gives back the file ssh-keygen wrote'); + } +}); + +test('other key formats rebuild too', async t => { + const root = await folder(t); + for (const [type, format] of [['rsa', 'PEM'], ['rsa', 'PKCS8'], ['ecdsa', 'PEM']]) { + const made = await makeKey(root, { type, format }); + assert.equal(await publicOf(root, rebuildPrivateKey(flatten(made.privateKey))), made.publicKey, type + ' ' + format); + } +}); + +test('an old-style encrypted key keeps its header lines, which can\'t be rebuilt', async t => { + const root = await folder(t), made = await makeKey(root, { type: 'rsa', format: 'PEM', phrase: passphrase }); + assert.match(made.privateKey, /Proc-Type:/); + assert.equal(rebuildPrivateKey(made.privateKey), made.privateKey); + assert.equal(rebuildPrivateKey(made.privateKey.replace(/\n/g, '\r\n')), made.privateKey, 'Windows line ends are still fine'); + assert.equal(rebuildPrivateKey(flatten(made.privateKey)), null, 'flattening one loses it for good'); +}); + +test('things that aren\'t keys don\'t rebuild', () => { + for (const value of ['', 'just text', 'ssh-ed25519 AAAA fixture', '-----BEGIN CERTIFICATE-----\nZml4dHVyZQ==\n-----END CERTIFICATE-----', ['-----BEGIN', 'OPENSSH PRIVATE KEY-----', 'not base64!!!', '-----END', 'OPENSSH PRIVATE KEY-----'].join(' '), ['-----BEGIN', 'OPENSSH PRIVATE KEY-----'].join(' ')]) { + assert.equal(rebuildPrivateKey(value), null, value); + } +}); + +test('a generated key is a working ed25519 pair with the comment asked for, and leaves nothing behind', async t => { + const root = await folder(t), { execute } = await import('../docker/setup/credentials/hostkeys.mjs'); let directory; + const made = await generateKey('t3-vault:fixture', { run: (program, args, options) => { directory = path.dirname(args.at(-1)); return execute(program, args, options); } }); + assert.match(made.privateKey, /^-----BEGIN OPENSSH PRIVATE KEY-----\n/); + assert.match(made.publicKey, /^ssh-ed25519 \S+ t3-vault:fixture$/); + assert.equal(await publicOf(root, made.privateKey), made.publicKey.split(' ').slice(0, 2).join(' '), 'needs no passphrase and matches its public half'); + const other = await generateKey('t3-vault:fixture'); + assert.notEqual(other.privateKey, made.privateKey); + await assert.rejects(fs.stat(directory), { code: 'ENOENT' }); +}); + +test('a key that doesn\'t need a passphrase opens as it is, with a public half and the comment asked for', async t => { + const root = await folder(t), made = await makeKey(root); + const opened = await openPrivateKey(made.privateKey, { comment: 't3-vault:fixture' }); + assert.equal(opened.privateKey, made.privateKey); + assert.equal(opened.publicKey, made.publicKey + ' t3-vault:fixture'); + assert.equal((await openPrivateKey(flatten(made.privateKey))).privateKey, made.privateKey, 'a flattened paste opens too'); + assert.equal((await openPrivateKey(made.privateKey)).publicKey, made.publicKey, 'no comment, none added'); +}); + +test('a key with a passphrase opens with it, and the result no longer needs one', async t => { + const root = await folder(t), made = await makeKey(root, { phrase: passphrase }); + await assert.rejects(publicOf(root, made.privateKey), 'the fixture really is encrypted'); + const opened = await openPrivateKey(made.privateKey, { passphrase, comment: 't3-vault:fixture' }); + assert.notEqual(opened.privateKey, made.privateKey); + assert.equal(await publicOf(root, opened.privateKey), made.publicKey, 'passphrase removed'); + assert.equal(opened.publicKey, made.publicKey + ' t3-vault:fixture'); + assert.equal(opened.privateKey.includes(passphrase), false); + assert.equal((await openPrivateKey(flatten(made.privateKey), { passphrase })).publicKey, made.publicKey, 'a flattened encrypted key opens too'); +}); + +test('a passphrase on an unencrypted key does no harm', async t => { + const root = await folder(t), made = await makeKey(root); + const opened = await openPrivateKey(made.privateKey, { passphrase: 'fixture-not-needed' }); + assert.equal(await publicOf(root, opened.privateKey), made.publicKey); +}); + +test('the wrong passphrase, or none, is asked about on the passphrase field', async t => { + const root = await folder(t), made = await makeKey(root, { phrase: passphrase }); + await assert.rejects(openPrivateKey(made.privateKey, { passphrase: 'fixture-wrong-passphrase' }), error => { + assert.equal(error.code, 'key-passphrase'); assert.equal(error.status, 400); assert.equal(error.extra.field, 'passphrase'); + assert.match(error.message, /passphrase didn’t open/); + assert.equal(error.message.includes('fixture-wrong-passphrase'), false); + return true; + }); + await assert.rejects(openPrivateKey(made.privateKey), error => error.code === 'key-passphrase' && /needs its passphrase/.test(error.message) && error.extra.field === 'passphrase'); +}); + +test('text that isn\'t a key is refused on the key field, before OpenSSH is asked', async () => { + let ran = 0; + for (const value of ['', 'fixture text that is not a key', 'ssh-ed25519 AAAA fixture']) { + await assert.rejects(openPrivateKey(value, { run: async () => { ran++; return { code: 0, stdout: '' }; } }), error => error.code === 'key-invalid' && error.status === 400 && error.extra.field === 'privateKey' && !error.message.includes(value || '\0')); + } + assert.equal(ran, 0); + const garbled = [['-----BEGIN', 'OPENSSH PRIVATE KEY-----'].join(' '), 'Zml4dHVyZS1ub3QtYS1yZWFsLWtleQ==', ['-----END', 'OPENSSH PRIVATE KEY-----'].join(' ')].join('\n'); + await assert.rejects(openPrivateKey(garbled), { code: 'key-passphrase' }, 'well formed but not a key: OpenSSH cannot tell that from a missing passphrase'); +}); + +test('a key that OpenSSH can\'t generate is an error, and the temporary folder still goes', async () => { + let directory; + await assert.rejects(generateKey('fixture', { run: async (_program, args) => { directory = path.dirname(args.at(-1)); return { code: 1, stdout: '' }; } }), { status: 500, code: 'key-failed' }); + await assert.rejects(fs.stat(directory), { code: 'ENOENT' }); +}); + +test('the temporary key file is private while OpenSSH reads it, and gone afterwards', async t => { + const root = await folder(t), made = await makeKey(root); let seen; + const { execute } = await import('../docker/setup/credentials/hostkeys.mjs'); + await openPrivateKey(made.privateKey, { run: async (program, args, options) => { + const file = args[args.indexOf('-f') + 1]; + seen = { file, mode: (await fs.stat(file)).mode & 0o777, folder: (await fs.stat(path.dirname(file))).mode & 0o777 }; + return execute(program, args, options); + } }); + assert.deepEqual([seen.mode, seen.folder], [0o600, 0o700]); + await assert.rejects(fs.stat(path.dirname(seen.file)), { code: 'ENOENT' }); +}); + +test('the install line appends the key with owner-only permissions', () => { + const line = installCommand('ssh-ed25519 AAAAfixture t3-vault:fixture'); + assert.equal(line, "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo 'ssh-ed25519 AAAAfixture t3-vault:fixture' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"); +}); diff --git a/tests/credentials-migration.test.mjs b/tests/credentials-migration.test.mjs new file mode 100644 index 0000000..e654e45 --- /dev/null +++ b/tests/credentials-migration.test.mjs @@ -0,0 +1,230 @@ +// Stores written by earlier versions, read by this one. Each row here is shaped +// as those versions wrote it, so these fail if the upgrade ever stops matching +// what's already on people's servers. +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 { upgradeProfile } from '../docker/setup/credentials/model.mjs'; +import { credentialKey } from './fixtures/credential-keys.mjs'; + +const password = 'fixture-migrated-password', token = 'fixture-migrated-token'; +const v2Ssh = { id: 'build-server', type: 'ssh', host: '192.0.2.10', port: 2222, username: 'deploy', secret: password, createdAt: '2026-01-02T03:04:05.000Z', updatedAt: '2026-02-03T04:05:06.000Z', secretChangedAt: '2026-01-15T00:00:00.000Z', revision: '0123456789abcdef' }; +const v2Api = { id: 'project-api', type: 'api', baseUrl: 'https://api.example.test/v1/', authType: 'header', authName: 'X-Key', authPrefix: 'Token', testPath: 'account', secret: token, createdAt: '2026-03-04T05:06:07.000Z', updatedAt: '2026-03-04T05:06:07.000Z', secretChangedAt: null, revision: 'fedcba9876543210' }; +const v2Bearer = { id: 'plain-api', type: 'api', baseUrl: 'https://plain.example.test/', authType: 'bearer', testPath: '', secret: token + '-plain', createdAt: '2026-03-04T05:06:07.000Z', updatedAt: '2026-03-04T05:06:07.000Z', secretChangedAt: '2026-03-04T05:06:07.000Z', revision: 'aaaaaaaaaaaaaaaa' }; + +async function fixture(t, options = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'credential-migration-')), directory = path.join(root, 'credentials'), home = path.join(root, 'home'); + await fs.mkdir(directory, { mode: 0o700 }); await fs.mkdir(home); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const file = path.join(directory, 'credentials.json'); + const open = (extra = {}) => createCredentialStore({ directory, home, ...options, ...extra }); + const write = async value => { const text = typeof value === 'string' ? value : JSON.stringify(value); await fs.writeFile(file, text, { mode: 0o600 }); return text; }; + return { root, directory, home, file, write, open, read: async () => JSON.parse(await fs.readFile(file, 'utf8')) }; +} +async function trustOnDisk(f, id, key, metadata = {}) { + await fs.mkdir(path.join(f.directory, 'known_hosts'), { mode: 0o700 }); + await fs.writeFile(path.join(f.directory, 'known_hosts', id), `${id} ${key.type} ${key.key}\n`, { mode: 0o600 }); + await fs.writeFile(path.join(f.directory, 'state.json'), JSON.stringify({ version: 1, tests: {}, hostKeys: { [id]: { type: key.type, fingerprint: key.fingerprint, trustedAt: '2026-02-01T00:00:00.000Z', carried: false, firstUse: false } }, ...metadata }), { mode: 0o600 }); +} + +test('a v2 store becomes v3, keeping revisions, dates, secrets and host keys', async t => { + const f = await fixture(t), key = credentialKey(); + await trustOnDisk(f, v2Ssh.id, key, { tests: { [v2Api.id]: { ok: false, code: 'rejected', ms: 12, at: '2026-03-05T00:00:00.000Z', revision: v2Api.revision, detail: { status: 401 } } } }); + await f.write({ version: 2, profiles: [v2Ssh, v2Api, v2Bearer] }); + const { credentials, sshAliases } = await f.open().list(); + + const raw = await f.read(); + assert.equal(raw.version, 3); assert.equal(Object.hasOwn(raw, 'profiles'), false); assert.equal(raw.credentials.length, 3); + assert.deepEqual(raw.credentials[0], { + id: 'build-server', type: 'ssh', description: '', + fields: { host: '192.0.2.10', port: 2222, username: 'deploy', auth: 'password' }, + secrets: { password }, env: {}, + createdAt: v2Ssh.createdAt, updatedAt: v2Ssh.updatedAt, secretChangedAt: v2Ssh.secretChangedAt, revision: v2Ssh.revision, + }); + assert.deepEqual(raw.credentials[1], { + id: 'project-api', type: 'token', description: '', + fields: { baseUrl: 'https://api.example.test/v1/', authType: 'header', authName: 'X-Key', authPrefix: 'Token', testPath: 'account' }, + secrets: { token }, env: {}, + createdAt: v2Api.createdAt, updatedAt: v2Api.updatedAt, secretChangedAt: null, revision: v2Api.revision, + }); + assert.deepEqual(raw.credentials[2].fields, { baseUrl: 'https://plain.example.test/', authType: 'bearer', testPath: '' }); + assert.equal((await fs.stat(f.file)).mode & 0o777, 0o600); + + // What people see and use is the same as before the upgrade. + const [box, api] = credentials; + assert.deepEqual([box.revision, box.createdAt, box.updatedAt, box.secretChangedAt], [v2Ssh.revision, v2Ssh.createdAt, v2Ssh.updatedAt, v2Ssh.secretChangedAt]); + assert.equal(box.hostKey.fingerprint, key.fingerprint, 'a server that was trusted stays trusted'); + assert.equal(box.hostKey.trustedAt, '2026-02-01T00:00:00.000Z'); + assert.equal(api.test.code, 'rejected', 'a result for the same revision still counts'); + assert.equal(JSON.stringify(credentials).includes(password) || JSON.stringify(credentials).includes(token), false); + const store = f.open(); + assert.equal((await store.forUse(v2Ssh.id)).secrets.password, password); + assert.equal((await store.forUse(v2Ssh.id)).knownHostsFile, path.join(f.directory, 'known_hosts', v2Ssh.id)); + assert.equal((await store.forUse(v2Api.id)).secrets.token, token); + assert.deepEqual((await store.forUse(v2Api.id)).fields.authPrefix, 'Token'); + + // Old passwords sign in the old way, and plain ssh now reaches them. + assert.equal(sshAliases, true, 'the upgrade writes the ssh files'); + assert.match(await fs.readFile(path.join(f.directory, 'ssh_config'), 'utf8'), /^Host build-server$/m); + + // An edit from a page opened before the upgrade still lines up. + const saved = (await store.save({ credential: { id: v2Api.id, type: 'token', fields: { testPath: 'me' }, secrets: {} }, revision: v2Api.revision })).credential; + assert.equal(saved.createdAt, v2Api.createdAt); assert.equal(saved.fields.testPath, 'me'); assert.equal((await store.forUse(v2Api.id)).secrets.token, token); +}); + +test('a v2 upgrade happens once, then the file is left alone', async t => { + const f = await fixture(t); + await f.write({ version: 2, profiles: [v2Ssh, v2Api] }); + await f.open().initialize(); + const once = await fs.readFile(f.file, 'utf8'), when = (await fs.stat(f.file)).mtimeMs; + await f.open().initialize(); await f.open().list(); + assert.equal(await fs.readFile(f.file, 'utf8'), once); + assert.equal((await fs.stat(f.file)).mtimeMs, when); +}); + +test('a v1 store also works: dates come from the file, revisions are new, and known_hosts is carried over', async t => { + const carried = credentialKey(), runs = []; + const f = await fixture(t); + const { createCredentialIO } = await import('../docker/setup/credentials/io.mjs'), { createHostKeys } = await import('../docker/setup/credentials/hostkeys.mjs'); + const io = createCredentialIO({ directory: f.directory }); + const hostKeys = createHostKeys({ io, home: f.home, run: async (program, args) => { runs.push(args); return { code: 0, stdout: 'known ' + carried.type + ' ' + carried.key + '\n' }; } }); + await f.write({ version: 1, profiles: [{ id: 'build-server', label: 'Old label', type: 'ssh', host: '192.0.2.10', port: 22, username: 'deploy', secret: password }, { id: 'project-api', label: 'Old label', type: 'api', baseUrl: 'https://api.example.test/v1', secret: token }] }); + const stamped = new Date('2026-01-01T00:00:00.000Z'); await fs.utimes(f.file, stamped, stamped); + const store = f.open({ io, hostKeys }), [box, api] = (await store.list()).credentials; + assert.equal((await f.read()).version, 3); + assert.deepEqual([box.createdAt, box.updatedAt, box.secretChangedAt], [stamped.toISOString(), stamped.toISOString(), null]); + assert.match(box.revision, /^[a-f0-9]{16}$/); assert.notEqual(box.revision, api.revision); + assert.equal(box.hostKey.fingerprint, carried.fingerprint); assert.equal(box.hostKey.carried, true); + assert.equal(api.hostKey, null); + assert.equal(api.fields.baseUrl, 'https://api.example.test/v1/'); assert.equal(api.fields.authType, 'bearer'); + assert.equal(Object.hasOwn(box, 'label'), false); + assert.equal((await store.forUse(box.id)).secrets.password, password); assert.equal((await store.forUse(api.id)).secrets.token, token); + assert.deepEqual(runs.map(args => args.slice(0, 2)), [['-F', '192.0.2.10']], 'only the SSH row looks in known_hosts'); +}); + +test('v2 never reads known_hosts: only v1 trusted it, and only once', async t => { + let ran = 0; + const f = await fixture(t); + const { createCredentialIO } = await import('../docker/setup/credentials/io.mjs'), { createHostKeys } = await import('../docker/setup/credentials/hostkeys.mjs'); + const io = createCredentialIO({ directory: f.directory }), hostKeys = createHostKeys({ io, home: f.home, run: async () => { ran++; return { code: 0, stdout: '' }; } }); + await f.write({ version: 2, profiles: [v2Ssh] }); + const [box] = (await f.open({ io, hostKeys }).list()).credentials; + assert.equal(ran, 0); assert.equal(box.hostKey, null, 'nothing is trusted that wasn\'t already'); +}); + +test('a read-only store refuses to migrate, and leaves the old file exactly as it was', async t => { + for (const version of [1, 2]) { + const f = await fixture(t), profiles = version === 1 ? [{ id: 'old-api', type: 'api', baseUrl: 'https://api.example.test/', secret: token }] : [v2Ssh, v2Api]; + const before = await f.write({ version, profiles }), readOnly = f.open({ readOnly: true }); + for (const read of [() => readOnly.list(), () => readOnly.forUse(profiles[0].id), () => readOnly.public(profiles[0].id)]) { + await assert.rejects(read(), error => { assert.equal(error.code, 'migration', 'version ' + version); assert.equal(error.status, 409); assert.match(error.message, /Open Setup once/); assert.equal(error.message.includes(token), false); return true; }); + } + assert.equal(await fs.readFile(f.file, 'utf8'), before); + assert.deepEqual(await fs.readdir(f.directory), ['credentials.json'], 'not even a lock file'); + await assert.rejects(fs.stat(path.join(f.home, '.ssh')), { code: 'ENOENT' }); + assert.equal((await f.open().list()).credentials.length, profiles.length, 'the writer can still do it afterwards'); + assert.equal((await f.read()).version, 3); + assert.equal((await readOnly.list()).credentials.length, profiles.length, 'and helpers work from then on'); + } +}); + +test('an unreadable old store is an error and is left for repair, never overwritten', async t => { + for (const damaged of [ + { version: 2, profiles: [{ id: 'x', type: 'ssh' }] }, { version: 2, profiles: [{ ...v2Ssh, host: '-oProxyCommand=bad' }] }, { version: 2, profiles: [v2Ssh, v2Ssh] }, { version: 2, profiles: 'nope' }, + { version: 2, credentials: [] }, { version: 1, profiles: [null] }, { version: 4, credentials: [] }, { version: 0, profiles: [] }, { profiles: [] }, [], 'not json', + ]) { + const f = await fixture(t), before = await f.write(damaged); + await assert.rejects(f.open().list(), { code: 'store', status: 500 }, JSON.stringify(damaged)); + assert.equal(await fs.readFile(f.file, 'utf8'), before); + } +}); + +test('a failed upgrade restores the old file, and works when tried again', async t => { + let failing = true; + const f = await fixture(t); + const { createCredentialIO } = await import('../docker/setup/credentials/io.mjs'); + const io = createCredentialIO({ directory: f.directory, effects: { ...fs, rename: async (from, to) => { if (failing && to.endsWith('/credentials.json')) { failing = false; throw new Error('injected failure'); } return fs.rename(from, to); } } }); + const before = await f.write({ version: 2, profiles: [v2Ssh] }); + await assert.rejects(f.open({ io }).initialize(), { code: 'store' }); + assert.equal(await fs.readFile(f.file, 'utf8'), before); + assert.equal((await f.open().list()).credentials[0].revision, v2Ssh.revision); +}); + +test('version 3 files round trip, for every type', async t => { + const f = await fixture(t), store = f.open(); + const rows = [ + { id: 'box-key', type: 'ssh', description: 'Key sign-in', fields: { host: 'example.test', port: 22, username: 'deploy', auth: 'key', publicKey: 'ssh-ed25519 AAAAfixture' }, secrets: { privateKey: 'fixture key text\n' } }, + { id: 'box-pass', type: 'ssh', fields: { host: 'example.test', port: 2222, username: 'deploy', auth: 'password' }, secrets: { password: 'fixture-password' } }, + { id: 'tok', type: 'token', fields: { baseUrl: 'https://example.test/', authType: 'query', authName: 'key', testPath: 'me' }, secrets: { token: 'fixture-token' }, env: { token: 'TOK_KEY' } }, + { id: 'log', type: 'login', fields: { username: 'me', url: 'https://example.test' }, secrets: { password: 'fixture-password' } }, + { id: 'db', type: 'database', fields: { engine: 'mysql', host: 'db.example.test', port: 3307, database: 'app', username: 'me', options: 'ssl=true' }, secrets: { password: 'fixture-password' } }, + { id: 'cloud', type: 'aws', fields: { accessKeyId: 'AKIAFIXTURE', region: 'eu-west-1' }, secrets: { secretAccessKey: 'fixture-secret', sessionToken: 'fixture-session' } }, + { id: 'gce', type: 'gcp', fields: { project: 'p', clientEmail: 'a@example.test' }, secrets: { serviceAccount: '{"type":"service_account"}\n' } }, + { id: 'kube', type: 'kubernetes', fields: {}, secrets: { kubeconfig: 'apiVersion: v1\nkind: Config\nclusters: []\n' } }, + { id: 'gh', type: 'git', fields: { host: 'github.com', username: 'x-access-token', baseUrl: 'https://api.github.com/', authType: 'bearer', testPath: 'user' }, secrets: { token: 'fixture-token' }, env: { token: 'GH_TOKEN' } }, + { id: 'npm', type: 'registry', fields: { username: 'me' }, secrets: { token: 'fixture-token' } }, + { id: 'cert', type: 'file', fields: { filename: 'cert.pem' }, secrets: { content: 'line one\nline two\n' }, env: { content: 'CERT_PATH' } }, + { id: 'mixed', type: 'custom', fields: { REGION: 'eu' }, secrets: { API_KEY: 'fixture-secret\nsecond line' } }, + ]; + for (const credential of rows) await store.save({ credential, create: true, origin: { by: 'agent', threadId: 'thread-1' } }); + const raw = await fs.readFile(f.file, 'utf8'), when = (await fs.stat(f.file)).mtimeMs, parsed = JSON.parse(raw); + assert.equal(parsed.version, 3); assert.equal(parsed.credentials.length, rows.length); + for (const row of rows) { + const stored = parsed.credentials.find(entry => entry.id === row.id); + assert.deepEqual(stored.secrets, row.secrets, row.id); + for (const [key, value] of Object.entries(row.fields)) assert.equal(stored.fields[key], value, row.id + ' ' + key); + assert.deepEqual(stored.env, row.env ?? {}, row.id); + assert.deepEqual(stored.origin, { by: 'agent', threadId: 'thread-1' }, row.id); + } + // A fresh reader gets the same thing, and reading changes nothing on disk. + const fresh = f.open(); + for (const row of rows) { + const used = await fresh.forUse(row.id), stored = parsed.credentials.find(entry => entry.id === row.id); + assert.deepEqual(used.secrets, row.secrets, row.id); assert.equal(used.revision, stored.revision); assert.equal(used.createdAt, stored.createdAt); + assert.deepEqual(used.origin, stored.origin); + } + await fresh.list(); await fresh.initialize(); + assert.equal(await fs.readFile(f.file, 'utf8'), raw); assert.equal((await fs.stat(f.file)).mtimeMs, when); + // Changing one credential leaves every other exactly as it was. + const before = parsed.credentials.find(entry => entry.id === 'tok'); + await fresh.save({ credential: { id: 'tok', type: 'token', fields: {}, secrets: {}, description: 'Now described' }, revision: before.revision }); + const after = await f.read(); + for (const row of rows) if (row.id !== 'tok') assert.deepEqual(after.credentials.find(entry => entry.id === row.id), parsed.credentials.find(entry => entry.id === row.id), row.id); + assert.equal(after.credentials.find(entry => entry.id === 'tok').description, 'Now described'); + // A read-only helper reads them too, without an upgrade of any kind. + assert.equal((await f.open({ readOnly: true }).list()).credentials.length, rows.length); +}); + +test('a version 3 row with something wrong in it stops the store rather than being quietly dropped', async t => { + const good = { id: 'tok', type: 'token', description: '', fields: {}, secrets: { token: 'fixture-token' }, env: {}, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', secretChangedAt: null, revision: '0123456789abcdef' }; + const f = await fixture(t); await f.write({ version: 3, credentials: [good] }); + assert.equal((await f.open().forUse('tok')).secrets.token, 'fixture-token', 'the fixture is valid to start with'); + for (const change of [{ revision: 'short' }, { revision: undefined }, { createdAt: 'not a date' }, { updatedAt: undefined }, { secretChangedAt: 'not a date' }, { secretChangedAt: undefined }, { type: 'api' }, { id: 'Bad' }, { secrets: {} }, { fields: { baseUrl: 'file:///etc/passwd' } }]) { + const g = await fixture(t), before = await g.write({ version: 3, credentials: [{ ...good, ...change }] }); + await assert.rejects(g.open().list(), { code: 'store' }, JSON.stringify(change)); + assert.equal(await fs.readFile(g.file, 'utf8'), before); + } + const twice = await fixture(t); await twice.write({ version: 3, credentials: [good, good] }); + await assert.rejects(twice.open().list(), { code: 'store' }); + const many = await fixture(t); await many.write({ version: 3, credentials: Array.from({ length: 65 }, (_, index) => ({ ...good, id: 'tok-' + index })) }); + await assert.rejects(many.open().list(), { code: 'store' }); + const shaped = await fixture(t); await shaped.write({ version: 3, profiles: [good] }); + await assert.rejects(shaped.open().list(), { code: 'store' }, 'version 3 reads credentials, not profiles'); +}); + +test('an origin the store can\'t believe is dropped on read, while the credential survives', async t => { + const good = { id: 'tok', type: 'token', description: '', fields: {}, secrets: { token: 'fixture-token' }, env: {}, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', secretChangedAt: null, revision: '0123456789abcdef', origin: { by: 'someone' }, changedBy: { by: 'agent', threadId: 'bad id!' } }; + const f = await fixture(t); await f.write({ version: 3, credentials: [good] }); + const row = await f.open().public('tok'); + assert.equal(row.origin, undefined); assert.deepEqual(row.changedBy, { by: 'agent' }); +}); + +test('upgradeProfile maps each old shape onto the new one', () => { + assert.deepEqual(upgradeProfile({ id: 'a', type: 'ssh', host: 'h', port: 22, username: 'u', secret: 's' }), { id: 'a', type: 'ssh', description: '', fields: { host: 'h', port: 22, username: 'u', auth: 'password' }, secrets: { password: 's' }, env: {} }); + assert.deepEqual(upgradeProfile({ id: 'a', type: 'api', baseUrl: 'https://x.test/', secret: 's' }), { id: 'a', type: 'token', description: '', fields: { baseUrl: 'https://x.test/', authType: 'bearer', testPath: '' }, secrets: { token: 's' }, env: {} }); + const stamped = upgradeProfile({ id: 'a', type: 'ssh', host: 'h', port: 22, username: 'u', secret: 's', createdAt: 'c', updatedAt: 'u', secretChangedAt: null, revision: 'r' }); + assert.deepEqual([stamped.createdAt, stamped.updatedAt, stamped.secretChangedAt, stamped.revision], ['c', 'u', null, 'r'], 'a null date stays null rather than becoming now'); +}); diff --git a/tests/credentials-run.test.mjs b/tests/credentials-run.test.mjs new file mode 100644 index 0000000..9e8ec00 --- /dev/null +++ b/tests/credentials-run.test.mjs @@ -0,0 +1,229 @@ +// runWith with real child processes: what a command gets and what comes back. +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 { EventEmitter } from 'node:events'; +import { spawn } from 'node:child_process'; +import { runWith, canTest } from '../docker/setup/credentials/run.mjs'; +import { maskValues, outputFilter, byteFilter, redactText, REDACTED } from '../docker/setup/credentials/redact.mjs'; +import { normalize } from '../docker/setup/credentials/model.mjs'; + +const token = 'fixture-run-token-value-123'; +const sink = () => { const chunks = []; return { write: chunk => { chunks.push(Buffer.from(chunk)); }, get bytes() { return Buffer.concat(chunks); }, get text() { return Buffer.concat(chunks).toString(); } }; }; +const credential = (id = 'project-api', value = token, extra = {}) => normalize({ id, type: 'token', secrets: { token: value }, ...extra }); +const script = source => [process.execPath, '-e', source]; +const run = async (credentials, command, options = {}) => { + const stdout = sink(), stderr = sink(); + const code = await runWith(credentials.map(entry => entry.credential ? entry : { credential: entry }), command, { stdout, stderr, stdin: 'ignore', env: { PATH: process.env.PATH }, ...options }); + return { code, out: stdout.text, err: stderr.text, bytes: stdout.bytes }; +}; + +test('the variable reaches the child, alongside the environment it was given', async () => { + const { code, out, err } = await run([credential()], script(`process.stdout.write(JSON.stringify({ same: process.env.PROJECT_API_TOKEN === ${JSON.stringify(token)}, kept: process.env.KEPT }))`), { env: { PATH: process.env.PATH, KEPT: 'yes' } }); + assert.equal(code, 0, err); assert.deepEqual(JSON.parse(out), { same: true, kept: 'yes' }); +}); + +test('the parent\'s secrets never pass through its own environment', async () => { + await run([credential()], script('')); + assert.equal(process.env.PROJECT_API_TOKEN, undefined); +}); + +test('values are hidden on stdout and stderr, however the output is split into chunks', async () => { + const half = token.length >> 1, first = token.slice(0, half), last = token.slice(half); + const source = `const one = ${JSON.stringify(first)}, two = ${JSON.stringify(last)}, wait = () => new Promise(r => setTimeout(r, 40)); + (async () => { + for (const stream of [process.stdout, process.stderr]) { stream.write('before ' + one); await wait(); stream.write(two + ' after\\n'); await wait(); stream.write('whole ' + one + two + ' end\\n'); } + })();`; + const { code, out, err } = await run([credential()], script(source)); + assert.equal(code, 0); + for (const stream of [out, err]) { assert.equal(stream, 'before [redacted] after\nwhole [redacted] end\n'); assert.equal(stream.includes(token), false); } + // Byte by byte is the worst case for a boundary. + const stepped = await run([credential()], script(`(async () => { for (const c of ${JSON.stringify('x' + token + 'y')}) { process.stdout.write(c); await new Promise(r => setImmediate(r)); } })()`)); + assert.equal(stepped.out, 'x[redacted]y'); +}); + +test('the forms a value commonly takes in output are hidden too', async () => { + const value = 'fixture "quoted" & spaced/value+', forms = [value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value), new URLSearchParams([['', value]]).toString().slice(1), Buffer.from(value).toString('base64')]; + const { out } = await run([credential('tricky', value)], script(`process.stdout.write(${JSON.stringify(forms.join(' | '))})`)); + assert.equal(out, forms.map(() => REDACTED).join(' | ')); +}); + +test('a database password is hidden inside the URL as well as by itself', async () => { + const password = 'p@ss/w:rd#fixture', database = normalize({ id: 'app-db', type: 'database', fields: { engine: 'postgres', host: 'db.example.test', username: 'app', database: 'main' }, secrets: { password } }); + const { out } = await run([database], script(`process.stdout.write(process.env.DATABASE_URL + ' ' + process.env.PGPASSWORD)`)); + assert.equal(out, 'postgres://app:[redacted]@db.example.test/main [redacted]'); +}); + +test('text that only starts like a secret comes through when the command ends', async () => { + const { out } = await run([credential()], script(`process.stdout.write('prefix ' + ${JSON.stringify(token.slice(0, 10))})`)); + assert.equal(out, 'prefix ' + token.slice(0, 10)); +}); + +test('binary output passes through byte-identical', async () => { + const every = Buffer.from(Array.from({ length: 256 }, (_, index) => index)); + const expected = Buffer.concat(Array.from({ length: 1024 }, () => every)); + assert.ok(expected.includes(0xff) && expected.includes(0x00)); + const source = `const every = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); process.stdout.write(Buffer.concat(Array.from({ length: 1024 }, () => every)));`; + for (const masked of [[credential()], [normalize({ id: 'plain', type: 'custom', fields: { NOT_SECRET: 'x' } })]]) { + const { code, bytes } = await run(masked, script(source)); + assert.equal(code, 0); + assert.equal(bytes.length, expected.length); + assert.ok(bytes.equals(expected), 'every byte came back the same'); + } +}); + +test('a value is hidden in the middle of binary output without disturbing the bytes around it', async () => { + const source = `process.stdout.write(Buffer.concat([Buffer.from([0xff, 0x00, 0xfe]), Buffer.from(${JSON.stringify(token)}), Buffer.from([0x80, 0x00, 0xff])]))`; + const { bytes } = await run([credential()], script(source)); + assert.ok(bytes.equals(Buffer.concat([Buffer.from([0xff, 0x00, 0xfe]), Buffer.from(REDACTED), Buffer.from([0x80, 0x00, 0xff])]))); +}); + +test('a value with non-ASCII characters is hidden even when a chunk splits a character', async () => { + const value = 'pässwörd-fixture-✓-value', encoded = Buffer.from(value), middle = encoded.indexOf(0xc3) + 1; + const source = `const all = Buffer.from(${JSON.stringify(value)}); (async () => { process.stdout.write(all.subarray(0, ${middle})); await new Promise(r => setTimeout(r, 40)); process.stdout.write(all.subarray(${middle})); })()`; + const { out } = await run([credential('accents', value)], script(source)); + assert.equal(out, REDACTED); +}); + +test('exit codes are carried', async () => { + for (const code of [0, 1, 7, 255]) assert.equal((await run([credential()], script(`process.exit(${code})`))).code, code); +}); + +test('a command ended by a signal reports 128 plus the signal, as a shell would', async () => { + for (const [signal, code] of [['SIGTERM', 143], ['SIGKILL', 137], ['SIGINT', 130]]) { + assert.equal((await run([credential()], script(`process.kill(process.pid, '${signal}'); setTimeout(() => {}, 5000);`))).code, code, signal); + } +}); + +test('a missing command is status 127, one that can\'t start is 126, and neither leaves a temporary file', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-run-')); t.after(() => fs.rm(root, { recursive: true, force: true })); + const missing = path.join(root, 'no-such-command'); + await assert.rejects(run([credential()], [missing]), error => error.status === 127 && error.code === 'command-not-found' && error.message.includes(missing)); + const notRunnable = path.join(root, 'not-executable'); await fs.writeFile(notRunnable, 'x', { mode: 0o600 }); + await assert.rejects(run([credential()], [notRunnable]), { status: 126, code: 'command-failed' }); + let file; + await assert.rejects(run([normalize({ id: 'cert', type: 'file', secrets: { content: 'fixture file text' } })], [missing], { spawnProcess: (command, args, options) => { file = options.env.CERT_FILE; return spawn(command, args, options); } }), { status: 127 }); + assert.equal(await fs.readFile(path.dirname(file) + '/credential', 'utf8').catch(error => error.code), 'ENOENT'); + await assert.rejects(fs.stat(path.dirname(path.dirname(file))), { code: 'ENOENT' }); +}); + +test('temporary files exist only during the command, at mode 600 in a 700 folder', async () => { + const content = 'fixture certificate text\nwith two lines\n'; + const source = `const fs = require('node:fs'), path = require('node:path'), file = process.env.CERT_FILE, folder = path.dirname(file), mode = target => fs.statSync(target).mode & 0o777; + process.stdout.write(JSON.stringify({ file, mode: mode(file), folder: mode(folder), parent: mode(path.dirname(folder)), same: fs.readFileSync(file, 'utf8') === ${JSON.stringify(content)}, owner: fs.statSync(file).uid === process.getuid() }));`; + const { code, out, err } = await run([normalize({ id: 'cert', type: 'file', fields: { filename: 'cert.pem' }, secrets: { content } })], script(source)); + assert.equal(code, 0, err); + const seen = JSON.parse(out); + assert.equal(path.basename(seen.file), 'cert.pem'); + assert.deepEqual([seen.mode, seen.folder, seen.parent, seen.same, seen.owner], [0o600, 0o700, 0o700, true, true]); + await assert.rejects(fs.stat(seen.file), { code: 'ENOENT' }); + await assert.rejects(fs.stat(path.dirname(path.dirname(seen.file))), { code: 'ENOENT' }); +}); + +test('temporary files go even when the command fails or is killed', async () => { + for (const ending of ['process.exit(3)', `process.kill(process.pid, 'SIGKILL'); setTimeout(() => {}, 5000);`]) { + const { out } = await run([normalize({ id: 'cert', type: 'file', secrets: { content: 'fixture' } })], script(`require('node:fs').writeSync(1, process.env.CERT_FILE); ${ending}`)); + await assert.rejects(fs.stat(out), { code: 'ENOENT' }); + } +}); + +test('file contents are hidden, including a long line printed by itself', async () => { + const line = 'Zml4dHVyZS1wcml2YXRlLWtleS1ib2R5LWxpbmU', content = `short\n${line}\nalso short\n`; + const { out } = await run([normalize({ id: 'cert', type: 'file', secrets: { content } })], script(`const text = require('node:fs').readFileSync(process.env.CERT_FILE, 'utf8'); process.stdout.write(text + '---\\n' + ${JSON.stringify(line)});`)); + assert.equal(out.includes(line), false); + assert.equal(out, REDACTED + '---\n' + REDACTED); +}); + +test('each credential with a file gets its own, even with the same name', async () => { + const one = normalize({ id: 'one', type: 'file', fields: { filename: 'same' }, secrets: { content: 'fixture first' } }); + const two = normalize({ id: 'two', type: 'file', fields: { filename: 'same' }, secrets: { content: 'fixture second' } }); + const { code, out } = await run([one, two], script(`const fs = require('node:fs'); process.stdout.write(JSON.stringify([process.env.ONE_FILE !== process.env.TWO_FILE, fs.readFileSync(process.env.ONE_FILE, 'utf8') === 'fixture first', fs.readFileSync(process.env.TWO_FILE, 'utf8') === 'fixture second']));`)); + assert.equal(code, 0); assert.deepEqual(JSON.parse(out), [true, true, true]); +}); + +test('two credentials claiming one variable is an error, before anything starts', async () => { + let started = false; + const shared = id => credential(id, token + id, { env: { token: 'SHARED_TOKEN' } }); + await assert.rejects(run([shared('one'), shared('two')], ['/bin/true'], { spawnProcess() { started = true; } }), error => { + assert.equal(error.status, 400); + assert.match(error.message, /one and two both set SHARED_TOKEN/); + assert.match(error.message, /two=OTHER_NAME/, 'it says how to fix it'); + return true; + }); + assert.equal(started, false); + // A file variable counts too. + const file = id => normalize({ id, type: 'file', env: { content: 'SHARED_FILE' }, secrets: { content: 'fixture' } }); + await assert.rejects(run([file('one'), file('two')], ['/bin/true']), /both set SHARED_FILE/); + await assert.rejects(run([shared('one'), normalize({ id: 'two', type: 'file', env: { content: 'SHARED_TOKEN' }, secrets: { content: 'fixture' } })], ['/bin/true']), /both set SHARED_TOKEN/); +}); + +test('NAME=VAR gives the main secret another name, which also settles a clash', async () => { + const source = `process.stdout.write(JSON.stringify([process.env.RENAMED === ${JSON.stringify(token)}, process.env.PROJECT_API_TOKEN === undefined]))`; + assert.deepEqual(JSON.parse((await run([{ credential: credential(), rename: 'RENAMED' }], script(source))).out), [true, true]); + const shared = (id, value) => credential(id, value, { env: { token: 'SHARED_TOKEN' } }); + const { code, out } = await run([shared('one', token + '-one'), { credential: shared('two', token + '-two'), rename: 'OTHER_TOKEN' }], script(`process.stdout.write(JSON.stringify([process.env.SHARED_TOKEN.endsWith('-one'), process.env.OTHER_TOKEN.endsWith('-two')]))`)); + assert.equal(code, 0); assert.deepEqual(JSON.parse(out), [true, true]); + const file = normalize({ id: 'cert', type: 'file', secrets: { content: 'fixture' } }); + assert.equal((await run([{ credential: file, rename: 'MY_CERT' }], script(`process.stdout.write(String(require('node:fs').existsSync(process.env.MY_CERT)))`))).out, 'true'); +}); + +test('every credential\'s values are hidden, not only the first one\'s', async () => { + const { out } = await run([credential('one', 'fixture-first-value'), credential('two', 'fixture-second-value')], script(`process.stdout.write(process.env.ONE_TOKEN + ' ' + process.env.TWO_TOKEN)`)); + assert.equal(out, '[redacted] [redacted]'); +}); + +test('signals reach the command, and the handlers don\'t outlive it', async () => { + const signals = new EventEmitter(); + const source = `process.on('SIGTERM', () => { process.stdout.write('stopping'); process.exit(9); }); process.stdout.write('ready'); setInterval(() => {}, 1000);`; + const stdout = { write: chunk => { if (String(chunk).includes('ready')) signals.emit('SIGTERM'); } }; + const code = await runWith([{ credential: credential() }], script(source), { stdout, stderr: sink(), stdin: 'ignore', env: { PATH: process.env.PATH }, signals }); + assert.equal(code, 9); + for (const name of ['SIGINT', 'SIGTERM', 'SIGHUP']) assert.equal(signals.listenerCount(name), 0, name); +}); + +test('only SSH credentials and ones with an address have a test', () => { + assert.equal(canTest({ type: 'ssh', fields: {} }), true); + assert.equal(canTest({ type: 'token', fields: { baseUrl: 'https://example.test/' } }), true); + assert.equal(canTest({ type: 'token', fields: {} }), false); + assert.equal(canTest({ type: 'custom' }), false); +}); + +// ---- the masks themselves ----------------------------------------------------- +test('values shorter than the minimum aren\'t masked, since they would hide ordinary text', () => { + assert.deepEqual(maskValues('abc'), []); + assert.deepEqual(maskValues(['', 'a', 'ab', 'abc']), []); + assert.ok(maskValues('abcd').includes('abcd')); + assert.equal(redactText('the cat sat', 'cat'), 'the cat sat'); + assert.equal(redactText('the cats sat', 'cats'), 'the [redacted] sat'); + assert.deepEqual(maskValues([undefined, null, 42, {}]), []); + assert.deepEqual(maskValues(undefined), []); + assert.ok(maskValues(' abcd ').includes('abcd'), 'the trimmed form counts'); + assert.deepEqual(maskValues(' a ').filter(value => value.trim().length < 4 && value.length < 4), [], 'and nothing short slips in from it'); +}); + +test('a multi-line secret hides each long line, leaves short ones, and tries the longest form first', () => { + const longOne = 'x'.repeat(16), tooShort = 'y'.repeat(15), secret = `${longOne}\n${tooShort}\r\n${'z'.repeat(40)}\n`; + const values = maskValues(secret); + assert.ok(values.includes(longOne)); assert.ok(values.includes('z'.repeat(40))); assert.equal(values.includes(tooShort), false); + assert.deepEqual(values, [...values].sort((a, b) => b.length - a.length)); + assert.equal(redactText(`a ${longOne} b ${tooShort} c`, secret), `a [redacted] b ${tooShort} c`); + assert.equal(redactText(secret, secret), REDACTED, 'the whole value wins over its lines'); + assert.equal(redactText('a ' + 'x'.repeat(16) + ' b', 'single line, so no lines of its own\nxxxxxxxxxxxxxxx'), 'a ' + 'x'.repeat(16) + ' b'); + assert.equal(maskValues('one line without a break here').length <= 6, true); +}); + +test('text and byte filters hold back only what could still become a secret', () => { + const written = [], text = outputFilter('fixture-secret', value => written.push(value)); + text.write('hello '); assert.deepEqual(written, ['hello '], 'ordinary output and prompts come straight through'); + text.write('fixture'); assert.deepEqual(written, ['hello '], 'this might still become the secret'); + text.write('-other'); assert.deepEqual(written, ['hello ', 'fixture-other']); + text.write(' fixture-'); text.end(); assert.equal(written.join(''), 'hello fixture-other fixture-'); + const out = sink(), bytes = byteFilter('fixture-secret', out.write); + bytes.write(Buffer.from('a fixture-sec')); bytes.write(Buffer.from('ret b')); bytes.end(); + assert.equal(out.text, 'a [redacted] b'); + const none = sink(), open = byteFilter([], none.write); + open.write(Buffer.from([0xff, 0x00])); open.end(); + assert.ok(none.bytes.equals(Buffer.from([0xff, 0x00]))); +}); diff --git a/tests/credentials-service.test.mjs b/tests/credentials-service.test.mjs index c7b3ac0..20a5f99 100644 --- a/tests/credentials-service.test.mjs +++ b/tests/credentials-service.test.mjs @@ -7,22 +7,29 @@ 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 { publicTypes } from '../docker/setup/credentials/types.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 ssh = { id: 'build-server', type: 'ssh', fields: { host: '192.0.2.10', port: 22, username: 'deploy', auth: 'password' }, secrets: { password: 'synthetic-pass' } }; +const api = { id: 'project-api', type: 'token', fields: { baseUrl: 'https://api.example.test/v1/', authType: 'bearer' }, secrets: { token: 'synthetic-token' } }; +const keyed = { id: 'key-server', type: 'ssh', fields: { host: '192.0.2.11', port: 22, username: 'deploy', auth: 'key' }, secrets: { privateKey: 'fixture-pasted-key-text' } }; +const made = { privateKey: 'fixture-generated-key-text\n', publicKey: 'ssh-ed25519 AAAAgenerated t3-vault:fixture' }; +const opener = async (text, { comment }) => ({ privateKey: 'opened ' + text + '\n', publicKey: 'ssh-ed25519 AAAAopened ' + comment }); +const withFields = (credential, fields) => ({ ...credential, fields: { ...credential.fields, ...fields } }); 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 }); + // Always pass home: saving an SSH credential adds a line to /.ssh/config. + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'credential-service-home-')); const key = credentialKey(); let offered = key, reachable = { ok: true }, signins = 0, scans = 0; - const keys = createHostKeys({ io, reach: async () => reachable, run: async (program, args) => { + const keys = createHostKeys({ io, home, 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; } }; + const store = createCredentialStore({ directory, io, hostKeys: keys, home }); + const service = createCredentialsService({ store, usage, keygen: async () => made, keyOpener: opener, test: async (profile, options) => { signins++; return options.test ? options.test(profile) : { ok: true, code: 'ok', detail: { user: profile.fields.username } }; }, ...options }); + t.after(() => { service.close(); return Promise.all([directory, home].map(folder => fs.rm(folder, { recursive: true, force: true }))); }); + return { directory, home, 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 => { @@ -31,7 +38,7 @@ test('scan only reads host details and never signs in, persists state or reads a 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); + assert.equal(JSON.stringify(result).includes(ssh.secrets.password), false); }); test('saved tests block before authentication until explicit fresh fingerprint trust', async t => { @@ -57,7 +64,7 @@ test('draft tests pin only the fingerprint explicitly approved and leave no save 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 } }; + return { ok: true, code: 'ok', detail: { user: profile.fields.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'); @@ -73,11 +80,11 @@ test('draft tests pin only the fingerprint explicitly approved and leave no save 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: '' } }); + let answer = await f.service.handle('/credentials/test', { id: ssh.id, draft: { ...ssh, secrets: {} } }); assert.equal(answer.result.code, 'ok'); - answer = await f.service.handle('/credentials/test', { id: ssh.id, draft: { ...ssh, host: 'other-host', secret: '' } }); + answer = await f.service.handle('/credentials/test', { id: ssh.id, draft: { ...withFields(ssh, { host: 'other-host' }), secrets: {} } }); 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 }); + for (const draft of [{ ...ssh, id: 'other', secrets: {} }, { ...api, id: ssh.id, secrets: {} }]) 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 => { @@ -85,14 +92,14 @@ test('saving comes before optional trust and exposes partial trust failure hones 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 } }); + const saved = await f.service.handle('/credentials/save', { credential: { ...ssh, secrets: {} }, 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; }; + f.keys.inspect = async (...args) => { const answer = await oldInspect(...args); await f.store.save({ credential: withFields(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); }); @@ -115,15 +122,22 @@ test('three in-flight tests are allowed; excess checks are refused and shutdown await assert.rejects(f.service.handle('/credentials/test', { id: api.id }), { status: 503 }); }); +test('a credential with no way to test it says so instead of inventing a result', async t => { + const f = await fixture(t); + await f.create({ id: 'plain-token', type: 'token', fields: {}, secrets: { token: 'synthetic-token' } }); + assert.deepEqual(await f.service.handle('/credentials/test', { id: 'plain-token' }), { ok: true, result: null }); + assert.equal(f.signins, 0); +}); + 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(options.redirect, 'manual'); return { status, ok: status < 300, body: { cancel() { cancelled++; } }, headers: { secret: api.secrets.token } }; } }); - assert.equal(result.code, code); assert.deepEqual(result.detail, { status }); assert.equal(cancelled, 1); assert.equal(JSON.stringify(result).includes(api.secret), false); + assert.equal(result.code, code); assert.deepEqual(result.detail, { status }); assert.equal(cancelled, 1); assert.equal(JSON.stringify(result).includes(api.secrets.token), 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, {}); + const result = await apiRequest(api, 'GET', '', { probe: true, fetchRequest: async () => { throw { ...error, message: api.secrets.token }; } }); assert.equal(result.code, code); assert.deepEqual(result.detail, {}); }); test('SSH helper refuses an untrusted credential before starting a process', async () => { @@ -132,6 +146,12 @@ test('SSH helper refuses an untrusted credential before starting a process', asy assert.equal(spawned, false); }); +test('SSH helper refuses a credential of another type before starting a process', async () => { + let spawned = false; + await assert.rejects(sshRequest(api, ['true'], { spawnProcess() { spawned = true; } }), /t3-vault run/); + 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); @@ -148,14 +168,136 @@ for (const [clientLine, remoteLine, status, code] of [ 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); + assert.equal(result.code, code); assert.equal(JSON.stringify(result).includes(ssh.secrets.password), 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 }); + await f.store.save({ credential: withFields(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); }); + +test('the page gets every type and whether plain ssh works, and saving from it is always marked as Setup', async t => { + const f = await fixture(t); + const empty = await f.service.handle('/credentials'); + assert.deepEqual(empty.types, publicTypes()); assert.equal(empty.sshAliases, false); assert.deepEqual(empty.credentials, []); + const saved = await f.service.handle('/credentials/create', { credential: ssh, origin: { by: 'agent', threadId: 'made-up' } }); + assert.deepEqual(saved.credential.origin, { by: 'setup' }, 'a request body cannot claim to be an agent'); + assert.equal((await f.service.handle('/credentials')).sshAliases, true); + await assert.rejects(f.service.handle('/credentials/unknown'), { status: 404, code: 'not-found' }); +}); + +test('detection reads only what it is given and refuses more than a key file could be', async t => { + const f = await fixture(t); + assert.equal((await f.service.handle('/credentials/detect', { text: 'ghp_' + 'fixture0000000000000000' })).detected.type, 'git'); + assert.equal((await f.service.handle('/credentials/detect', { text: '' })).detected, null); + for (const text of [undefined, 42, 'x'.repeat(48 * 1024 + 1)]) await assert.rejects(f.service.handle('/credentials/detect', { text }), { status: 400 }); +}); + +test('a pasted key is opened before anything is saved, so a bad one leaves nothing behind', async t => { + const f = await fixture(t); + await assert.rejects(createCredentialsService({ store: f.store, usage, keyOpener: async () => { throw Object.assign(new Error('That doesn’t look like a private key.'), { status: 400 }); } }).save({ credential: keyed }, { create: true }), /private key/); + assert.deepEqual((await f.store.list()).credentials, []); + const answer = await f.service.handle('/credentials/create', { credential: keyed, passphrase: 'synthetic-passphrase' }); + assert.equal(answer.credential.fields.publicKey, 'ssh-ed25519 AAAAopened t3-vault:key-server'); + assert.deepEqual(Object.keys((await f.store.forUse(keyed.id)).secrets), ['privateKey']); + assert.equal((await f.store.forUse(keyed.id)).secrets.privateKey, 'opened fixture-pasted-key-text\n'); + assert.equal(JSON.stringify(answer).includes('fixture-pasted-key-text'), false); + assert.equal(JSON.stringify(await fs.readFile(path.join(f.directory, 'credentials.json'), 'utf8')).includes('synthetic-passphrase'), false, 'the passphrase is never kept'); +}); + +test('a key sign-in pins a new host on first use, and from then on a different key blocks it', async t => { + const f = await fixture(t); + const answer = await f.service.handle('/credentials/create', { credential: keyed }); + assert.deepEqual([answer.trust.ok, answer.trust.firstUse], [true, true]); + assert.equal(answer.credential.hostKey.fingerprint, f.key.fingerprint); assert.equal(answer.credential.hostKey.firstUse, true); + assert.equal((await f.service.handle('/credentials/test', { id: keyed.id })).result.code, 'ok'); assert.equal(f.signins, 1); + const next = credentialKey(); f.offered(next); + const changed = await f.service.handle('/credentials/test', { id: keyed.id }); + assert.equal(changed.result.code, 'host-key-changed'); assert.equal(f.signins, 1); + assert.equal((await f.store.public(keyed.id)).hostKey.fingerprint, f.key.fingerprint); +}); + +test('an unreachable host still lets a key be saved, and the first working test pins it', async t => { + const f = await fixture(t); f.reachable({ ok: false, code: 'unreachable', detail: {} }); + const answer = await f.service.handle('/credentials/create', { credential: keyed }); + assert.equal(answer.ok, true); assert.deepEqual(answer.trust, { ok: false, code: 'unreachable' }); assert.equal(answer.credential.hostKey, null); + assert.equal((await f.service.handle('/credentials/test', { id: keyed.id })).result.code, 'unreachable'); assert.equal(f.signins, 0); + f.reachable({ ok: true }); + const tested = await f.service.handle('/credentials/test', { id: keyed.id }); + assert.equal(tested.result.code, 'ok'); assert.equal(tested.result.hostKey.state, 'new'); assert.equal(f.signins, 1); + assert.equal((await f.store.public(keyed.id)).hostKey.firstUse, true); +}); + +test('pinning on first use never applies to a password, which would hand it to whoever answered', async t => { + const f = await fixture(t); await f.create(ssh); + assert.deepEqual(await f.service.pinFirstUse(ssh.id), { ok: false, code: 'host-key-untrusted' }); + assert.equal((await f.store.public(ssh.id)).hostKey, null); +}); + +test('a generated key is offered once, can be saved, and expires', async t => { + let at = Date.parse('2026-10-30T10:00:00Z'); + const f = await fixture(t, { clock: () => at }); + const offer = await f.service.handle('/credentials/generate-key', { id: keyed.id }); + assert.equal(offer.publicKey, made.publicKey); assert.match(offer.install, /authorized_keys/); assert.ok(offer.install.includes(made.publicKey)); + assert.equal(JSON.stringify(offer).includes(made.privateKey.trim()), false); + const saved = await f.service.handle('/credentials/create', { credential: { ...keyed, secrets: {} }, generatedKeyId: offer.keyId }); + assert.equal((await f.store.forUse(keyed.id)).secrets.privateKey, made.privateKey); assert.equal(saved.credential.fields.publicKey, made.publicKey); + await assert.rejects(f.service.handle('/credentials/create', { credential: { ...keyed, id: 'again', secrets: {} }, generatedKeyId: offer.keyId }), { code: 'key-expired' }); + const late = await f.service.handle('/credentials/generate-key', { id: 'late' }); + at += 31 * 60_000; + await assert.rejects(f.service.handle('/credentials/create', { credential: { ...keyed, id: 'late', secrets: {} }, generatedKeyId: late.keyId }), { code: 'key-expired' }); + assert.deepEqual((await f.store.list()).credentials.map(row => row.id), [keyed.id]); +}); + +test('moving a saved password to a key proves the key works before the password goes', async t => { + let sshCalls = [], verdict = { ok: true, code: 'ok', detail: {} }; + const f = await fixture(t, { ssh: async (credential, command) => { sshCalls.push({ credential, command }); return { ok: true, code: 'ok', detail: {} }; }, test: async () => verdict }); + const saved = (await f.create(ssh)).credential; + await assert.rejects(f.service.handle('/credentials/install-key', { id: ssh.id }), { status: 409, code: 'host-key-untrusted' }, 'a password never goes to an unconfirmed server'); + assert.equal(sshCalls.length, 0); + const trusted = await f.store.trust({ id: ssh.id, revision: saved.revision, key: f.key }); + await assert.rejects(f.service.handle('/credentials/install-key', { id: ssh.id, revision: 'stale' }), { code: 'changed' }); + verdict = { ok: false, code: 'auth-rejected', detail: {} }; + await assert.rejects(f.service.handle('/credentials/install-key', { id: ssh.id }), { code: 'install-failed' }); + assert.equal((await f.store.forUse(ssh.id)).secrets.password, ssh.secrets.password, 'the password stays until the key works'); + verdict = { ok: true, code: 'ok', detail: {} }; + const moved = await f.service.handle('/credentials/install-key', { id: ssh.id, revision: trusted.revision }); + assert.ok(sshCalls.at(-1).command[0].includes(made.publicKey)); + assert.equal(moved.credential.fields.auth, 'key'); assert.equal(moved.credential.fields.publicKey, made.publicKey); + assert.equal(moved.credential.hostKey.fingerprint, f.key.fingerprint, 'the same server stays trusted'); + assert.deepEqual((await f.store.forUse(ssh.id)).secrets, { privateKey: made.privateKey }); + assert.equal((await fs.readFile(path.join(f.directory, 'credentials.json'), 'utf8')).includes(ssh.secrets.password), false); + await assert.rejects(f.service.handle('/credentials/install-key', { id: api.id }), { code: 'not-found' }); +}); + +test('installing with a one-time password needs a reviewed fingerprint and never saves the password', async t => { + const password = 'synthetic-one-time-password', calls = []; + const f = await fixture(t, { ssh: async (credential, command) => { calls.push({ credential, command }); return { ok: true, code: 'ok', detail: {} }; }, test: async () => ({ ok: true, code: 'ok', detail: {} }) }); + const input = { credential: { ...keyed, secrets: {} }, generate: true, install: { password } }; + await assert.rejects(f.service.save(input, { create: true }), { code: 'host-key-untrusted' }); + await assert.rejects(f.service.save({ ...input, install: {}, trust: { fingerprint: f.key.fingerprint } }, { create: true }), { status: 400, extra: { field: 'password' } }); + await assert.rejects(f.service.save({ ...input, trust: { fingerprint: credentialKey().fingerprint } }, { create: true }), { code: 'key-changed' }); + assert.equal(calls.length, 0); assert.deepEqual((await f.store.list()).credentials, []); + const saved = await f.service.save({ ...input, trust: { fingerprint: f.key.fingerprint } }, { create: true, origin: { by: 'terminal' } }); + assert.equal(calls[0].credential.secrets.password, password); assert.equal(calls[0].credential.fields.auth, 'password'); + assert.equal(saved.credential.fields.auth, 'key'); assert.equal(saved.credential.hostKey.fingerprint, f.key.fingerprint); + assert.deepEqual(saved.credential.origin, { by: 'terminal' }); + assert.deepEqual(Object.keys((await f.store.forUse(keyed.id)).secrets), ['privateKey']); + assert.equal((await fs.readFile(path.join(f.directory, 'credentials.json'), 'utf8')).includes(password), false); +}); + +test('a key that fails after a one-time install leaves nothing saved and says the password wasn\'t kept', async t => { + const f = await fixture(t, { ssh: async () => ({ ok: true, code: 'ok', detail: {} }), test: async () => ({ ok: false, code: 'auth-rejected', detail: {} }) }); + await assert.rejects(f.service.save({ credential: { ...keyed, secrets: {} }, generate: true, install: { password: 'synthetic-one-time-password' }, trust: { fingerprint: f.key.fingerprint } }, { create: true }), error => error.code === 'install-failed' && /wasn’t saved/.test(error.message)); + assert.deepEqual((await f.store.list()).credentials, []); +}); + +test('reachHost reports what happened without throwing', async () => { + assert.equal((await reachHost({ host: '127.0.0.1', port: 1 }, { connect: () => { throw new Error('fixture'); } })).code, 'unreachable'); + const stopped = new AbortController(); stopped.abort(); + assert.equal((await reachHost({ host: '127.0.0.1', port: 1 }, { signal: stopped.signal })).code, 'timeout'); +}); diff --git a/tests/credentials-skill.test.mjs b/tests/credentials-skill.test.mjs new file mode 100644 index 0000000..e02ae27 --- /dev/null +++ b/tests/credentials-skill.test.mjs @@ -0,0 +1,130 @@ +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 { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { installVaultSkill, vaultSkillFolders } from '../docker/setup/credentials/skill.mjs'; +import { skillMetadata } from '../docker/setup/skills.mjs'; +import { credentialMentions } from '../docker/setup/credentials/usage.mjs'; + +const SKILL = fileURLToPath(new URL('../docker/setup/credentials/SKILL.md', import.meta.url)); +async function fixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-skill-')), home = path.join(root, 'home'), source = path.join(root, 'SKILL.md'), stateFile = path.join(root, 'state', 'skill.json'); + await fs.mkdir(home); await fs.writeFile(source, 'version one\n'); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const env = { CLAUDE_CONFIG_DIR: path.join(root, 'claude-config') }; + const install = (options = {}) => installVaultSkill({ home, env, stateFile, source, ...options }); + const files = () => vaultSkillFolders({ home, env }).map(folder => path.join(folder, 'SKILL.md')); + const read = file => fs.readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + return { root, home, env, source, stateFile, install, files, read, publish: text => fs.writeFile(source, text) }; +} + +test('the folders are the ones that reach every agent, and follow CLAUDE_CONFIG_DIR', () => { + const home = '/fixture/home'; + assert.deepEqual(vaultSkillFolders({ home }), ['/fixture/home/.claude/skills/t3-vault', '/fixture/home/.agents/skills/t3-vault', '/fixture/home/.gemini/config/skills/t3-vault']); + assert.equal(vaultSkillFolders({ home, env: { CLAUDE_CONFIG_DIR: '/fixture/claude' } })[0], '/fixture/claude/skills/t3-vault'); + assert.equal(vaultSkillFolders({ home, env: { CLAUDE_CONFIG_DIR: 'relative/claude' } })[0], '/fixture/home/relative/claude/skills/t3-vault'); + assert.equal(vaultSkillFolders({ home, env: { CLAUDE_CONFIG_DIR: ' ' } })[0], '/fixture/home/.claude/skills/t3-vault'); +}); + +test('it installs into all three folders, and remembers what it installed', async t => { + const f = await fixture(t); + await f.install(); + for (const file of f.files()) { assert.equal(await f.read(file), 'version one\n', file); assert.equal((await fs.stat(file)).mode & 0o777, 0o644, file); } + assert.equal((await fs.stat(path.dirname(f.stateFile))).mode & 0o777, 0o700); + for (const file of f.files()) assert.deepEqual((await fs.readdir(path.dirname(file))).filter(name => name !== 'SKILL.md'), [], 'no temporary file left'); + assert.deepEqual(Object.keys(JSON.parse(await f.read(f.stateFile))), ['digest']); +}); + +test('running again with nothing new changes nothing', async t => { + const f = await fixture(t); + await f.install(); + const times = await Promise.all(f.files().map(async file => (await fs.stat(file)).mtimeMs)); + await f.install(); + assert.deepEqual(await Promise.all(f.files().map(async file => (await fs.stat(file)).mtimeMs)), times); +}); + +test('an untouched copy gets the new version', async t => { + const f = await fixture(t); + await f.install(); + await f.publish('version two\n'); await f.install(); + for (const file of f.files()) assert.equal(await f.read(file), 'version two\n', file); + await f.publish('version three\n'); await f.install(); + for (const file of f.files()) assert.equal(await f.read(file), 'version three\n', file); +}); + +test('an edited copy is left alone, then and on every later update', async t => { + const f = await fixture(t), [edited, ...others] = f.files(); + await f.install(); + await fs.writeFile(edited, 'my own words\n'); + await f.publish('version two\n'); await f.install(); + assert.equal(await f.read(edited), 'my own words\n'); + for (const file of others) assert.equal(await f.read(file), 'version two\n', file); + await f.publish('version three\n'); await f.install(); + assert.equal(await f.read(edited), 'my own words\n'); + for (const file of others) assert.equal(await f.read(file), 'version three\n', file); +}); + +test('a removed copy stays removed, and updating doesn\'t bring it back', async t => { + const f = await fixture(t), [removed, ...others] = f.files(); + await f.install(); + await fs.rm(path.dirname(removed), { recursive: true }); + await f.install(); + assert.equal(await f.read(removed), null, 'not by a plain restart'); + await f.publish('version two\n'); await f.install(); + assert.equal(await f.read(removed), null, 'and not by an update'); + for (const file of others) assert.equal(await f.read(file), 'version two\n', file); +}); + +test('a skill of that name that was there first is someone\'s own, and is left alone', async t => { + const f = await fixture(t), [existing, ...others] = f.files(); + await fs.mkdir(path.dirname(existing), { recursive: true }); await fs.writeFile(existing, 'already mine\n'); + await f.install(); + assert.equal(await f.read(existing), 'already mine\n'); + for (const file of others) assert.equal(await f.read(file), 'version one\n', file); +}); + +test('one agent\'s unwritable folder doesn\'t affect the others', async t => { + const f = await fixture(t); + await fs.writeFile(path.join(f.home, '.gemini'), 'a file where a folder should be'); + await f.install(); + const [claude, agents] = f.files(); + assert.equal(await f.read(claude), 'version one\n'); assert.equal(await f.read(agents), 'version one\n'); + assert.equal(await fs.readFile(path.join(f.home, '.gemini'), 'utf8'), 'a file where a folder should be'); +}); + +test('damaged state counts as a first run and doesn\'t overwrite an edit', async t => { + const f = await fixture(t), [edited] = f.files(); + await f.install(); + await fs.writeFile(edited, 'my own words\n'); await fs.writeFile(f.stateFile, '{broken'); + await f.publish('version two\n'); await f.install(); + assert.equal(await f.read(edited), 'my own words\n'); +}); + +test('a source that can\'t be read fails loudly and writes no state, so a later run still installs', async t => { + const f = await fixture(t); + await assert.rejects(f.install({ source: path.join(f.root, 'absent') }), { code: 'ENOENT' }); + await assert.rejects(fs.stat(f.stateFile), { code: 'ENOENT' }); + await f.install(); + for (const file of f.files()) assert.equal(await f.read(file), 'version one\n', file); +}); + +test('the real SKILL.md is named t3-vault and its commands exist', async t => { + const text = await fs.readFile(SKILL, 'utf8'); + assert.match(text, /^---\nname: t3-vault\ndescription: .+\n---\n/); + const metadata = skillMetadata(text, 'not-from-the-folder'); + assert.equal(metadata.name, 't3-vault'); + assert.ok(metadata.description.length > 20 && metadata.description.length < 1024); + // An agent follows this file literally, so every option it names has to work. + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-vault-skill-')); t.after(() => fs.rm(root, { recursive: true, force: true })); + const cli = fileURLToPath(new URL('../docker/setup/credentials/cli.mjs', import.meta.url)); + const help = spawnSync(process.execPath, [cli, '--help'], { env: { PATH: process.env.PATH, HOME: root, T3CODE_HOME: path.join(root, '.t3') }, encoding: 'utf8' }).stdout; + const types = spawnSync(process.execPath, [cli, 'types'], { env: { PATH: process.env.PATH, HOME: root, T3CODE_HOME: path.join(root, '.t3') }, encoding: 'utf8' }).stdout; + for (const [, command] of text.matchAll(/t3-vault ([a-z-]+)/g)) assert.ok(help.includes('t3-vault ' + command), command); + for (const [, option] of text.matchAll(/(--[a-z][a-z-]+)/g)) assert.ok(help.includes(option) || types.includes(option) || option === '--user', option); + assert.deepEqual([...credentialMentions(text)], [], 'the Vault\'s own skill doesn\'t count as using anything'); + assert.match(text, /request_secret/); assert.match(text, /t3-vault list/); + assert.doesNotMatch(text, /t3-ssh|t3-api/); +}); diff --git a/tests/credentials-sshconfig.test.mjs b/tests/credentials-sshconfig.test.mjs new file mode 100644 index 0000000..0b311b7 --- /dev/null +++ b/tests/credentials-sshconfig.test.mjs @@ -0,0 +1,189 @@ +// `ssh -G` prints the configuration ssh would use and connects to nothing. +// Always with -F: ssh finds its default config through the real passwd home, +// whatever HOME says, and a test must never read or depend on that. +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 { spawnSync } from 'node:child_process'; +import { sshConfigText, ensureInclude, hasInclude } from '../docker/setup/credentials/sshconfig.mjs'; + +const keyed = { id: 'build-server', type: 'ssh', fields: { host: '192.0.2.10', port: 2222, username: 'deploy', auth: 'key', publicKey: 'ssh-ed25519 AAAAfixture' } }; +const password = { id: 'old-box', type: 'ssh', fields: { host: 'old.example.test', port: 22, username: 'root', auth: 'password' } }; +const token = { id: 'project-api', type: 'token', fields: { host: 'not-a-server.example.test' } }; +async function folder(t, prefix = 't3-vault-sshconfig-') { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} +/** What ssh itself would use for this name, connecting to nothing. */ +const resolved = (file, name) => { + const answer = spawnSync('/usr/bin/ssh', ['-G', '-F', file, name], { encoding: 'utf8', timeout: 10000 }); + assert.equal(answer.status, 0, answer.stderr); + const options = new Map(); + for (const line of answer.stdout.split('\n')) { + const at = line.indexOf(' '); + if (at < 1) continue; + const key = line.slice(0, at); + options.set(key, [...(options.get(key) || []), line.slice(at + 1)]); + } + return key => options.get(key)?.at(-1); +}; +const written = async (root, credentials, name = 'ssh_config') => { const file = path.join(root, name); await fs.writeFile(file, sshConfigText(credentials, { root }), { mode: 0o600 }); return file; }; + +test('the generated file has a block for each SSH credential, in order, and nothing else', () => { + const text = sshConfigText([password, token, keyed], { root: '/fixture/vault' }); + assert.match(text, /^# Generated by t3-vault\. Changes here are replaced\./); + assert.deepEqual(text.match(/^Host .*$/gm), ['Host build-server', 'Host old-box']); + assert.doesNotMatch(text, /project-api|not-a-server/); + assert.match(text, /^ HostName 192\.0\.2\.10$/m); assert.match(text, /^ Port 2222$/m); assert.match(text, /^ User deploy$/m); + assert.match(text, /^ IdentityFile "\/fixture\/vault\/keys\/build-server"$/m); + assert.match(text, /^ UserKnownHostsFile "\/fixture\/vault\/known_hosts\/build-server"$/m); + assert.match(text, /^ GlobalKnownHostsFile \/dev\/null$/m); + assert.match(text, /^ StrictHostKeyChecking yes$/m); + assert.match(text, /^ LocalCommand \/usr\/bin\/touch "\/fixture\/vault\/usage\/build-server\.touch" 2>\/dev\/null$/m); + assert.doesNotMatch(text, /ssh-ed25519|AAAA/, 'a public key is not needed here, and a private one never is'); + assert.equal(text.endsWith('\n'), true); + assert.equal(sshConfigText([], { root: '/fixture/vault' }).includes('Host '), false); + assert.equal(sshConfigText([token], { root: '/fixture/vault' }), sshConfigText([], { root: '/fixture/vault' })); +}); + +test('ssh itself resolves a key credential to the saved server, key and pinned host', async t => { + const root = await folder(t), get = resolved(await written(root, [keyed, password]), 'build-server'); + assert.equal(get('hostname'), '192.0.2.10'); + assert.equal(get('port'), '2222'); + assert.equal(get('user'), 'deploy'); + assert.equal(get('identityfile'), path.join(root, 'keys', 'build-server')); + assert.equal(get('identitiesonly'), 'yes'); + assert.equal(get('batchmode'), 'yes'); + assert.equal(get('hostkeyalias'), 'build-server'); + assert.equal(get('stricthostkeychecking'), 'true'); + assert.equal(get('userknownhostsfile'), path.join(root, 'known_hosts', 'build-server')); + assert.equal(get('globalknownhostsfile'), '/dev/null'); + assert.equal(get('preferredauthentications'), 'publickey'); + assert.equal(get('permitlocalcommand'), 'yes'); + assert.match(get('localcommand'), /usage\/build-server\.touch/); +}); + +test('ssh itself resolves a password credential without offering any key', async t => { + const root = await folder(t), get = resolved(await written(root, [keyed, password]), 'old-box'); + assert.equal(get('hostname'), 'old.example.test'); + assert.equal(get('port'), '22'); + assert.equal(get('user'), 'root'); + assert.match(get('pubkeyauthentication'), /^(?:no|false)$/, 'OpenSSH versions print this either way'); + assert.equal(get('preferredauthentications'), 'password,keyboard-interactive'); + assert.equal(get('hostkeyalias'), 'old-box'); + assert.equal(get('stricthostkeychecking'), 'true'); + assert.equal(get('userknownhostsfile'), path.join(root, 'known_hosts', 'old-box')); + assert.notEqual(get('identityfile'), path.join(root, 'keys', 'old-box')); +}); + +test('a name that isn\'t saved gets none of it', async t => { + const root = await folder(t), get = resolved(await written(root, [keyed]), 'somewhere-else'); + assert.equal(get('hostname'), 'somewhere-else'); + assert.notEqual(get('user'), 'deploy'); + assert.equal(get('hostkeyalias'), undefined); + assert.notEqual(get('stricthostkeychecking'), 'true'); +}); + +test('a home with spaces or quotes in its path still resolves', async t => { + const root = path.join(await folder(t), 'with "quotes" and spaces'); await fs.mkdir(root); + const get = resolved(await written(root, [keyed]), 'build-server'); + assert.equal(get('identityfile'), path.join(root, 'keys', 'build-server')); + assert.equal(get('userknownhostsfile'), path.join(root, 'known_hosts', 'build-server')); + assert.equal(get('hostname'), '192.0.2.10'); +}); + +test('a % in the path is taken literally, not as one of ssh\'s own tokens', async t => { + const root = path.join(await folder(t), '100%done'); await fs.mkdir(root); + const file = await written(root, [keyed]), get = resolved(file, 'build-server'); + assert.equal(get('userknownhostsfile'), path.join(root, 'known_hosts', 'build-server'), '%d would otherwise become the home folder'); + assert.ok(get('identityfile').endsWith('/100%%done/keys/build-server'), 'expanded when ssh loads it, so -G shows it doubled'); + assert.ok(get('localcommand').includes('100%%done/usage/build-server.touch')); +}); + +test('IPv6 servers work', async t => { + const root = await folder(t); + const get = resolved(await written(root, [{ ...keyed, fields: { ...keyed.fields, host: '2001:db8::1' } }]), 'build-server'); + assert.equal(get('hostname'), '2001:db8::1'); +}); + +test('the Include goes at the top of ~/.ssh/config, in front of what is there, and keeps its mode', async t => { + const home = await folder(t), file = '/fixture/vault/ssh_config', target = path.join(home, '.ssh/config'); + await fs.mkdir(path.join(home, '.ssh'), { mode: 0o700 }); + const mine = 'Host *\n ForwardAgent no\n\nHost mine\n User me\n'; + await fs.writeFile(target, mine); await fs.chmod(target, 0o640); + assert.equal(await hasInclude({ home, file }), false); + assert.equal(await ensureInclude({ home, file }), true); + assert.equal(await fs.readFile(target, 'utf8'), `Include "${file}"\n\n` + mine); + assert.equal((await fs.stat(target)).mode & 0o777, 0o640); + assert.equal(await hasInclude({ home, file }), true); + assert.deepEqual((await fs.readdir(path.join(home, '.ssh'))).sort(), ['config'], 'no temporary file is left'); +}); + +test('an Include that\'s already there isn\'t added again or moved', async t => { + const home = await folder(t), file = '/fixture/vault/ssh_config', target = path.join(home, '.ssh/config'); + assert.equal(await ensureInclude({ home, file }), true); + const once = await fs.readFile(target, 'utf8'), when = (await fs.stat(target)).mtimeMs; + assert.equal(await ensureInclude({ home, file }), true); + assert.equal(await fs.readFile(target, 'utf8'), once); + assert.equal((await fs.stat(target)).mtimeMs, when, 'not even rewritten'); + const moved = `Host mine\n User me\n\n Include "${file}"\n`; + await fs.writeFile(target, moved); + assert.equal(await ensureInclude({ home, file }), true); + assert.equal(await fs.readFile(target, 'utf8'), moved, 'somewhere else in the file still counts'); + assert.equal(await hasInclude({ home, file }), true); + assert.equal(await hasInclude({ home, file: '/fixture/other/ssh_config' }), false); +}); + +test('it creates ~/.ssh at 700 and the config at 600 when neither exists', async t => { + const home = await folder(t), file = '/fixture/vault/ssh_config'; + assert.equal(await hasInclude({ home, file }), false, 'nothing there is not an error'); + assert.equal(await ensureInclude({ home, file }), true); + assert.equal((await fs.stat(path.join(home, '.ssh'))).mode & 0o777, 0o700); + assert.equal((await fs.stat(path.join(home, '.ssh/config'))).mode & 0o777, 0o600); + assert.equal(await fs.readFile(path.join(home, '.ssh/config'), 'utf8'), `Include "${file}"\n\n`); +}); + +test('an existing ~/.ssh keeps its own mode', async t => { + const home = await folder(t); + await fs.mkdir(path.join(home, '.ssh'), { mode: 0o755 }); await fs.chmod(path.join(home, '.ssh'), 0o755); + assert.equal(await ensureInclude({ home, file: '/fixture/vault/ssh_config' }), true); + assert.equal((await fs.stat(path.join(home, '.ssh'))).mode & 0o777, 0o755); +}); + +test('it refuses a config that is a link, without touching what the link points at', async t => { + const home = await folder(t), file = '/fixture/vault/ssh_config', target = path.join(home, '.ssh/config'), outside = path.join(home, 'dotfiles-config'); + await fs.mkdir(path.join(home, '.ssh'), { mode: 0o700 }); await fs.writeFile(outside, 'Host mine\n'); await fs.symlink(outside, target); + assert.equal(await ensureInclude({ home, file }), false); + assert.equal(await fs.readFile(outside, 'utf8'), 'Host mine\n'); + assert.equal((await fs.lstat(target)).isSymbolicLink(), true); + assert.deepEqual((await fs.readdir(path.join(home, '.ssh'))).sort(), ['config']); + // Someone can add the line through the link themselves, and that counts. + await fs.writeFile(outside, `Include "${file}"\n`); + assert.equal(await hasInclude({ home, file }), true); +}); + +test('it refuses a config that is a folder, and any other failure comes back as false', async t => { + const home = await folder(t), file = '/fixture/vault/ssh_config'; + await fs.mkdir(path.join(home, '.ssh/config'), { recursive: true }); + assert.equal(await ensureInclude({ home, file }), false); + assert.equal(await hasInclude({ home, file }), false); + const blocked = await folder(t); await fs.writeFile(path.join(blocked, '.ssh'), 'a file where a folder should be'); + assert.equal(await ensureInclude({ home: blocked, file }), false); + const failing = await folder(t); + assert.equal(await ensureInclude({ home: failing, file, effects: { ...fs, rename: async () => { throw new Error('injected failure'); } } }), false); + assert.deepEqual(await fs.readdir(path.join(failing, '.ssh')), [], 'the temporary file is cleaned up'); +}); + +test('ssh reads the generated file through the Include, ahead of a Host * block', async t => { + const home = await folder(t), root = path.join(home, '.t3/server-credentials'); await fs.mkdir(root, { recursive: true }); + const file = await written(root, [keyed]), target = path.join(home, '.ssh/config'); + await fs.mkdir(path.join(home, '.ssh'), { mode: 0o700 }); + await fs.writeFile(target, 'Host *\n User someone-else\n Port 9999\n', { mode: 0o600 }); + assert.equal(await ensureInclude({ home, file }), true); + const get = resolved(target, 'build-server'); + assert.equal(get('hostname'), '192.0.2.10'); assert.equal(get('user'), 'deploy'); assert.equal(get('port'), '2222'); + assert.equal(resolved(target, 'unrelated')('user'), 'someone-else', 'everything else still works as it did'); +}); diff --git a/tests/credentials-store.test.mjs b/tests/credentials-store.test.mjs index 066db68..0cbd6d4 100644 --- a/tests/credentials-store.test.mjs +++ b/tests/credentials-store.test.mjs @@ -3,22 +3,29 @@ 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 { createCredentialStore } from '../docker/setup/credentials.mjs'; import { createCredentialIO } from '../docker/setup/credentials/io.mjs'; import { createHostKeys } from '../docker/setup/credentials/hostkeys.mjs'; +import { createUsageLog } from '../docker/setup/credentials/state.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' }; +const ssh = { id: 'build-server', label: 'No longer stored', type: 'ssh', fields: { host: '192.0.2.10', port: 22, username: 'deploy', auth: 'password' }, secrets: { password: 'synthetic-private-password' } }; +const api = { id: 'project-api', type: 'token', fields: { baseUrl: 'https://api.example.test/v1/' }, secrets: { token: 'synthetic-private-token' } }; +// The shapes version 1 and 2 files held, for the stores upgraded on read. +const legacySsh = { id: ssh.id, label: 'No longer stored', type: 'ssh', host: '192.0.2.10', port: 22, username: 'deploy', secret: ssh.secrets.password }; +const legacyApi = { id: api.id, type: 'api', baseUrl: api.fields.baseUrl, secret: api.secrets.token }; +const withFields = (credential, fields) => ({ ...credential, fields: { ...credential.fields, ...fields } }); +const keyed = { id: 'key-server', type: 'ssh', fields: { host: '192.0.2.11', port: 22, username: 'deploy', auth: 'key', publicKey: 'ssh-ed25519 AAAAfixture t3-vault:key-server' }, secrets: { privateKey: 'fixture-private-key-text\n' } }; 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 }); + // Always pass home: saving an SSH credential adds a line to /.ssh/config. + const store = createCredentialStore({ directory, io, hostKeys, home: root, ...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 => { +test('v3 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; @@ -26,18 +33,33 @@ test('v2 stores immutable names, independent random revisions and server-owned d 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; + const edited = (await store.save({ credential: { ...withFields(ssh, { username: 'other' }), secrets: { password: '' } }, 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 }); + await store.save({ credential: { ...api, secrets: { token: '' } }, 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/); + assert.equal(raw.version, 3); assert.equal(Object.hasOwn(raw, 'profiles'), false); assert.equal(Object.hasOwn(raw.credentials[0], 'label'), false); + assert.deepEqual(Object.keys(raw.credentials.find(row => row.id === ssh.id)).sort(), ['changedBy', 'createdAt', 'description', 'env', 'fields', 'id', 'origin', 'revision', 'secretChangedAt', 'secrets', 'type', 'updatedAt']); + assert.equal(JSON.stringify(await store.list()).includes(ssh.secrets.password), false); + await assert.rejects(store.save({ credential: { ...api, id: ssh.id }, revision: edited.revision }), /change its type/); + await assert.rejects(store.draft({ id: ssh.id, draft: { ...ssh, id: 'different', secrets: {} } }), /name cannot change/); + await assert.rejects(store.draft({ id: ssh.id, draft: { ...api, id: ssh.id, secrets: {} } }), /change its type/); await assert.rejects(store.remove({ id: '../escape', revision: '' }), { code: 'invalid' }); }); +test('who added a credential is kept, who changed it is tracked, and unknown callers are not believed', async t => { + const { store } = await fixture(t); + const made = (await store.save({ credential: api, create: true, origin: { by: 'agent', threadId: 'thread:1' } })).credential; + assert.deepEqual(made.origin, { by: 'agent', threadId: 'thread:1' }); assert.equal(made.changedBy, undefined); + const changed = (await store.save({ credential: { ...api, description: 'Project API' }, revision: made.revision, origin: { by: 'terminal' } })).credential; + assert.deepEqual(changed.origin, { by: 'agent', threadId: 'thread:1' }); assert.deepEqual(changed.changedBy, { by: 'terminal' }); + assert.equal(changed.description, 'Project API'); + assert.deepEqual((await store.public(api.id)).origin, { by: 'agent', threadId: 'thread:1' }, 'origin survives a read from disk'); + const other = { ...api, id: 'other-api' }; + assert.deepEqual((await store.save({ credential: other, create: true, origin: { by: 'someone' } })).credential.origin, { by: 'setup' }); + assert.deepEqual((await store.save({ credential: { ...other, id: 'third-api' }, create: true, origin: { by: 'agent', threadId: 'not valid!' } })).credential.origin, { by: 'agent' }); + assert.deepEqual((await store.save({ credential: { ...other, id: 'fourth-api' }, create: true })).credential.origin, { by: 'setup' }); +}); + 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']); @@ -45,20 +67,21 @@ test('migration drops labels, sets mtime dates and carries a single existing key } } }); 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 }); + await fs.writeFile(file, JSON.stringify({ version: 1, profiles: [{ ...legacySsh, port: 2222 }, legacyApi] }), { 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']); + assert.equal(JSON.stringify(JSON.parse(await fs.readFile(file, 'utf8'))).includes('No longer stored'), false); + assert.deepEqual((await fs.readdir(directory)).sort(), ['credentials.json', 'keys', 'known_hosts', 'ssh_config', 'state.json', 'usage']); }); 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 io.prepare(); const raw = JSON.stringify({ version: 1, profiles: [legacySsh] }); 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); @@ -67,17 +90,17 @@ test('migration failure leaves the exact v1 bytes and does not leave carried tru }); test('helpers only read: no chmod, creation, migration, lock or state access', async t => { - const { directory, create, store, io } = await fixture(t); + const { directory, create, store, root } = await fixture(t); const saved = (await create(api)).credential; const before = await fs.readdir(directory); - const readOnly = createCredentialStore({ directory, readOnly: true }); + const readOnly = createCredentialStore({ directory, home: root, 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 readOnly.forUse(api.id)).secrets.token, api.secrets.token); 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 fs.writeFile(path.join(directory, 'credentials.json'), JSON.stringify({ version: 1, profiles: [legacyApi] })); 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(); @@ -85,7 +108,7 @@ test('helpers only read: no chmod, creation, migration, lock or state access', a 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(createCredentialStore({ directory: missing, home: root, readOnly: true }).forUse(api.id), { code: 'not-found' }); await assert.rejects(fs.stat(missing), { code: 'ENOENT' }); }); @@ -97,13 +120,24 @@ test('state damage is disposable and cannot confer or erase file-based host trus 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 store.recordTest({ id: a.id, revision: a.revision, hostFingerprint: key.fingerprint, result: { ok: true, code: 'ok', ms: 3, detail: { user: 'deploy', secret: ssh.secrets.password } } }); + assert.equal((await store.public(a.id)).test.code, 'ok'); assert.equal(JSON.stringify(JSON.parse(await fs.readFile(file))).includes(ssh.secrets.password), 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('a key sign-in waits for its first connection without asking for attention, a password does not', async t => { + const { store, create, key } = await fixture(t); + const a = (await create(keyed)).credential; + assert.equal(a.hostKey, null); + assert.deepEqual((await store.summary()).attention, []); + const pinned = await store.trust({ id: a.id, revision: a.revision, key, firstUse: true }); + assert.equal(pinned.hostKey.firstUse, true); + await create(ssh); + assert.deepEqual((await store.summary()).attention, [{ id: ssh.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; @@ -114,23 +148,33 @@ test('tests never bump revisions; late tests and removed trust never overwrite n 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; + const old = a; a = (await store.save({ credential: { ...withFields(ssh, { username: 'other' }), secrets: {} }, 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('a test result survives a save that changes nothing, and is dropped by one that does', async t => { + const { store, create } = await fixture(t); + let a = (await create(api)).credential; + await store.recordTest({ id: a.id, revision: a.revision, result: { ok: true, code: 'ok', ms: 1, detail: {} } }); + a = (await store.save({ credential: { ...api, secrets: {}, description: 'Only the description' }, revision: a.revision })).credential; + assert.equal(a.test.code, 'ok'); + a = (await store.save({ credential: { ...api, secrets: { token: 'a-different-synthetic-token' } }, revision: a.revision })).credential; + assert.equal(a.test, null); +}); + 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 }); + const changed = await store.save({ credential: { ...withFields(ssh, { port: 2222 }), secrets: {} }, 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; + const missingSecret = { ...a }; assert.equal(Object.hasOwn(missingSecret, 'secrets'), false); 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); + assert.equal((await store.draft({ id: a.id, draft: withFields(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'))); @@ -138,16 +182,53 @@ test('host/port changes and deletion remove both metadata and the credential-spe await assert.rejects(store.save({ credential: ssh, revision: a.revision }), { code: 'gone' }); }); +test('saved SSH credentials become plain ssh hosts, and removing one takes its key and block away', async t => { + const { store, create, directory, root } = await fixture(t); + assert.equal((await store.list()).sshAliases, false); + await create(api); + await assert.rejects(fs.stat(path.join(root, '.ssh/config')), { code: 'ENOENT' }, 'nothing to read until there is an SSH credential'); + const a = (await create(keyed)).credential; + await create(ssh); + const config = path.join(directory, 'ssh_config'), keyFile = path.join(directory, 'keys', keyed.id); + assert.equal(await fs.readFile(keyFile, 'utf8'), keyed.secrets.privateKey); + for (const [file, mode] of [[keyFile, 0o600], [config, 0o600], [path.join(directory, 'keys'), 0o700], [path.join(directory, 'usage'), 0o700]]) assert.equal((await fs.stat(file)).mode & 0o777, mode, file); + const text = await fs.readFile(config, 'utf8'); + assert.match(text, /^Host build-server$/m); assert.match(text, /^Host key-server$/m); assert.doesNotMatch(text, /project-api/); + assert.equal(text.includes(ssh.secrets.password) || text.includes(keyed.secrets.privateKey.trim()), false, 'the generated file holds no secrets'); + assert.equal((await fs.readFile(path.join(root, '.ssh/config'), 'utf8')).split('\n')[0], `Include "${config}"`); + assert.equal((await store.list()).sshAliases, true); + await store.remove({ id: a.id, revision: a.revision }); + await assert.rejects(fs.stat(keyFile), { code: 'ENOENT' }); + assert.doesNotMatch(await fs.readFile(config, 'utf8'), /key-server/); + // Changing how it signs in removes the key from disk as well. + const again = (await create(keyed)).credential; + await fs.stat(keyFile); + await store.save({ credential: { ...keyed, fields: { host: keyed.fields.host, port: 22, username: 'deploy', auth: 'password' }, secrets: { password: 'synthetic-new-password' } }, revision: again.revision }); + await assert.rejects(fs.stat(keyFile), { code: 'ENOENT' }); + assert.equal(Object.keys((await store.forUse(keyed.id)).secrets).join(), 'password'); +}); + +test('a ~/.ssh/config the Vault can\'t write costs plain ssh, never the saved credential', async t => { + const { store, create, root, directory } = await fixture(t); + const external = path.join(root, 'someone-elses-config'); + await fs.writeFile(external, 'Host mine\n'); await fs.mkdir(path.join(root, '.ssh')); await fs.symlink(external, path.join(root, '.ssh/config')); + assert.equal((await create(ssh)).credential.id, ssh.id); + assert.equal(await fs.readFile(external, 'utf8'), 'Host mine\n'); + assert.equal((await fs.lstat(path.join(root, '.ssh/config'))).isSymbolicLink(), true); + assert.equal((await store.list()).sshAliases, false); + assert.match(await fs.readFile(path.join(directory, 'ssh_config'), 'utf8'), /^Host build-server$/m, 't3-vault ssh and a later repair still have the file'); +}); + 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 === 'oversize') await fs.writeFile(file, ' '.repeat(4 * 1024 * 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()); + await assert.rejects(store.list(), { code: damage === 'symlink' ? 'ELOOP' : 'store' }); assert.equal(await fs.readFile(external, 'utf8'), before); }); @@ -155,15 +236,42 @@ test('state recovery replaces its directory entry without following a symlink', 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') } } }); + await store.recordTest({ id: a.id, revision: a.revision, result: { ok: false, code: 'failed', detail: { line: api.secrets.token + ' and ' + Buffer.from(api.secrets.token).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); + assert.equal(JSON.stringify(await store.list()).includes(api.secrets.token), 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; + const b = (await store.save({ credential: { ...api, secrets: { token: 'a-different-synthetic-token' } }, revision: a.revision })).credential; assert.notEqual(a.secretChangedAt, b.secretChangedAt); assert.ok(Date.parse(b.updatedAt) > Date.parse(a.updatedAt)); }); + +test('the usage log counts uses, keeps only a command\'s name, and notices plain ssh', async t => { + let ms = Date.parse('2026-10-30T10:00:00Z'); + const { store, create, directory } = await fixture(t, { store: { clock: () => ms } }); + const log = createUsageLog({ directory, clock: () => ms }); await create(api); + assert.equal(await log.read(api.id), null); + await log.record(api.id, { command: '/usr/bin/curl', env: { T3CODE_THREAD_ID: 'thread-1' } }); + ms += 1000; await log.record(api.id, { command: 'wrangler', env: {} }); + assert.deepEqual(await log.read(api.id), { at: '2026-10-30T10:00:01.000Z', count: 2, command: 'wrangler' }); + assert.deepEqual((await store.public(api.id)).usage, { at: '2026-10-30T10:00:01.000Z', count: 2, command: 'wrangler' }); + const file = path.join(directory, 'usage', api.id + '.json'); + assert.equal((await fs.stat(file)).mode & 0o777, 0o600); + await log.record(api.id, { command: '/usr/bin/curl', env: { T3CODE_THREAD_ID: 'thread-1' } }); + assert.deepEqual(JSON.parse(await fs.readFile(file, 'utf8')), { at: '2026-10-30T10:00:01.000Z', count: 3, threadId: 'thread-1', command: 'curl' }); + assert.deepEqual((await fs.readdir(path.join(directory, 'usage'))).filter(name => name.endsWith('.tmp')), []); + // ssh's LocalCommand touches a file, which is all a plain `ssh NAME` leaves behind. + const touch = path.join(directory, 'usage', api.id + '.touch'); + await fs.writeFile(touch, ''); await fs.utimes(touch, new Date('2026-10-30T11:00:00Z'), new Date('2026-10-30T11:00:00Z')); + assert.deepEqual(await log.read(api.id), { count: 3, at: '2026-10-30T11:00:00.000Z', viaSsh: true }); + await fs.writeFile(file, '{broken'); + assert.deepEqual(await log.read(api.id), { count: 1, at: '2026-10-30T11:00:00.000Z', viaSsh: true }); + // Bookkeeping never fails a command, and goes when the credential goes. + await log.record('../escape', { command: 'x' }); assert.equal(await log.read('../escape'), null); + const saved = await store.public(api.id); + await store.remove({ id: api.id, revision: saved.revision }); + assert.deepEqual(await fs.readdir(path.join(directory, 'usage')), []); +}); diff --git a/tests/credentials-types.test.mjs b/tests/credentials-types.test.mjs new file mode 100644 index 0000000..aa6f3aa --- /dev/null +++ b/tests/credentials-types.test.mjs @@ -0,0 +1,98 @@ +// The templates the page and the CLI both draw from, and the old command names. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as fs from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TYPES, TYPE_IDS, publicTypes, isType, applies, withDefaults, envNames, primarySecret } from '../docker/setup/credentials/types.mjs'; + +const repo = fileURLToPath(new URL('..', import.meta.url)); +const fixture = JSON.parse(readFileSync(new URL('../scripts/fixtures/console/credentials.json', import.meta.url), 'utf8')); + +test('the console fixture carries the same types the server sends', () => { + assert.deepEqual(fixture.types, JSON.parse(JSON.stringify(publicTypes()))); +}); + +test('the types survive JSON unchanged, so the page sees what the server does', () => { + const direct = publicTypes(), sent = JSON.parse(JSON.stringify(direct)); + assert.deepEqual(sent, direct); + assert.deepEqual(direct.map(type => type.order), [...direct.map(type => type.order)].sort((a, b) => a - b)); + assert.deepEqual(direct.map(type => type.id), TYPE_IDS); +}); + +test('the console fixture has only public rows', () => { + assert.ok(fixture.credentials.length > 0); + for (const row of fixture.credentials) { + assert.ok(isType(row.type), row.id); + assert.equal(Object.hasOwn(row, 'secrets'), false, row.id); + assert.ok(Array.isArray(row.secretNames), row.id); assert.ok(Array.isArray(row.delivers), row.id); + } +}); + +test('every type is complete enough to draw a form and a flag from', () => { + for (const [id, type] of Object.entries(TYPES)) { + for (const key of ['label', 'badge', 'icon']) assert.ok(typeof type[key] === 'string' && type[key], id + ' ' + key); + if (type.dynamic) { assert.deepEqual([type.fields, type.secrets], [[], []], id); continue; } + assert.ok(type.secrets.some(entry => entry.primary), id + ' has a main secret'); + const keys = [...type.fields, ...type.secrets].map(entry => entry.key); + assert.equal(new Set(keys).size, keys.length, id + ' repeats a key'); + for (const entry of [...type.fields, ...type.secrets]) { + assert.ok(entry.key && entry.label, id + ' ' + entry.key); + if (entry.kind === 'select') assert.ok(entry.options.length > 1, id + ' ' + entry.key); + if (entry.kind === 'number') assert.ok(entry.min !== undefined && entry.max !== undefined, id + ' ' + entry.key); + if (entry.env) assert.match(entry.env, /^(?:\{NAME\}_)?[A-Z_]+$/, id + ' ' + entry.key); + if (entry.pattern) new RegExp(entry.pattern); + } + // Every combination of a select's values has at most one main secret. + const select = type.fields.find(entry => entry.kind === 'select' && type.secrets.some(secret => secret.when?.[entry.key])); + for (const [value] of select?.options ?? [[]]) { + const fields = withDefaults(id, select ? { [select.key]: value } : {}); + assert.equal(type.secrets.filter(entry => entry.primary && applies(entry, fields)).length, 1, id + ' ' + value); + } + } +}); + +test('types know what applies, what is defaulted and what a credential is called', () => { + assert.equal(isType('toString'), false, 'a name on the prototype isn\'t a type'); assert.equal(isType(undefined), false); assert.equal(isType('ssh'), true); + assert.deepEqual(withDefaults('ssh'), { port: 22, auth: 'key' }); + assert.deepEqual(withDefaults('ssh', { auth: 'password', port: 2222 }), { port: 2222, auth: 'password' }); + assert.equal(primarySecret('ssh', { auth: 'key' }).key, 'privateKey'); assert.equal(primarySecret('custom'), null); + assert.equal(applies({ needs: 'baseUrl' }, {}), false); assert.equal(applies({ needs: 'baseUrl' }, { baseUrl: 'x' }), true); + assert.equal(applies({ when: { auth: ['key'] } }, { auth: 'password' }), false); assert.equal(applies({}, {}), true); + assert.deepEqual(envNames({ id: 'cloudflare-token', type: 'token' }), { token: 'CLOUDFLARE_TOKEN' }); + assert.deepEqual(envNames({ id: 'cloudflare-token', type: 'token', env: { token: 'CF_API_TOKEN' } }), { token: 'CF_API_TOKEN' }); + assert.deepEqual(envNames({ id: 'box', type: 'ssh' }), {}); +}); + +async function* textFiles(folder) { + for (const entry of await fs.readdir(folder, { withFileTypes: true })) { + const file = path.join(folder, entry.name); + if (entry.isDirectory()) { if (entry.name !== 'node_modules') yield* textFiles(file); continue; } + if (!entry.isFile()) continue; + const bytes = await fs.readFile(file); + if (!bytes.includes(0)) yield { file, text: bytes.toString('utf8') }; + } +} + +test('nothing under docker/ or examples/ still mentions t3-ssh or the t3-api command', async () => { + // docker/setup/t3-api.mjs is an unrelated module that talks to T3's own API, so + // its file name is allowed wherever it's written with its extension. + const stale = /(? 100, 'the walk really did read the tree'); + assert.deepEqual(found, []); +}); + +test('the check above would notice either old command', () => { + const stale = /(? { - 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']); + assert.deepEqual([...credentialMentions(`t3-vault ssh build-server -- uptime\nt3-vault http 'project-api' GET me\n t3-vault http "another-key" GET me\n`) ], ['build-server', 'project-api', 'another-key']); + assert.deepEqual([...credentialMentions('not3-vault ssh nope\nx-t3-vault http nope\nt3-vault http Bad\nt3-vault http "quoted-mismatch\nt3-vault http missing-\nt3-vault http missing_\nt3-vault http foo-bar-baz2')], ['foo-bar-baz2']); +}); + +test('every command that names a credential counts, and the ones that don\'t do not', () => { + for (const command of ['ssh', 'http', 'show', 'test', 'replace', 'trust', 'use-key']) assert.deepEqual([...credentialMentions(`t3-vault ${command} one-name`)], ['one-name'], command); + for (const line of ['t3-vault list', 't3-vault types', 't3-vault --help', 't3-vault add new-name --secret-file x', 't3-vault rm old-name --yes', 'vault ssh nope']) assert.deepEqual([...credentialMentions(line)], [], line); +}); + +test('t3-vault run counts every name before the command, including renamed and quoted ones', () => { + assert.deepEqual([...credentialMentions('t3-vault run cloudflare-token -- wrangler deploy')], ['cloudflare-token']); + assert.deepEqual([...credentialMentions('t3-vault run one two=OTHER_NAME "three" \'four=VAR\' -- env')], ['one', 'two', 'three', 'four']); + assert.deepEqual([...credentialMentions('t3-vault run one -- ssh not-a-mention\nt3-vault run two -- x')], ['one', 'two']); + assert.deepEqual([...credentialMentions('t3-vault run one | sed x-y\nt3-vault run NAME -- x\nt3-vault run good Bad later -- x')], ['one', 'good'], 'the names stop at the first thing that isn\'t one'); +}); + +test('plain ssh mentions find a bare host, and nothing that only looks like one', () => { + assert.deepEqual([...sshMentions('ssh build-server uptime\n ssh "quoted-box" true\nrun: ssh tail-box')], ['build-server', 'quoted-box', 'tail-box']); + for (const line of ['ssh user@host', 'ssh host.example.com', 'ssh host:22', 'ssh -i key host', 'openssh server', 'x-ssh nope', 'ssh Bad', 'ssh-keygen -t ed25519']) assert.deepEqual([...sshMentions(line)], [], line); }); 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'); + await fs.writeFile(real, '---\nname: deploy\ndescription: Deploy from a shared skill.\n---\nt3-vault 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' }), + instructions: async () => ({ content: 't3-vault http 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' }]); @@ -29,6 +46,21 @@ test('seven-provider scans deduplicate shared real files, include instructions a usage.invalidate(); await usage.annotate([]); assert.equal(scans, 21); }); +test('a skill that says plain ssh NAME only counts for an SSH credential of that name', async t => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'credential-usage-')); t.after(() => fs.rm(home, { recursive: true, force: true })); + const skill = path.join(home, 'ops', 'SKILL.md'); await fs.mkdir(path.dirname(skill), { recursive: true }); + await fs.writeFile(skill, '---\nname: ops\ndescription: Operations.\n---\nssh access is required. Then: ssh build-server uptime and t3-vault run cloudflare-token -- wrangler'); + const usage = createCredentialUsage({ home, installed: async () => [], scan: async () => ({ skills: [{ path: skill, resolvedPath: skill }], warnings: [] }), + readFile: async (file, _limit, io) => ({ content: await io.readFile(file, 'utf8'), bytes: 100 }), instructions: async () => ({ content: 'ssh instruction-box true' }) }); + const { credentials } = await usage.annotate([{ id: 'build-server', type: 'ssh' }, { id: 'access', type: 'token' }, { id: 'cloudflare-token', type: 'token' }, { id: 'instruction-box', type: 'ssh' }]); + const used = Object.fromEntries(credentials.map(row => [row.id, row.usedIn])); + assert.deepEqual(used['build-server'].map(item => item.name), ['ops']); + assert.deepEqual(used.access, [], 'the word "access" after ssh is not this token'); + assert.deepEqual(used['cloudflare-token'].map(item => item.name), ['ops']); + assert.deepEqual(used['instruction-box'], [{ kind: 'instructions', name: 'Global instructions' }]); + assert.equal(JSON.stringify(used).includes('sshOnly'), false, 'bookkeeping stays on the server'); +}); + 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 index d623a30..b2f805d 100644 --- a/tests/credentials.test.mjs +++ b/tests/credentials.test.mjs @@ -5,63 +5,74 @@ 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 { spawn, execFileSync } from 'node:child_process'; import { createCredentialStore, credentialFailure } from '../docker/setup/credentials.mjs'; +import { normalize } from '../docker/setup/credentials/model.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 }; +const ssh = { id: 'build-server', label: 'Build server', type: 'ssh', fields: { host: '192.0.2.1', port: 22, username: 'deploy', auth: 'password' }, secrets: { password: secret } }; +const token = (baseUrl, fields = {}, value = secret) => ({ id: 'service', label: 'Service API', type: 'token', fields: { baseUrl, ...fields }, secrets: { token: value } }); +const withFields = (credential, fields) => ({ ...credential, fields: { ...credential.fields, ...fields } }); +const sink = () => { const chunks = []; return { write: chunk => { chunks.push(Buffer.from(chunk)); }, get text() { return Buffer.concat(chunks).toString(); } }; }; 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 }) }; + // Always pass home: saving an SSH credential adds a line to /.ssh/config. + const open = () => createCredentialStore({ directory, home: root }); + return { root, directory, open, store: open() }; } 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); + const { directory, open, 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(); + await create(store, { ...token('http://192.0.2.2/api', {}, secret + '-token'), id: 'project-api' }); + const snapshot = await open().list(); assert.equal(snapshot.credentials.length, 2); - assert.equal(snapshot.credentials[1].baseUrl, 'http://192.0.2.2/api/'); + assert.equal(snapshot.credentials[1].fields.baseUrl, 'http://192.0.2.2/api/'); assert.equal(JSON.stringify(snapshot).includes(secret), false); - assert.equal((await store.forUse('build-server')).secret, secret); + for (const row of snapshot.credentials) assert.equal(Object.hasOwn(row, 'secrets'), false); + assert.deepEqual(snapshot.credentials.map(row => row.secretNames), [['password'], ['token']]); + assert.deepEqual(snapshot.credentials.map(row => row.hasSecret), [true, true]); + assert.deepEqual(snapshot.credentials.map(row => row.delivers), [[], ['PROJECT_API_TOKEN']]); + assert.deepEqual(snapshot.credentials.map(row => row.usage), [null, null]); + assert.equal((await store.forUse('build-server')).secrets.password, 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); + const saved = await store.save({ credential: { ...ssh, secrets: { password: '' } }, revision: before.revision }); + assert.equal((await store.forUse('build-server')).secrets.password, 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'); + const next = await store.save({ credential: { ...ssh, secrets: { password: 'replacement-password' } }, revision: saved.credential.revision }); + assert.equal((await store.forUse('build-server')).secrets.password, 'replacement-password'); await store.remove({ id: 'build-server', revision: next.credential.revision }); - await assert.rejects(store.forUse('build-server'), /not found/); + await assert.rejects(store.forUse('build-server'), { status: 404, code: '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 { store, open } = 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 }))); + const other = open(); + const outcomes = await Promise.allSettled(['first-host', 'second-host'].map((host, index) => (index ? other : store).save({ credential: withFields(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'); + assert.equal((await store.forUse('build-server')).fields.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 change of [{ id: '../escape' }, { id: 'new' }, { fields: { ...ssh.fields, host: '-oProxyCommand=bad' } }, { fields: { ...ssh.fields, username: 'user;bad' } }, { fields: { ...ssh.fields, port: 0 } }, { fields: { ...ssh.fields, auth: 'telepathy' } }, { secrets: { password: 'line\nbreak' } }, { secrets: { password: '\ud800' } }, { secrets: undefined }, { type: 'unknown' }, { description: 'two\nlines' }]) { + await assert.rejects(create(store, { ...ssh, ...change }), error => !error.message.includes(secret)); } 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 assert.rejects(create(store, { ...token(baseUrl), id: 'api' })); } await create(store, ssh); const file = path.join(directory, 'credentials.json'); @@ -79,6 +90,35 @@ test('invalid input and damaged stores fail without echoing or replacing credent await assert.rejects(store.list()); }); +test('variable names a command relies on cannot be overwritten by a saved value', () => { + const named = env => normalize({ id: 'service', type: 'token', fields: {}, secrets: { token: secret }, env: { token: env } }); + assert.equal(named('CLOUDFLARE_API_TOKEN').env.token, 'CLOUDFLARE_API_TOKEN'); + for (const env of ['PATH', 'HOME', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', 'NODE_OPTIONS', 'BASH_ENV', 'has space', '1LEADING', 'A=B']) assert.throws(() => named(env), { status: 400, extra: { field: 'env' } }, env); + for (const input of [{ fields: { PATH: '/tmp' } }, { secrets: { LD_PRELOAD: secret } }, { fields: { 'not a name': 'x' } }, { fields: { SAME: 'x' }, secrets: { SAME: secret } }, { fields: {}, secrets: {} }]) { + assert.throws(() => normalize({ id: 'mixed', type: 'custom', ...input }), { status: 400, extra: { field: 'vars' } }); + } +}); + +test('normalizing keeps only what a type holds and treats a pasted value the way a person meant it', () => { + const bare = normalize({ id: 'service', type: 'token', fields: { authType: 'header', authName: 'X-Key', testPath: 'account' }, secrets: { token: secret + '\n' } }); + assert.deepEqual(bare.fields, {}, 'API settings mean nothing without an address'); + assert.equal(bare.secrets.token, secret, 'a trailing line break is not part of a single-line secret'); + const mode = auth => normalize({ id: 'box', type: 'ssh', fields: { host: 'example.net', username: 'deploy', auth }, secrets: { password: secret, privateKey: 'fixture-key-text' } }); + assert.deepEqual(mode('password').secrets, { password: secret }); + assert.deepEqual(mode('key').secrets, { privateKey: 'fixture-key-text' }); + assert.equal(mode('key').fields.port, 22, 'the default port is saved'); + const file = normalize({ id: 'cert', type: 'file', secrets: { content: 'line one\nline two\n' } }); + assert.equal(file.secrets.content, 'line one\nline two\n', 'a file keeps its line breaks'); + assert.equal(file.fields.filename, 'credential'); + assert.throws(() => normalize({ id: 'long', type: 'token', secrets: { token: 'x'.repeat(8193) } }), { status: 400 }); + assert.throws(() => normalize({ id: 'long', type: 'file', secrets: { content: 'x'.repeat(48 * 1024 + 1) } }), { status: 400 }); + assert.throws(() => normalize({ id: 'blank', type: 'file', secrets: { content: ' \n' } }), { status: 400 }); + // A custom row that is left out is removed; a listed one keeps its saved value. + const previous = normalize({ id: 'mixed', type: 'custom', fields: { REGION: 'eu' }, secrets: { KEEP: secret, DROP: secret + '-other' } }); + const edited = normalize({ id: 'mixed', type: 'custom', fields: { REGION: 'us' }, secretNames: ['KEEP'], secrets: {} }, previous); + assert.deepEqual([edited.fields, edited.secrets], [{ REGION: 'us' }, { KEEP: secret }]); +}); + 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')]; @@ -98,22 +138,21 @@ test('legacy bearer connections remain readable and changing auth settings retai 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); + assert.equal((await store.forUse('service')).fields.authType, 'bearer'); + assert.equal(JSON.parse(await fs.readFile(file, 'utf8')).version, 3); 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 }); + const saved = await store.save({ credential: { ...before.credentials[0], fields: { ...before.credentials[0].fields, authType: 'header', authName: 'X-API-Key', authPrefix: '', testPath: 'account' }, secrets: { token: '' } }, 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'); + assert.equal((await store.forUse('service')).secrets.token, secret); + assert.equal((await store.list()).credentials[0].fields.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)); + await assert.rejects(create(store, token('https://api.example.com/v1/', change)), error => error.status === 400 && !error.message.includes(secret)); } }); @@ -127,17 +166,16 @@ for (const [authType, authName, authPrefix] of [['bearer', undefined, undefined] }); 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' }); + await create(store, token('http://127.0.0.1:' + server.address().port + '/v1/', { authType, authName, authPrefix, testPath: 'account' }, key)); 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); + const output = sink(); + assert.equal(await apiRequest(profile, 'GET', 'projects?limit=2', { stdout: output }), 0); assert.equal(received.url.searchParams.get('limit'), '2'); - assert.equal(output.includes(secret), false); assert.match(output, /\[redacted\]/); + assert.equal(output.text.includes(secret), false); assert.match(output.text, /\[redacted\]/); }); } @@ -153,10 +191,10 @@ test('API helper authenticates internally, filters output and never follows redi }); 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'); + const profile = { id: 'project-api', type: 'token', fields: { baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/', authType: 'bearer' }, secrets: { token: secret } }; + const output = sink(); + assert.equal(await apiRequest(profile, 'POST', 'projects', { body: '{"name":"demo-project"}', stdout: output }), 0); + assert.equal(output.text, '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'); @@ -165,6 +203,7 @@ test('API helper authenticates internally, filters output and never follows redi 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)); + assert.throws(() => apiTarget({ id: 'no-address', fields: {} }, 'x'), /t3-vault run/); }); test('SSH helper supplies askpass privately and filters both output streams', async t => { @@ -176,8 +215,8 @@ 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; } }, + const out = sink(), err = sink(); + const code = await sshRequest({ ...ssh, knownHostsFile: '/synthetic/known_hosts/build-server', hostKeyType: 'ssh-ed25519' }, ['uptime'], { stdin: 'ignore', stdout: out, stderr: err, spawnProcess: (executable, args, options) => { assert.equal(executable, '/usr/bin/ssh'); assert.ok(args.includes('StrictHostKeyChecking=yes')); assert.equal(JSON.stringify(args).includes(secret), false); @@ -185,7 +224,7 @@ setTimeout(()=>{process.stdout.write(secret.slice(5)+' done');process.stderr.wri return spawn(process.execPath, [fixtureFile], options); }, }); - assert.equal(code, 0); assert.equal(out, 'output [redacted] done'); assert.equal(err, 'error [redacted] done'); + assert.equal(code, 0); assert.equal(out.text, 'output [redacted] done'); assert.equal(err.text, '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, @@ -199,22 +238,52 @@ setTimeout(()=>{process.stdout.write(secret.slice(5)+' done');process.stderr.wri assert.equal(interrupted.signalCode, 'SIGKILL'); }); +test('SSH keeps a known_hosts path literal, even with a % in it', async () => { + let args; + await sshRequest({ ...ssh, knownHostsFile: '/synthetic/100%done/build-server', hostKeyType: 'ssh-ed25519' }, ['uptime'], { stdin: 'ignore', stdout: sink(), stderr: sink(), spawnProcess: (_executable, given, options) => { args = given; return spawn(process.execPath, ['-e', ''], options); } }); + assert.ok(args.includes('UserKnownHostsFile=/synthetic/100%%done/build-server')); + assert.equal(execFileSync('/usr/bin/ssh', ['-G', '-F', '/dev/null', '-o', args[args.findIndex(arg => arg.startsWith('UserKnownHostsFile=')) ], 'x'], { encoding: 'utf8' }).match(/^userknownhostsfile (.*)$/m)[1], '/synthetic/100%done/build-server'); +}); + +test('SSH with a key hands it over in a private file that is gone afterwards, never in arguments or the environment', async t => { + const { root } = await fixture(t); + const line = 'Zml4dHVyZS1wcml2YXRlLWtleS1ib2R5LWxpbmU', key = `fixture key header\n${line}\nfixture key footer\n`; + const printer = path.join(root, 'print.cjs'); await fs.writeFile(printer, 'process.stdout.write(process.argv[2]);'); + const out = sink(); let seen; + const code = await sshRequest({ id: 'box', type: 'ssh', fields: { host: '192.0.2.5', port: 2200, username: 'deploy', auth: 'key', publicKey: 'ssh-ed25519 AAAA' }, secrets: { privateKey: key }, knownHostsFile: '/synthetic/known_hosts/box', hostKeyType: 'ssh-ed25519' }, ['uptime'], { stdin: 'ignore', stdout: out, stderr: sink(), + spawnProcess: (_executable, args, options) => { + const file = args[args.indexOf('-i') + 1]; + seen = { args, env: options.env, file }; + assert.equal(args.includes('IdentitiesOnly=yes'), true); assert.equal(args.includes('BatchMode=yes'), true); + assert.equal(args[args.indexOf('-p') + 1], '2200'); assert.equal(args[args.indexOf('-l') + 1], 'deploy'); + return spawn(process.execPath, [printer, 'printed ' + line], options); + }, + }); + assert.equal(code, 0); + assert.equal(out.text, 'printed [redacted]', 'a line of the key is hidden too'); + assert.equal(JSON.stringify(seen.args).includes(line), false); + assert.equal(Object.values(seen.env).some(value => value.includes(line)), false); + assert.equal(seen.env.T3_CREDENTIAL_PASSWORD, process.env.T3_CREDENTIAL_PASSWORD); + await assert.rejects(fs.stat(seen.file), { code: 'ENOENT' }); + await assert.rejects(fs.stat(path.dirname(seen.file)), { code: 'ENOENT' }); +}); + 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 create(store, { ...withFields(ssh, { host }), id: 'v6-' + host.length }); } - await assert.rejects(create(store, { ...ssh, id: 'bad-host', host: '-oProxyCommand=bad' })); + await assert.rejects(create(store, { ...withFields(ssh, { host: '-oProxyCommand=bad' }), id: 'bad-host' })); }); 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); + const profile = { id: 'project-api', type: 'token', fields: { baseUrl: 'http://127.0.0.1:' + server.address().port + '/api/', authType: 'bearer' }, secrets: { token: secret } }; + const output = sink(), error = sink(); + assert.equal(await apiRequest(profile, 'GET', 'projects', { stdout: output, stderr: error }), 1); + assert.equal(output.text, '[redacted]'); + assert.match(error.text, /HTTP 401/); + assert.equal((output.text + error.text).includes(secret), false); }); diff --git a/tests/files-api.test.mjs b/tests/files-api.test.mjs index 8f7ede1..06ad6d8 100644 --- a/tests/files-api.test.mjs +++ b/tests/files-api.test.mjs @@ -20,7 +20,7 @@ async function filesHTTP(t){ export const processSnapshot=async()=>({complete:true,processes:[]}); export const createStorageManager=()=>({ready:Promise.resolve(),snapshot:()=>({categories:[]}),sizes:()=>({at:'2026-10-25T00:00:00Z',partial:false,bytesOf:()=>4096,childrenOf:()=>[],entries:()=>new Map().entries()}),refresh:async()=>({}),status:async()=>({categories:[],disk:{totalBytes:1000,availableBytes:500}}),stop(){}});`); 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 child=spawn(process.execPath,[fileURLToPath(new URL('../docker/setup/server.mjs',import.meta.url))],{env:{...process.env,HOME:home,T3CODE_HOME:path.join(home,'.t3'),T3_WORKSPACE:workspace,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_HARNESS_MODULE:module,T3_SETUP_PORT:String(port),T3_SETUP_KEY:'files-fixture-key',T3_SETUP_KEY_SOURCE:'env',T3_INFRA_LAUNCHER:'/bin/false',T3CODE_PORT:'1'},stdio:['ignore','pipe','pipe']}); + const child=spawn(process.execPath,[fileURLToPath(new URL('../docker/setup/server.mjs',import.meta.url))],{env:{...process.env,HOME:home,T3CODE_HOME:path.join(home,'.t3'),CLAUDE_CONFIG_DIR:path.join(home,'.claude'),T3_WORKSPACE:workspace,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_HARNESS_MODULE:module,T3_SETUP_PORT:String(port),T3_SETUP_KEY:'files-fixture-key',T3_SETUP_KEY_SOURCE:'env',T3_INFRA_LAUNCHER:'/bin/false',T3CODE_PORT:'1'},stdio:['ignore','pipe','pipe']}); let logs='';child.stdout.on('data',chunk=>{logs+=chunk;});child.stderr.on('data',chunk=>{logs+=chunk;}); t.after(async()=>{if(child.exitCode===null){const ended=once(child,'exit');child.kill();await ended;}await fs.rm(root,{recursive:true,force:true});}); const base=`http://127.0.0.1:${port}`; diff --git a/tests/fixtures/standalone-shell.html b/tests/fixtures/standalone-shell.html index bb2eb73..57b4407 100644 --- a/tests/fixtures/standalone-shell.html +++ b/tests/fixtures/standalone-shell.html @@ -15,7 +15,7 @@