diff --git a/docker/bin/t3-login b/docker/bin/t3-login index efd4618..b263146 100644 --- a/docker/bin/t3-login +++ b/docker/bin/t3-login @@ -44,7 +44,7 @@ Usage: t3-login cursor Cursor CLI -> cursor-agent login grok Grok Build -> grok login muse Muse Code -> muse login - connect T3 Connect -> t3 connect + connect T3 Connect -> t3 connect [command] [options] Run this with a TTY: docker compose exec -it t3code t3-login claude Then enable the provider in T3 Code under Settings > Providers. @@ -59,9 +59,10 @@ or with `t3-harness install `. `connect` authorizes this environment for T3 Connect. It does not start or disturb the running server - the link takes effect on T3 Code's next start, so -restart it afterwards: Restart T3 Code under Environment on the setup page, or +restart it afterwards: Restart T3 Code under Server on the setup page, or restart the container. The setup page can also do the whole sign-in itself -(Devices -> From anywhere). +(Access -> T3 Connect). For notifications through your own address: + t3-login connect link --publish-only USAGE } @@ -105,7 +106,7 @@ case "$harness" in fi cmd=("$bin" "${args[@]}") ;; - connect) cmd=("$T3_INFRA_LAUNCHER" connect) ;; + connect) cmd=("$T3_INFRA_LAUNCHER" connect "${@:2}") ;; -h|--help|"") usage; [ -n "$harness" ] && exit 0 || exit 2 ;; *) printf 't3-login: unknown harness %s\n\n' "$harness" >&2; usage >&2; exit 2 ;; esac diff --git a/docker/harness/catalogue.mjs b/docker/harness/catalogue.mjs index 8c31972..cf8befc 100644 --- a/docker/harness/catalogue.mjs +++ b/docker/harness/catalogue.mjs @@ -80,11 +80,15 @@ export const CATALOGUE = Object.freeze([ miseTool: "npm:@opencode/cli", releaseTool: "npm:@opencode/cli", releaseMajor: "2", - miseOptions: "allow_builds=@opencode/cli", + // This reviewed vendor package can be new or have no download signal. + // mise scopes the reputation approval to this package; dependencies and + // malicious-package checks keep their normal policy. + miseOptions: "allow_builds=@opencode/cli,allow_low_downloads=true", executable: "node_modules/.bin/opencode", bin: "opencode", minimumVersion: MINIMUM_OPENCODE2_VERSION, - requiresNightly: true, + // OpenCode v2 support shipped in T3 Code Stable 0.1.2. + minimumT3Version: "0.1.2", }], architectures: ["x64", "arm64"], credentials: { diff --git a/docker/harness/manager.mjs b/docker/harness/manager.mjs index 3a64772..58df75b 100644 --- a/docker/harness/manager.mjs +++ b/docker/harness/manager.mjs @@ -1119,6 +1119,7 @@ export function createHarnessManager(options = {}) { latest: results[installations.findIndex((installation) => installation.miseTool === active.miseTool)].value?.latest ?? null, releases: results.flatMap((result, index) => result.status === "fulfilled" ? result.value.releases.map((release) => ({ ...release, requiresNightly: Boolean(installations[index].requiresNightly), + ...(installations[index].minimumT3Version ? { minimumT3Version: installations[index].minimumT3Version } : {}), })) : []).sort((a, b) => compareVersions(b.version, a.version)), degraded: results.flatMap((result, index) => result.status === "rejected" ? [{ tool: installations[index].miseTool, error: String(result.reason?.message ?? result.reason) }] : []), }; diff --git a/docker/setup/app.js b/docker/setup/app.js index 082e616..65d047a 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -1055,6 +1055,7 @@
${action}${!url && here && !here.local ? html`` : ''}
${connectRow(s)} + ${connectPreferenceRows(s)} `; }; @@ -1105,6 +1106,54 @@ `; }; + let connectSaving = null; + let connectPreferenceError = null; + const connectPreferenceRows = (s) => { + const prefs = s.connect?.preferences; + const reason = !s.connect ? 'Connection settings are unavailable. Try again after T3 Code is ready.' + : s.connect.revoked ? 'This server was removed from your T3 account. Set up T3 Connect again above.' + : !s.connect.linked ? 'Set up T3 Connect above to link this server to your T3 account.' + : !prefs ? 'Could not read connection settings. Check that T3 Code is running and up to date.' : null; + return html`${[ + ['publishAgentActivity', 'Publish agent activity', 'smartphone', 'Send agent activity to mobile notifications and Live Activities. Sign in to the same T3 account in the mobile app and allow notifications.'], + ['holdWebhooksWhileOffline', 'Hold webhooks while offline', 'clock', 'T3 Connect stores webhook requests for up to 24 hours while this server is offline, then delivers them. Turning this off still delivers requests already held.'], + ].map(([key, label, glyph, help]) => { + const saving = connectSaving === key; + const error = connectPreferenceError?.key === key ? connectPreferenceError.message : null; + const unavailable = reason || (key === 'holdWebhooksWhileOffline' + ? prefs.holdWebhooksWhileOffline === null ? 'Update T3 Code to manage offline webhook holding.' + : !prefs.managedTunnelActive ? 'Requires an active T3 Connect tunnel.' : null : null); + return html`
+ +
+
${label}${saving ? html`Saving…` : ''}
+ ${unavailable || help} + ${error ? html`${error}` : ''} +
+
+
`; + })}`; + }; + + const saveConnectPreference = async (key) => { + const prefs = state.status?.connect?.preferences; + if (connectSaving || !prefs || !['publishAgentActivity', 'holdWebhooksWhileOffline'].includes(key)) return; + const enabled = !prefs[key]; + connectSaving = key; + connectPreferenceError = null; + render(); + const res = await api('/connect/preferences', { body: { [key]: enabled } }); + await loadStatus(); + connectSaving = null; + if (!res.ok) connectPreferenceError = { key, message: res.error || 'Could not save the setting. Try again.' }; + else { + if (state.status?.connect) state.status.connect.preferences = res.data.preferences; + Kit.announce((key === 'publishAgentActivity' ? 'Agent activity publishing' : 'Offline webhook holding') + (enabled ? ' on' : ' off')); + } + render(); + document.querySelector('[data-key="connect-toggle-' + key + '"]')?.focus({ preventScroll: true }); + }; + // --------------------------------------------------------------- agents -- const settleSoftwareCheck = (s) => { if (!ui.updateAwaiting || s.updates?.refreshing) return; @@ -2687,7 +2736,7 @@ // own `t3 connect link --headless`, which the setup service runs), restart // T3 Code so it makes the link, and wait for it to come on. Opened again // later, it starts wherever the server says things are. - const conn = { layer: null, phase: 'idle', session: null, error: null, identity: null, attempt: 0, poll: null, oldPid: null, since: 0 }; + const conn = { layer: null, phase: 'idle', mode: 'managed', session: null, error: null, identity: null, attempt: 0, poll: null, oldPid: null, since: 0 }; const connectSheetBody = () => { const st = conn.session || {}; @@ -2695,7 +2744,14 @@ const restartable = Boolean(s.t3 && s.t3.pid); const signedIn = sheetStep(1, 'Sign in to T3' + (conn.identity ? ' as ' + conn.identity : ''), 'done', ''); let body; - if (conn.phase === 'failed') { + if (conn.phase === 'choose') { + body = html`

Choose how this server uses your T3 account. Both options need a sign-in and a T3 Code restart.

+ ${[ + ['managed', 'T3 Connect tunnel', 'Reach this server through T3’s relay without a public address of your own. Activity publishing can be turned on afterwards.'], + ['publish_only', 'Notifications only', 'Publish agent activity for mobile notifications and Live Activities. Keep connecting through your own address, such as Tailscale or a reverse proxy.'], + ].map(([mode, label, help]) => html``)} +
`; + } else if (conn.phase === 'failed') { body = html`${notice('danger', 'circle-alert', 'T3 Connect did not finish', conn.error || 'The sign-in stopped before it finished.')}${st.tail ? html`
${st.tail}
` : ''}`; } else if (conn.phase === 'signin') { body = st.url @@ -2710,10 +2766,12 @@ } else if (conn.phase === 'linking') { body = html`${signedIn}${sheetStep(2, 'Turn it on', 'active', html`${sheetWaiting('T3 Code is making the link…')}${now() - conn.since > 120_000 ? html`This is taking longer than usual. You can close this: Access shows when it is on.` : ''}`)}`; } else if (conn.phase === 'on') { - body = notice('ok', 'circle-check', 'T3 Connect is on', 'Devices signed in to your T3 account reach this server through ' + ((s.connect && s.connect.relayHost) || 'T3’s relay') + '.'); + body = notice('ok', 'circle-check', 'T3 Connect is on', M.connectView(s).text); } const close = (label, variant) => html``; - const foot = conn.phase === 'failed' + const foot = conn.phase === 'choose' + ? html`${close('Cancel')}` + : conn.phase === 'failed' ? html`${close('Close')}` : conn.phase === 'authorized' ? html`${close('Later')}${restartable ? html`` : ''}` @@ -2721,7 +2779,7 @@ : conn.phase === 'signin' ? html`Esc cancels${close('Cancel')}` : close('Close'); return html` -

T3 Connect

Runs t3 connect link for you
+

T3 Connect

${conn.phase === 'choose' ? 'Link this server to your T3 account' : conn.mode === 'publish_only' ? 'Notifications through your own server address' : 'Reach this server through T3’s relay'}
${body}
${foot}
`; }; @@ -2750,10 +2808,11 @@ }; const connBeginSignin = async () => { + if (conn.phase === 'signin') return; const attempt = connNext(); Object.assign(conn, { phase: 'signin', session: null, error: null }); connRender(); - const res = await api('/auth/signin', { body: { agent: 'connect' } }); + const res = await api('/auth/signin', { body: { agent: 'connect', mode: conn.mode } }); if (!conn.layer || conn.attempt !== attempt) { if (res.ok && res.data.id) api('/auth/cancel', { body: { id: res.data.id } }); return; @@ -2798,8 +2857,8 @@ const openConnect = (trigger, { restart } = {}) => { if (conn.layer) return; const view = M.connectView(state.status); - const phase = { on: 'on', linking: 'linking', restart: 'authorized' }[view.state] || 'signin'; - Object.assign(conn, { phase, session: null, error: null, identity: null, since: now() }); + const phase = { on: 'on', linking: 'linking', restart: 'authorized' }[view.state] || 'choose'; + Object.assign(conn, { phase, mode: state.status?.connect?.linkMode || 'managed', session: null, error: null, identity: null, since: now() }); conn.layer = Kit.open({ kind: 'sheet', panel: { tag: 'aside', class: 'tc-sheet ' + (Kit.isPhone() ? 'tc-sheet--bottom' : 'tc-sheet--inset'), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'connect-title' }, @@ -2819,11 +2878,13 @@ if (!action) return; const what = action.getAttribute('data-sheet'); if (what === 'close') conn.layer.close('close'); - else if (what === 'retry') connBeginSignin(); + else if (what === 'retry' || what === 'continue') connBeginSignin(); else if (what === 'restart') connRestart(); }); - if (phase === 'signin') connBeginSignin(); - else if (phase === 'linking') { const attempt = connNext(); conn.poll = setTimeout(() => connWatch(attempt), 1500); } + conn.layer.panel.addEventListener('change', (e) => { + if (e.target.name === 'connect-mode' && conn.phase === 'choose') conn.mode = e.target.value; + }); + if (phase === 'linking') { const attempt = connNext(); conn.poll = setTimeout(() => connWatch(attempt), 1500); } else if (phase === 'authorized' && restart) connRestart(); }; @@ -3012,7 +3073,7 @@ return html`${current ? html`On ${current}. T3 Code switches to the release you pick once it is installed and runs; ${current} keeps working until then.` : html`T3 Code is pointed at the release you pick once it is installed and runs.`}${kept.length ? html` Also on the volume: ${kept.join(', ')}.` : ''} - ${t.id === 'opencode' ? html`OpenCode 1 is the default. Choose a 2.x release to use OpenCode 2 with T3 Code Nightly. Updates stay on the selected major version.` : ''}`; + ${t.id === 'opencode' ? html`OpenCode 1 is the default. OpenCode 2 requires T3 Code 0.1.2 or newer, including Stable. Updates stay on the selected major version.` : ''}`; } const info = toolSheet.info; const bins = (info && info.bins && info.bins.length ? info.bins : t.bins) || []; @@ -3044,6 +3105,7 @@ else if (r.waiting) tags.push(r.releasedAt ? 'out ' + M.relTime(r.releasedAt, now()) : 'new'); else if (r.prerelease) tags.push('preview'); if (r.requiresNightly) tags.push('requires Nightly'); + if (r.minimumT3Version) tags.push('T3 Code ' + r.minimumT3Version + '+'); if (r.version === toolSheet.offered) tags.push('newest'); if (r.version === current) tags.push('installed'); else if ((facts.managedVersions || []).includes(r.version)) tags.push('on the volume'); @@ -3221,7 +3283,7 @@ const confirmed = await Kit.confirm({ title: toV2 ? 'Install OpenCode 2?' : 'Switch back to OpenCode 1?', body: toV2 - ? 'Use T3 Code Nightly and stop running OpenCode sessions before switching. OpenCode 2 converts its shared database on first use.' + ? 'Requires T3 Code 0.1.2 or newer. Stop running OpenCode sessions before switching. OpenCode 2 converts its shared database on first use.' : 'Stop running OpenCode sessions before switching. Installing OpenCode 1 does not restore a database or configuration converted by OpenCode 2.', consequences: [ { icon: 'shield-check', text: 'Keep a backup of your OpenCode data before switching' }, @@ -3682,6 +3744,7 @@ 'url.edit': (a, el) => openUrlSheet(el), 'connect.setup': (a, el) => openConnect(el), + 'connect.preference': (a) => saveConnectPreference(a.id), 'connect.restart': (a, el) => openConnect(el, { restart: true }), 'connect.off': async () => { const ok = await Kit.confirm({ diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index 70160db..8374dab 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -1080,10 +1080,13 @@ const T3Model = (() => { const restartable = Boolean(s && s.t3 && s.t3.pid); if (!c) return { state: 'unknown', badge: null, text: 'Couldn’t read its state just now.', action: null }; if (c.state === 'on') { + const publishOnly = c.preferences ? c.preferences.managedTunnelActive === false : c.linkMode === 'publish_only'; return { state: 'on', badge: { tone: 'ok', text: 'On' }, - text: 'Devices signed in to your T3 account reach this server through ' + (c.relayHost || 'T3’s relay') + '.', + text: publishOnly + ? 'Linked to your T3 account for agent activity. Devices reach this server through your own address.' + : 'Devices signed in to your T3 account reach this server through ' + (c.relayHost || 'T3’s relay') + '.', action: null, // Turning it off cuts devices off: a menu item and a confirmation away. menu: [{ cmd: 'connect.off', label: 'Turn off T3 Connect…', icon: 'log-out', danger: true }], @@ -1110,9 +1113,9 @@ const T3Model = (() => { if (c.state === 'signin') { return { state: 'signin', - badge: { tone: 'warn', text: 'Signed out' }, - text: 'Its sign-in is gone, so the link cannot be made until you sign in again.', - action: { cmd: 'connect.setup', label: 'Sign in', icon: 'log-in' }, + badge: { tone: 'warn', text: c.revoked ? 'Removed' : 'Signed out' }, + text: c.revoked ? 'This server was removed from your T3 account. Set it up again to restore the link.' : 'Its sign-in is gone, so the link cannot be made until you sign in again.', + action: { cmd: 'connect.setup', label: c.revoked ? 'Set up again' : 'Sign in', icon: 'log-in' }, }; } return { diff --git a/docker/setup/connect.mjs b/docker/setup/connect.mjs index d3eb1d5..0d94a0c 100644 --- a/docker/setup/connect.mjs +++ b/docker/setup/connect.mjs @@ -10,8 +10,8 @@ // t3 connect status --json what is saved // t3 connect unlink off again, keeping the sign-in // -// This module reads what those print. The setup service runs them and keeps -// the processes; nothing here has effects. +// The setup service runs those processes. This module parses their output +// and reads or saves live preferences through T3 Code's native server API. /** * What `t3 connect link --headless` has printed so far: @@ -67,14 +67,17 @@ export function connectState(json) { if (!json || typeof json !== "object") return null; const desired = json.desired === true; const authenticated = json.authenticated === true; - const linked = json.linked === true; - const state = linked ? "on" : !desired ? "off" : authenticated ? "pending" : "signin"; + const revoked = json.revoked === true; + const linked = json.linked === true && !revoked; + const state = revoked ? "signin" : linked ? "on" : !desired ? "off" : authenticated ? "pending" : "signin"; const relayClient = json.relayClient && typeof json.relayClient === "object" ? json.relayClient : {}; return { state, desired, authenticated, linked, + revoked, + linkMode: json.linkMode === "publish_only" ? "publish_only" : "managed", relayHost: hostOf(json.relayUrl), publishAgentActivity: json.publishAgentActivity === true, relayClient: { @@ -95,3 +98,44 @@ export function jsonFrom(stdout) { return null; } } + +/** Read and update native preferences without overwriting the other switch. */ +export function createConnectPreferences({ request }) { + let saving = false; + const preferencesOf = (state) => { + if (typeof state?.publishAgentActivity !== "boolean" || typeof state?.linked !== "boolean") { + throw new Error("This T3 Code version does not expose connection settings. Update T3 Code and try again."); + } + return { + linked: state.linked && state.linkRevoked !== true, + revoked: state.linkRevoked === true, + managedTunnelActive: state.linkRevoked !== true && (state.managedTunnelActive ?? state.linked) === true, + publishAgentActivity: state.publishAgentActivity, + holdWebhooksWhileOffline: typeof state.holdWebhooksWhileOffline === "boolean" ? state.holdWebhooksWhileOffline : null, + }; + }; + const read = async () => preferencesOf(await request("/api/connect/link-state")); + const save = async (input) => { + const fields = ["publishAgentActivity", "holdWebhooksWhileOffline"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length !== 1 + || !fields.some((key) => Object.hasOwn(input, key) && typeof input[key] === "boolean")) { + return { http: 400, body: { ok: false, error: "Choose one connection setting and an on or off value." } }; + } + if (saving) return { http: 409, body: { ok: false, error: "Wait for the connection setting to finish saving." } }; + saving = true; + try { + const current = await read(); + if (!current.linked) return { http: 409, body: { ok: false, error: current.revoked ? "This server was removed from T3 Connect. Set it up again before changing connection settings." : "Set up T3 Connect before changing activity publishing." } }; + if (Object.hasOwn(input, "holdWebhooksWhileOffline") && (!current.managedTunnelActive || current.holdWebhooksWhileOffline === null)) { + return { http: 409, body: { ok: false, error: "Webhook holding needs an active T3 Connect tunnel and a supported T3 Code version." } }; + } + const saved = await request("/api/connect/preferences", { publishAgentActivity: current.publishAgentActivity, ...input }); + return { http: 200, body: { ok: true, preferences: preferencesOf(saved) } }; + } catch (error) { + return { http: 502, body: { ok: false, error: String(error?.message || "Could not save connection settings.").slice(0, 300) } }; + } finally { + saving = false; + } + }; + return { read, save }; +} diff --git a/docker/setup/server.mjs b/docker/setup/server.mjs index 16d41d8..b6dae54 100644 --- a/docker/setup/server.mjs +++ b/docker/setup/server.mjs @@ -38,7 +38,7 @@ import { createSkillsService } from "./skills.mjs"; import { createMcpService } from "./mcp.mjs"; import { clientAddress } from "./client-address.mjs"; import { newKey, readKeyFile, writeKeyFile } from "./setup-key.mjs"; -import { connectState, jsonFrom, parseLinkOutput } from "./connect.mjs"; +import { connectState, createConnectPreferences, jsonFrom, parseLinkOutput } from "./connect.mjs"; import { checkReaches, clearSaved, parsePublicUrl, platformUrl, readSaved, resolvePublicUrl, writeSaved, } from "./public-url.mjs"; @@ -174,6 +174,7 @@ const t3Api = createT3Api({ revokeSession: (sessionId) => t3(["auth", "session", "revoke", String(sessionId)]), }); const antigravity = createAntigravity({ api: t3Api }); +const connectPreferences = createConnectPreferences({ request: (path, body) => t3Api.http(path, body) }); const T3_AGENT_IDS = new Set(["antigravity"]); const isConsoleSession = (session) => session?.subject === CONSOLE_SUBJECT; const deviceLabels = createDeviceLabels({ @@ -1085,24 +1086,35 @@ const filesHold=createHoldControl({runDir:RUN_DIR,runtimeBusy:()=>runtimeModule. // the page sees it come on). Anything this page does to Connect drops it. const CONNECT_FRESH_MS = { idle: 5 * 60_000, pending: 10_000 }; let connectCache = { at: 0, value: null, refreshing: null }; +let connectRevision = 0; // When this service saw a sign-in finish. A link saved after T3 Code's last // start still needs one; one saved before it is being made, and another // restart would only interrupt that. Unknown (0) after this service restarts. let connectLinkedAt = 0; const refreshConnect = () => { + const revision = connectRevision; connectCache.refreshing ??= t3(["connect", "status", "--json"]) - .then(({ stdout }) => { - connectCache = { at: Date.now(), value: connectState(jsonFrom(stdout)), refreshing: null }; + .then(async ({ stdout }) => { + const value = connectState(jsonFrom(stdout)); + if (value) value.preferences = value.linked ? await connectPreferences.read().catch(() => null) : null; + if (value?.preferences?.revoked) Object.assign(value, { linked: false, revoked: true, state: "signin" }); + if (revision !== connectRevision) return; + connectCache = { at: Date.now(), value, refreshing: null }; }) .catch(() => { + if (revision !== connectRevision) return; connectCache = { ...connectCache, at: Date.now(), refreshing: null }; - }); + }).finally(() => { connectCache.refreshing = null; }); return connectCache.refreshing; }; -const forgetConnect = () => { - connectCache = { ...connectCache, at: 0 }; +const forgetConnect = (preferences) => { + connectRevision += 1; // A read started before a settings save must not replace its result. + const value = preferences && connectCache.value + ? { ...connectCache.value, preferences, publishAgentActivity: preferences.publishAgentActivity } + : connectCache.value; + connectCache = { ...connectCache, value, at: 0 }; }; /** @@ -1214,8 +1226,9 @@ const sourceControlSignIn = async (input, out = false) => { * The link it saves is made on T3 Code's next start; the page offers that * restart once this is done. */ -const startConnectLink = () => { - const child = spawn(T3_LAUNCHER, ["connect", "link", "--headless"], { +const startConnectLink = (mode = "managed") => { + if (!["managed", "publish_only"].includes(mode)) throw new Error("Choose a T3 Connect tunnel or notifications only."); + const child = spawn(T3_LAUNCHER, ["connect", "link", "--headless", ...(mode === "publish_only" ? ["--publish-only"] : [])], { env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"], }); @@ -2165,7 +2178,7 @@ const cancelLifecycle = (target, rawId) => { const ROUTES = [...FILES_ROUTES, "/login", "/logout", "/hello", "/status", "/pair", "/revoke", "/devices/rename", "/ports", "/storage/versions/prune", "/storage/versions", "/storage/refresh", "/storage/clean/preview", "/storage/clean", "/storage/policy", "/storage", - "/public-url/clear", "/public-url", "/setup-key/reveal", "/setup-key/replace", "/connect/unlink", "/t3/restart", + "/public-url/clear", "/public-url", "/setup-key/reveal", "/setup-key/replace", "/connect/unlink", "/connect/preferences", "/t3/restart", "/t3/updates/check", "/t3/update", "/ports/expose", "/ports/unexpose", "/auto-update", "/auto-update/policy", "/harnesses/install", "/harnesses/update", "/harnesses/uninstall", "/harnesses/cancel", "/harnesses/versions", "/harnesses/enable", "/harnesses", @@ -2888,7 +2901,7 @@ const server = createServer(async (req, res) => { try { const input = JSON.parse((await readBody(req)) || "{}"); if (input.agent === "antigravity") return sendJson(res, 200, publicSession(await startAntigravitySignin())); - if (input.agent === "connect") return sendJson(res, 200, publicSession(startConnectLink())); + if (input.agent === "connect") return sendJson(res, 200, publicSession(startConnectLink(input.mode))); // gh and az: a source control CLI's device code, from the harness's catalogue. await loadHarness(); if (harnessModule.getSourceControl(input.agent)) return sendJson(res, 200, publicSession(await startScmDeviceSignin(input.agent))); @@ -3015,6 +3028,15 @@ const server = createServer(async (req, res) => { return sendJson(res, answer.http, answer.body); } + if (req.method === "POST" && route === "/connect/preferences") { + let input; + try { input = JSON.parse((await readBody(req)) || "{}"); } + catch { return sendJson(res, 400, { ok: false, error: "Expected a connection setting." }); } + const answer = await connectPreferences.save(input); + forgetConnect(answer.body.preferences); + return sendJson(res, answer.http, answer.body); + } + // The key itself, for a signed-in browser to copy. Nothing new to it: one // signed in with T3 Code has a terminal that can read the same file. if (req.method === "POST" && route === "/setup-key/reveal") { diff --git a/docker/setup/t3-api.mjs b/docker/setup/t3-api.mjs index d52082a..34d1357 100644 --- a/docker/setup/t3-api.mjs +++ b/docker/setup/t3-api.mjs @@ -13,6 +13,8 @@ // `t3 auth` CLI for a few hours and renewed before it lapses. A sign-in belongs // to the session that started it, so renewal only ever happens on a fresh // connection, never under one in use. +// Native HTTP endpoints use a separate cached credential. Connect settings +// reads and renewals cannot retire the owner of a running RPC stream. // // const api = createT3Api({ url, issueSession }); // const config = await api.call("server.getConfig", {}); @@ -63,7 +65,9 @@ export function createT3Api({ idleMs = 60_000, renewWithinMs = 60 * 60 * 1000, }) { - let session = null; // { token, sessionId, expiresAt (ms) } + // A Connect read must never renew/revoke the session that owns a live RPC + // sign-in or installation stream. Each transport caches its own credential. + const credentials = { rpc: { value: null, pending: null }, http: { value: null, pending: null } }; let socket = null; // the open socket, or null let opening = null; // a connection on its way, shared let nextId = 0; @@ -136,19 +140,36 @@ export function createT3Api({ } }; - /** A session good for a while yet: issued now, or renewed when its end is near. */ - const ensureSession = async (force = false) => { - if (!force && session && session.expiresAt - now() > renewWithinMs) return session; - const previous = session; + const retireSession = (value) => { + if (value?.sessionId) void Promise.resolve().then(() => revokeSession(value.sessionId)).catch(() => {}); + }; + const assertGeneration = (generation) => { + if (generation !== sessionGeneration) throw new T3ApiError("T3 Code restarted during this request. Retry when it is ready."); + }; + + /** Cache and serialize issuance separately for HTTP and RPC. */ + const ensureSession = async (cache, force = false, refused = null) => { + if (cache.pending) return cache.pending; + // Another request may already have replaced the credential that got a + // delayed 401. Reuse its replacement rather than revoking it again. + if (force && refused && cache.value && cache.value !== refused) return cache.value; + if (!force && cache.value && cache.value.expiresAt - now() > renewWithinMs) return cache.value; + const previous = cache.value; const generation = sessionGeneration; - const issued = await issueSession(); - if (generation !== sessionGeneration) throw new T3ApiError("T3 Code restarted while the console was signing in. Retry when it is ready."); - if (!issued?.token) throw new T3ApiError("T3 Code did not issue a session for the console"); - session = { token: issued.token, sessionId: issued.sessionId ?? null, expiresAt: expiresAtMs(issued.expiresAt) }; - if (previous?.sessionId && previous.sessionId !== session.sessionId) { - void Promise.resolve(revokeSession(previous.sessionId)).catch(() => {}); - } - return session; + const issuing = (async () => { + const issued = await issueSession(); + if (generation !== sessionGeneration) { + retireSession(issued); + assertGeneration(generation); + } + if (!issued?.token) throw new T3ApiError("T3 Code did not issue a session for the console"); + cache.value = { token: issued.token, sessionId: issued.sessionId ?? null, expiresAt: expiresAtMs(issued.expiresAt) }; + if (previous?.sessionId !== cache.value.sessionId) retireSession(previous); + return cache.value; + })(); + cache.pending = issuing; + try { return await issuing; } + finally { if (cache.pending === issuing) cache.pending = null; } }; const settleAll = (error) => { @@ -211,7 +232,7 @@ export function createT3Api({ opening ??= (async () => { let lastError = null; for (const fresh of [false, true]) { - const { token } = await ensureSession(fresh); + const { token } = await ensureSession(credentials.rpc, fresh); const targetUrl = await socketUrl(sessionGeneration); try { return await new Promise((resolve, reject) => { @@ -312,6 +333,55 @@ export function createT3Api({ settleAll(new T3ApiError("The console closed its connection to T3 Code")); }; - const reset = () => { sessionGeneration += 1; protocolVersion = null; protocolDiscovery = null; close(); session = null; }; - return { call, subscribe, first, close, reset, sessionId: () => session?.sessionId ?? null }; + /** Native HTTP endpoints, independent of an active RPC session. */ + const http = async (path, body) => { + const generation = sessionGeneration; + const target = new URL(url); + target.protocol = target.protocol === "wss:" ? "https:" : "http:"; + target.pathname = path; + target.search = ""; + target.hash = ""; + let refused = null; + for (const fresh of [false, true]) { + const credential = await ensureSession(credentials.http, fresh, refused); + assertGeneration(generation); + const { token } = credential; + const response = await fetchImpl(target.toString(), { + method: body === undefined ? "GET" : "POST", + headers: { authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(callTimeoutMs), + redirect: "error", + }); + if (generation !== sessionGeneration) { + await response.body?.cancel(); + assertGeneration(generation); + } + if (response.status === 401 && !fresh) { + await response.body?.cancel(); + refused = credential; + continue; + } + if (!response.ok) { + await response.body?.cancel(); + throw new T3ApiError(`T3 Code could not ${body === undefined ? "read" : "save"} connection settings (HTTP ${response.status}).`); + } + const data = await response.json(); + assertGeneration(generation); + return data; + } + }; + + const reset = () => { + sessionGeneration += 1; + protocolVersion = null; + protocolDiscovery = null; + close(); + for (const cache of Object.values(credentials)) { + retireSession(cache.value); + cache.value = null; + cache.pending = null; + } + }; + return { call, http, subscribe, first, close, reset, sessionId: () => credentials.rpc.value?.sessionId ?? null }; } diff --git a/docs/agents.md b/docs/agents.md index 8ae040e..57e3731 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -59,9 +59,9 @@ Typing a prefix such as `2.1` picks the newest release under it. **OpenCode's version picker includes both 1.x and 2.x.** OpenCode 1 remains the default first-start install. Choose **Install a specific version…** on its row and search for `2` to install OpenCode 2 from the official `@opencode/cli` -package through mise. It requires T3 Code **Nightly** and OpenCode **2.0.18** -or newer. The two distributions share one row and one active `opencode` -command; **Update** stays within the major version you selected. +package through mise. It requires T3 Code **0.1.2** or newer, including **Stable**, +and OpenCode **2.0.18** or newer. The two distributions share one row and one +active `opencode` command; **Update** stays within the major version you selected. Before switching majors, stop running OpenCode sessions and keep a backup of its data. OpenCode 2 converts the shared OpenCode database on first use, and @@ -161,6 +161,12 @@ both count, as they do for T3 Code: | Muse Code | saved credentials in `~/.config/muse/auth.json`; sign-in is checked on the first message | | Antigravity | T3 Code's provider status | +Codex 0.164.0 changed API-key status from "Logged in using an API key" to +"API key configured". Setup recognizes both formats so a saved key updates +the Agents row and completes an active sign-in. +A configured key confirms local storage; Codex checks whether the key works +when you send a request. + Muse has no login-status command, and its model list works while signed out. The setup page reports saved credentials without claiming that they are verified. It ignores an inherited `META_API_KEY`, as T3 Code does; save the diff --git a/docs/media/agents-opencode/migration-phone-dark.png b/docs/media/agents-opencode/migration-phone-dark.png new file mode 100644 index 0000000..3af873e Binary files /dev/null and b/docs/media/agents-opencode/migration-phone-dark.png differ diff --git a/docs/media/agents-opencode/migration-phone-light.png b/docs/media/agents-opencode/migration-phone-light.png new file mode 100644 index 0000000..dbf4537 Binary files /dev/null and b/docs/media/agents-opencode/migration-phone-light.png differ diff --git a/docs/media/agents-opencode/picker-desktop-dark.png b/docs/media/agents-opencode/picker-desktop-dark.png new file mode 100644 index 0000000..76abc22 Binary files /dev/null and b/docs/media/agents-opencode/picker-desktop-dark.png differ diff --git a/docs/media/agents-opencode/picker-desktop-light.png b/docs/media/agents-opencode/picker-desktop-light.png new file mode 100644 index 0000000..99613f5 Binary files /dev/null and b/docs/media/agents-opencode/picker-desktop-light.png differ diff --git a/docs/media/connect-settings/choice-desktop-dark.png b/docs/media/connect-settings/choice-desktop-dark.png new file mode 100644 index 0000000..4995d8c Binary files /dev/null and b/docs/media/connect-settings/choice-desktop-dark.png differ diff --git a/docs/media/connect-settings/choice-desktop-light.png b/docs/media/connect-settings/choice-desktop-light.png new file mode 100644 index 0000000..2147321 Binary files /dev/null and b/docs/media/connect-settings/choice-desktop-light.png differ diff --git a/docs/media/connect-settings/choice-phone-dark.png b/docs/media/connect-settings/choice-phone-dark.png new file mode 100644 index 0000000..f6cd52b Binary files /dev/null and b/docs/media/connect-settings/choice-phone-dark.png differ diff --git a/docs/media/connect-settings/choice-phone-light.png b/docs/media/connect-settings/choice-phone-light.png new file mode 100644 index 0000000..81ed88d Binary files /dev/null and b/docs/media/connect-settings/choice-phone-light.png differ diff --git a/docs/media/connect-settings/error-desktop-dark.png b/docs/media/connect-settings/error-desktop-dark.png new file mode 100644 index 0000000..de67d3a Binary files /dev/null and b/docs/media/connect-settings/error-desktop-dark.png differ diff --git a/docs/media/connect-settings/error-desktop-light.png b/docs/media/connect-settings/error-desktop-light.png new file mode 100644 index 0000000..8a7f8bb Binary files /dev/null and b/docs/media/connect-settings/error-desktop-light.png differ diff --git a/docs/media/connect-settings/error-phone-dark.png b/docs/media/connect-settings/error-phone-dark.png new file mode 100644 index 0000000..112a85e Binary files /dev/null and b/docs/media/connect-settings/error-phone-dark.png differ diff --git a/docs/media/connect-settings/error-phone-light.png b/docs/media/connect-settings/error-phone-light.png new file mode 100644 index 0000000..f462ffb Binary files /dev/null and b/docs/media/connect-settings/error-phone-light.png differ diff --git a/docs/media/connect-settings/saving-desktop-dark.png b/docs/media/connect-settings/saving-desktop-dark.png new file mode 100644 index 0000000..c298341 Binary files /dev/null and b/docs/media/connect-settings/saving-desktop-dark.png differ diff --git a/docs/media/connect-settings/saving-desktop-light.png b/docs/media/connect-settings/saving-desktop-light.png new file mode 100644 index 0000000..4f99497 Binary files /dev/null and b/docs/media/connect-settings/saving-desktop-light.png differ diff --git a/docs/media/connect-settings/saving-phone-dark.png b/docs/media/connect-settings/saving-phone-dark.png new file mode 100644 index 0000000..eb97fcb Binary files /dev/null and b/docs/media/connect-settings/saving-phone-dark.png differ diff --git a/docs/media/connect-settings/saving-phone-light.png b/docs/media/connect-settings/saving-phone-light.png new file mode 100644 index 0000000..3451bfc Binary files /dev/null and b/docs/media/connect-settings/saving-phone-light.png differ diff --git a/docs/remote-access.md b/docs/remote-access.md index b3005e7..59ea4a6 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -101,18 +101,43 @@ tunnel of your own. Devices then connect through your T3 account instead of a pairing link, and Connect renews their credentials, so you don't re-pair every 30 days. -On the setup page, **Access → T3 Connect → Set up** shows a link and a code -to approve in any browser. T3 Code makes the link when it starts, so the page +On the setup page, **Access → T3 Connect → Set up** lets you choose a +**T3 Connect tunnel** or **Notifications only**, then shows a link and a code +to approve in any browser. Notifications only enables activity publishing +while devices keep using your own address, such as Tailscale or a reverse +proxy. T3 Code makes the link when it starts, so the page then offers to restart it, and shows when the link is made. The container keeps running throughout. **Turn off T3 Connect**, in its **⋯** menu, undoes it and keeps your sign-in, so turning it on again asks for no new code. +Under the same Access section, **Publish agent activity** enables T3's mobile +notifications and Live Activities for agents running on this server. Sign in +to the same T3 account in the mobile app and allow notifications there. These +are agent activity notifications; they do not report container health or setup +updates. The setting uses T3's native API and takes effect without a restart. +Activity publishing works with notification-only links; it does not require +routing your connections through a T3 Connect tunnel. If the server was +removed from your T3 account, **Set up again** restores its link. + +**Hold webhooks while offline** requires an active T3 Connect tunnel. When +enabled, T3 Connect stores incoming webhook requests outside this server for +up to 24 hours and delivers them when it returns. Turning it off does not +discard requests already held. The controls stay disabled until the server is +linked and its native settings can be read; update T3 Code if your version +does not support them. + From a shell, the same sign-in is: ```bash docker compose exec -it t3code t3-login connect ``` +For notifications through your own address, use: + +```bash +docker compose exec -it t3code t3-login connect link --publish-only +``` + The link then takes effect **on T3 Code's next start**: use **Restart** under **Server → T3 Code**, or restart the container. Use `t3-login connect` rather than `t3 connect` directly: `docker exec` runs as root, and Connect writes into diff --git a/scripts/access-ui-audit.js b/scripts/access-ui-audit.js index 00140d9..d202ef2 100644 --- a/scripts/access-ui-audit.js +++ b/scripts/access-ui-audit.js @@ -37,12 +37,15 @@ const geometry = () => { await page.addInitScript(mode => localStorage.setItem('t3-console-theme', mode), theme); page.on('pageerror', error => { errors.push(error.message); console.error('Browser error:',error.message); }); const status = statusFor('ready'); + status.connect = { state: 'on', linked: true, relayHost: 'relay.t3.codes', preferences: { linked: true, managedTunnelActive: true, publishAgentActivity: false, holdWebhooksWhileOffline: false } }; status.sessions = [{ sessionId: 'phone', setupLabel: 'Work phone', client: { platform: 'ios', label: 'Original phone' }, createdAt: new Date().toISOString() }, { sessionId: 'other', setupLabel: 'Other device', client: { platform: 'macos' }, createdAt: new Date().toISOString() }]; status.pairings = [{ id: 'spare', label: 'Spare link', createdAt: new Date().toISOString(), expiresAt: new Date(Date.now()+3600000).toISOString() }]; status.authLists = { session: { available: true, stale: false, refreshing: false }, pairing: { available: true, stale: false, refreshing: false } }; let heldStatus = null, renameGate = null, revokeGate = null, pairGate = null; let renameFail = false, revokeFail = false, pairFail = false, unavailable = false; + let preferenceGate = null, preferenceFail = false; + let connectAuthorized = false, connectMode = 'managed'; await page.route('**/*', async route => { const url = new URL(route.request().url()), method = route.request().method(); const at = suffix => url.pathname.endsWith(suffix); @@ -56,6 +59,26 @@ const geometry = () => { if (at('/ports')) return route.fulfill({ json: portsFor('ready') }); if (at('/providers')) return route.fulfill({ json: fixture('providers.json') }); if (at('/.well-known/t3/environment')) return route.fulfill({ json: {} }); + if (at('/auth/signin')) { + const input = route.request().postDataJSON(); + assert.equal(input.agent, 'connect'); connectMode = input.mode; + requests.push({ action: 'connect-signin', mode: input.mode }); + return route.fulfill({ json: { id: 'connect-fixture', state: 'awaiting-browser', url: 'https://fixture.invalid/device', code: 'TEST-CODE' } }); + } + if (at('/auth/session')) return route.fulfill({ json: { id: 'connect-fixture', state: connectAuthorized ? 'done' : 'awaiting-browser', url: 'https://fixture.invalid/device', code: 'TEST-CODE' } }); + if (at('/auth/cancel')) return route.fulfill({ json: { ok: true } }); + if (at('/t3/restart')) { + status.t3.pid += 1; + status.connect = { state: 'on', linked: true, linkMode: connectMode, relayHost: 'relay.t3.codes', preferences: { linked: true, managedTunnelActive: connectMode === 'managed', publishAgentActivity: connectMode === 'publish_only', holdWebhooksWhileOffline: false } }; + return route.fulfill({ status: 202, json: { ok: true } }); + } + if (at('/connect/preferences')) { + const input = route.request().postDataJSON(); requests.push({ action: 'preference', ...input }); + await preferenceGate?.promise; + if (preferenceFail) return route.fulfill({ status: 502, json: { ok: false, error: 'Could not save connection settings. Try again.' } }); + Object.assign(status.connect.preferences, input); + return route.fulfill({ json: { ok: true, preferences: status.connect.preferences } }); + } if (at('/devices/rename')) { const input = route.request().postDataJSON(); requests.push({ action:'rename', ...input }); await renameGate?.promise; if (renameFail) return route.fulfill({ status:503, json:{ ok:false,error:'Device names are temporarily unavailable. Try again.' } }); @@ -96,10 +119,96 @@ const geometry = () => { const device=t.locator('[data-key="dev-phone"]'), other=t.locator('[data-key="dev-other"]'); await device.waitFor().catch(async error => { console.error(await t.locator('body').innerText()); throw error; }); const check=async state=>assert.deepEqual(await t.evaluate(geometry),[],`${tag}: ${state}`); - const screenshot=async name=>{await check(name);await page.screenshot({path:path.join(output,`${tag}-${name}.png`)});}; + const screenshot = async name => { + if (['connection-saving', 'connection-saving-webhooks', 'connection-failed', 'connection-enabled', 'connection-unavailable'].includes(name)) { + await t.locator('[data-key="connect"]').evaluate(row => { + row.closest('.tc-group').scrollIntoView({ block: 'end', behavior: 'instant' }); + window.scrollBy({ top: (document.querySelector('.tc-tabbar')?.getBoundingClientRect().height || 0) + 16, behavior: 'instant' }); + }); + assert.equal(await t.locator('[data-key="connect-holdWebhooksWhileOffline"]').evaluate(row => { + const bar = document.querySelector('.tc-tabbar')?.getBoundingClientRect(); + return row.getBoundingClientRect().bottom <= (bar?.height ? bar.top : innerHeight) - 4; + }), true, `${tag}: the connection setting and its error stay above navigation`); + } + await check(name); + await page.screenshot({ path: path.join(output, `${tag}-${name}.png`) }); + }; const rename=async()=>{await t.locator('[data-cmd="device.menu"][data-id="phone"]').click();await t.getByRole('menuitem',{name:'Rename…',exact:true}).click();await t.locator('#device-label').waitFor();}; const revoke=async(kind,id)=>{await t.locator(`[data-cmd="${kind==='session'?'device':'link'}.menu"][data-id="${id}"]`).click();await t.getByRole('menuitem',{name:kind==='session'?'Revoke…':'Revoke link…',exact:true}).click();await t.getByRole('alertdialog').getByRole('button',{name:kind==='session'?'Revoke':'Revoke link',exact:true}).click();}; + const publish = t.getByRole('switch', { name: 'Publish agent activity', exact: true }); + const hold = t.getByRole('switch', { name: 'Hold webhooks while offline', exact: true }); + const publishRow = t.locator('[data-key="connect-publishAgentActivity"]'); + const holdRow = t.locator('[data-key="connect-holdWebhooksWhileOffline"]'); + preferenceGate = gate(); + await publish.click(); + assert.equal(await publish.isDisabled(), true); + assert.equal(await hold.isDisabled(), true); + assert.equal(await publishRow.getAttribute('aria-busy'), 'true'); + await publishRow.getByRole('status').filter({ hasText: 'Saving…' }).waitFor(); + assert.equal(await holdRow.getByRole('status').count(), 0, 'only the changed setting shows progress'); + await publish.evaluate(button => button.click()); + assert.equal(requests.filter(row => row.action === 'preference').length, 1); + await screenshot('connection-saving'); + preferenceGate.resolve(); preferenceGate = null; + await t.locator('#connect-toggle-publishAgentActivity[aria-checked=true]:enabled').waitFor(); + assert.equal(await hold.getAttribute('aria-checked'), 'false'); + preferenceFail = true; preferenceGate = gate(); await hold.click(); + await holdRow.getByRole('status').filter({ hasText: 'Saving…' }).waitFor(); + assert.equal(await publishRow.getByRole('status').count(), 0); + await screenshot('connection-saving-webhooks'); + preferenceGate.resolve(); preferenceGate = null; + await holdRow.getByRole('alert').filter({ hasText: 'Could not save connection settings. Try again.' }).waitFor(); + assert.equal(await publishRow.getByRole('alert').count(), 0, 'save errors stay beside the setting that failed'); + assert.equal(await hold.evaluate(button => button === document.activeElement), true, 'focus returns to the failed setting'); + assert.equal(await hold.getAttribute('aria-checked'), 'false'); + await screenshot('connection-failed'); + preferenceFail = false; await hold.click(); + await t.locator('#connect-toggle-holdWebhooksWhileOffline[aria-checked=true]:enabled').waitFor(); + assert.equal(await holdRow.getByRole('alert').count(), 0, 'a successful retry clears the error'); + assert.equal(await publish.getAttribute('aria-checked'), 'true'); + await screenshot('connection-enabled'); + status.connect.preferences.managedTunnelActive = false; + await page.clock.fastForward(16000); + await t.locator('#connect-toggle-holdWebhooksWhileOffline:disabled').waitFor(); + assert.equal(await publish.isDisabled(), false); + status.connect.preferences = null; + await page.clock.fastForward(16000); + await t.locator('#connect-toggle-publishAgentActivity:disabled').waitFor(); + await screenshot('connection-unavailable'); + + for (const mode of ['publish_only', 'managed']) { + connectAuthorized = false; + const removed = mode === 'managed'; + status.connect = { state: removed ? 'signin' : 'off', linked: false, revoked: removed, linkMode: 'managed', preferences: null }; + await page.clock.fastForward(16000); + const connect = t.locator('[data-key="connect"]'); + await connect.getByRole('button', { name: removed ? 'Set up again' : 'Set up', exact: true }).waitFor(); + assert.equal(await publish.isDisabled(), true); + if (removed) await connect.getByText(/removed from your T3 account/).waitFor(); + await connect.getByRole('button', { name: removed ? 'Set up again' : 'Set up', exact: true }).click(); + const dialog = t.getByRole('dialog', { name: 'T3 Connect', exact: true }); + assert.equal(await dialog.getByRole('radio', { name: /^T3 Connect tunnel/ }).isChecked(), true); + const selectedMode = dialog.getByRole('radio', { name: mode === 'publish_only' ? /^Notifications only/ : /^T3 Connect tunnel/ }); + await selectedMode.check(); + await page.clock.runFor(250); + assert.equal(await selectedMode.isChecked(), true); + await screenshot('connection-choice-' + mode); + await dialog.getByRole('button', { name: 'Continue', exact: true }).click(); + await dialog.getByText('TEST-CODE', { exact: true }).waitFor(); + assert.equal(requests.filter(row => row.action === 'connect-signin').at(-1).mode, mode); + connectAuthorized = true; + await page.clock.fastForward(1600); + await dialog.getByRole('button', { name: 'Restart T3 Code', exact: true }).click(); + await page.clock.fastForward(1600); + await dialog.getByRole('button', { name: 'Done', exact: true }).waitFor(); + await dialog.getByText(mode === 'publish_only' ? /through your own address/ : /through relay\.t3\.codes/).waitFor(); + await screenshot('connection-ready-' + mode); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + assert.equal(await publish.getAttribute('aria-checked'), String(mode === 'publish_only')); + assert.equal(await hold.isDisabled(), mode === 'publish_only'); + } + await rename(); await t.locator('#device-label').fill('Travel phone & "work"'); // Capture a poll before saving, then deliver it after the write. const old={started:gate(),release:gate()}; heldStatus=old; @@ -171,7 +280,7 @@ const geometry = () => { await t.getByText(/This link was revoked before a device used it/).waitFor(); await screenshot('link-revoked'); assert.deepEqual(errors,[]); - await context.close(); console.log(`PASS ${tag}: pending states, duplicates, failures/retry, stale polls and pairing`); + await context.close(); console.log(`PASS ${tag}: connection modes, relinking, preferences, pending states, failures/retry, stale polls and pairing`); } } finally { await browser.close(); } })().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/scripts/auto-update-ui-audit.js b/scripts/auto-update-ui-audit.js index 1f16fb5..894de3e 100644 --- a/scripts/auto-update-ui-audit.js +++ b/scripts/auto-update-ui-audit.js @@ -71,6 +71,7 @@ const geometry = () => { let holdStatus = null; let rustReply = 'fail', rustRequests = 0, rustStarted, finishRust; let runtimeReply = 'fail', runtimeRequests = 0, runtimeStarted, finishRuntime; + let opencodeInstalls = 0; const runtime = { ...fixture.t3?.runtime, channel: 'stable', version: '0.1.0', expectedPatchRevision: null, patchRevision: null, releases: { stable: { version: '0.1.0' }, nightly: { version: '0.1.1-nightly.20261017.1' } }, checkedAt: 1, checking: false, @@ -79,6 +80,18 @@ const geometry = () => { let requestedChannel; await host.route('**/*', async (route) => { const url = new URL(route.request().url()), method = route.request().method(); + if (url.pathname.endsWith('/harnesses/versions') && url.searchParams.get('id') === 'opencode') { + return route.fulfill({ json: { latest: '1.18.31', releases: [ + { version: '2.0.27', supported: true, minimumT3Version: '0.1.2' }, + { version: '2.0.18', supported: true, minimumT3Version: '0.1.2' }, + { version: '2.0.17', supported: false, minimumT3Version: '0.1.2' }, + { version: '1.18.31', supported: true }, + ] } }); + } + if (method === 'POST' && url.pathname.endsWith('/harnesses/install')) { + opencodeInstalls++; + return route.fulfill({ status: 202, json: { ok: true } }); + } if (url.pathname.endsWith('/status')) { const snapshot = structuredClone({ ...fixture, updates, t3: { ...fixture.t3, runtime }, autoUpdate: { policy, state: {}, lastActivityAt: Date.now(), busy: null } }); if (holdStatus) { const gate = holdStatus; holdStatus = null; gate.started(); await gate.wait; } @@ -180,6 +193,34 @@ const geometry = () => { await errorDialog.waitFor({ state: 'hidden' }); assert.equal(await page.evaluate(() => document.activeElement.dataset.cmd), 'row.error', 'Escape returns focus to Details'); + // OpenCode v2 works on Stable 0.1.2 too. Keep the version requirement + // distinct from the shared-database migration consent. + await failedRow.getByRole('button', { name: 'More for OpenCode', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Install a specific version…', exact: true }).click(); + const versions = page.getByRole('dialog', { name: 'Install a specific version of OpenCode', exact: true }); + await versions.getByRole('option').first().waitFor(); + await versions.getByLabel('Version', { exact: true }).fill('2'); + assert.equal(await versions.getByRole('option').count(), 3, 'the major prefix includes both supported and unsupported v2 releases'); + assert.equal(await versions.locator('[data-version="2.0.17"]').getAttribute('aria-disabled'), 'true', 'the v2 minimum remains enforced'); + assert.ok((await versions.locator('[data-version="2.0.27"]').textContent()).includes('T3 Code 0.1.2+'), 'the picker reports the runtime minimum'); + assert.ok(!(await versions.textContent()).includes('Nightly'), 'Stable users are not told to switch channels'); + await check('OpenCode v2 picker'); + await host.screenshot({ path: path.join(output, `${tag}-opencode-v2-picker.png`) }); + await versions.locator('[data-version="2.0.27"]').click(); + await versions.getByRole('button', { name: 'Install OpenCode 2.0.27', exact: true }).click(); + const migration = page.getByRole('alertdialog', { name: 'Install OpenCode 2?', exact: true }); + await migration.waitFor(); + const consent = await migration.textContent(); + assert.ok(consent.includes('T3 Code 0.1.2 or newer') && consent.includes('shared database'), 'the switch explains runtime support and data conversion'); + assert.ok(!consent.includes('Nightly'), 'the migration consent does not require Nightly'); + await check('OpenCode v2 migration'); + await host.screenshot({ path: path.join(output, `${tag}-opencode-v2-migration.png`) }); + await migration.getByRole('button', { name: 'Cancel', exact: true }).click(); + await migration.waitFor({ state: 'hidden' }); + assert.equal(opencodeInstalls, 0, 'cancelling migration never installs or changes the selected major'); + await versions.getByRole('button', { name: 'Cancel', exact: true }).click(); + await versions.waitFor({ state: 'hidden' }); + // Automatic updates: a switch per category on the Updates page, each // saved as it is pressed and independent of the others. url.hash = 'updates'; await page.goto(url.href); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 1ef5c9e..99aff5c 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1854,6 +1854,48 @@ check "the log says it was asked for" \ "output_has 'docker logs ${NAME}-one' 'restarting T3 Code, as asked on the setup page'" check "status reports T3 Connect off, with its relay client ready" \ "one_status | jq -e '.connect.state == \"off\" and .connect.relayClient.status == \"available\"'" +one_connect_preferences_are_gated() { + local endpoint="${ONE_URL}/__setup/connect/preferences" + local payload='{"publishAgentActivity":true}' + [ "$(curl -sS --noproxy '*' --max-time 10 -o /dev/null -w '%{http_code}' \ + -H 'content-type: application/json' -d "$payload" "$endpoint")" = 401 ] || return 1 + [ "$(curl -sS --noproxy '*' --max-time 10 -o /dev/null -w '%{http_code}' -b "$ONE_JAR" \ + -H 'sec-fetch-site: cross-site' -H 'content-type: application/json' -d "$payload" "$endpoint")" = 403 ] || return 1 + [ "$(curl -sS --noproxy '*' --max-time 10 -o /dev/null -w '%{http_code}' -b "$ONE_JAR" \ + -H 'content-type: application/json' -d '{"publishAgentActivity":"true"}' "$endpoint")" = 400 ] || return 1 + [ "$(curl -sS --noproxy '*' --max-time 25 -o /dev/null -w '%{http_code}' -b "$ONE_JAR" \ + -H 'content-type: application/json' -d "$payload" "$endpoint")" = 409 ] +} +check "connection preferences require sign-in, same-site access, booleans and a linked account" one_connect_preferences_are_gated +one_native_activity_preferences() { + docker exec -i --user t3 "${NAME}-one" node --input-type=module >>"$UI_AUDIT_LOG" 2>&1 <<'JS' +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createT3Api } from '/opt/t3-setup/t3-api.mjs'; +const cli = (...args) => execFileSync('/usr/local/bin/t3', args, { encoding: 'utf8' }); +const api = createT3Api({ url: 'ws://127.0.0.1:3773/ws', issueSession: async () => { + const output = cli('auth', 'session', 'issue', '--ttl', '4h', '--subject', 't3-setup-console', '--json'); + return JSON.parse(output.slice(output.indexOf('{'))); +} }); +let before; +try { + before = await api.http('/api/connect/link-state'); + assert.equal(before.linked, false, 'the smoke container must not have a real T3 account'); + await api.http('/api/connect/preferences', { publishAgentActivity: true }); + const saved = await api.http('/api/connect/link-state'); + assert.equal(saved.publishAgentActivity, true); + assert.equal(saved.holdWebhooksWhileOffline, before.holdWebhooksWhileOffline); +} finally { + try { + if (before) await api.http('/api/connect/preferences', { publishAgentActivity: before.publishAgentActivity }); + } finally { + api.close(); + if (api.sessionId()) cli('auth', 'session', 'revoke', api.sessionId()); + } +} +JS +} +check "native activity preferences persist without changing webhook holding or linking an account" one_native_activity_preferences # docker stop reaches T3 Code through the supervisor, which then exits with # it, well inside the timeout: a SIGKILL at the deadline would read as 137. one_stops_cleanly() { diff --git a/scripts/test-toolchain-e2e.sh b/scripts/test-toolchain-e2e.sh index 04080fa..45f3b92 100755 --- a/scripts/test-toolchain-e2e.sh +++ b/scripts/test-toolchain-e2e.sh @@ -547,7 +547,7 @@ has "the launcher still runs the recorded version" "${VERSION[claude]}" "$(dex " is "T3 still runs it through the launcher" "$claude_launcher" \ "$(field '.providerInstances.claudeAgent.config.binaryPath // .providers.claudeAgent.binaryPath // empty' "$(settings_body)")" -section "OpenCode v2 in an isolated home (Stable keeps its v1 provider)" +section "OpenCode v2 in an isolated home (the default install stays on v1)" docker cp "${ROOT}/scripts/opencode-auth-smoke.mjs" "$NAME:/tmp/opencode-auth-smoke.mjs" >/dev/null opencode_v2_out="$(docker exec -i -u t3 \ -e HOME=/tmp/t3-opencode-v2-e2e \ @@ -568,7 +568,7 @@ const manager = createHarnessManager(); await mkdir(process.env.HOME, { recursive: true }); const versions = await manager.versions("opencode"); assert.ok(versions.latest.startsWith("1.")); -assert.ok(versions.releases.some(r => r.version.startsWith("2.") && r.supported && r.requiresNightly)); +assert.ok(versions.releases.some(r => r.version.startsWith("2.") && r.supported && !r.requiresNightly && r.minimumT3Version === "0.1.2")); const first = await manager.install("opencode"); assert.equal(first.ok, true, first.error); assert.ok(first.harness.installedVersion.startsWith("1.")); diff --git a/tests/connect-api.test.mjs b/tests/connect-api.test.mjs new file mode 100644 index 0000000..c297f27 --- /dev/null +++ b/tests/connect-api.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createServer } from 'node:http'; +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; + +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +const exec = promisify(execFile); + +test('connection saves survive failed refreshes and sign-in forwards only supported modes', { timeout: 30000 }, async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't3-connect-api-')); + const launcher = path.join(root, 't3.mjs'); + const nativeState = { linked: true, managedTunnelActive: true, publishAgentActivity: false, holdWebhooksWhileOffline: false }; + let nativeReads = 0; + let heldRead = null; + const native = createServer(async (req, res) => { + res.setHeader('content-type', 'application/json'); + if (req.url === '/api/connect/link-state') { + nativeReads++; + const snapshot = JSON.stringify(nativeState), held = heldRead; heldRead = null; + if (held) { held.started(); await held.wait; } + res.end(snapshot); + } + else if (req.url === '/api/connect/preferences') { + let body = ''; for await (const chunk of req) body += chunk; + Object.assign(nativeState, JSON.parse(body)); + res.end(JSON.stringify(nativeState)); + } else { res.statusCode = 404; res.end('{}'); } + }).listen(0, '127.0.0.1'); + await once(native, 'listening'); + await fs.writeFile(launcher, `#!${process.execPath} +import fs from 'node:fs/promises'; +const root = ${JSON.stringify(root)}; +const args = process.argv.slice(2); +if (args[0] === 'connect' && args[1] === 'status') { + try { await fs.access(root + '/fail-status'); process.exit(1); } catch {} + console.log(JSON.stringify({ desired: true, authenticated: true, linked: true })); +} else if (args[0] === 'connect' && args[1] === 'link') { + await fs.writeFile(root + '/link-args', JSON.stringify(args)); + console.log('Open this URL on a device with a browser:\\nhttps://fixture.invalid/device\\nConfirm this code when asked: TEST-CODE'); + setInterval(() => {}, 1000); +} else if (args[0] === 'auth' && args[2] === 'issue') { + console.log(JSON.stringify({ token: 'fixture', sessionId: 'console', expiresAt: '2099-01-01T00:00:00Z' })); +} else if (args[0] === 'auth') console.log('[]'); +else process.exit(1); +`, { mode: 0o700 }); + 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, + 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') }; + const child = spawn(process.execPath, [fileURLToPath(new URL('../docker/setup/server.mjs', import.meta.url))], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let logs = ''; child.stdout.on('data', x => { logs += x; }); child.stderr.on('data', x => { logs += x; }); + t.after(async () => { + if (child.exitCode === null) { const ended = once(child, 'exit'); child.kill(); await ended; } + native.closeAllConnections(); await new Promise(resolve => native.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + }); + const request = (route, body, headers = { 'x-t3-setup-key': 'fixture' }) => fetch(`http://127.0.0.1:${port}${route}`, { + method: body === undefined ? 'GET' : 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), + }); + for (let i = 0; i < 200; i++) { + if (child.exitCode !== null) throw new Error(logs); + try { await request('/hello'); break; } catch { await pause(20); } + } + const first = await (await request('/status')).json(); + assert.equal(first.connect.preferences.publishAgentActivity, false); + assert.equal((await request('/connect/preferences', { publishAgentActivity: true }, {})).status, 401); + assert.equal((await request('/connect/preferences', { publishAgentActivity: true }, { 'x-t3-setup-key': 'fixture', 'sec-fetch-site': 'cross-site' })).status, 403); + await fs.writeFile(path.join(root, 'fail-status'), 'yes'); + const beforeReads = nativeReads; + const saved = await request('/connect/preferences', { publishAgentActivity: true }); + assert.equal(saved.status, 200); + assert.equal((await saved.json()).preferences.publishAgentActivity, true); + assert.equal(nativeReads - beforeReads, 1, 'only the pre-save read is needed'); + const after = await (await request('/status')).json(); + assert.equal(after.connect.preferences.publishAgentActivity, true, 'a failed CLI refresh must keep the confirmed save'); + assert.equal(after.connect.publishAgentActivity, true, 'the CLI-shaped field must agree with preferences'); + + await fs.rm(path.join(root, 'fail-status')); + await request('/connect/preferences', { publishAgentActivity: false }); + let started, release; + const reading = new Promise(resolve => { started = resolve; }); + heldRead = { started, wait: new Promise(resolve => { release = resolve; }) }; + t.after(() => release()); + const oldPoll = request('/status'); + await reading; + const newer = await request('/connect/preferences', { publishAgentActivity: true }); + assert.equal(newer.status, 200); + release(); await oldPoll; await pause(20); + assert.equal((await (await request('/status')).json()).connect.preferences.publishAgentActivity, true, 'an older in-flight read cannot replace a confirmed save'); + + nativeState.linkRevoked = true; + assert.equal((await request('/connect/preferences', { publishAgentActivity: false })).status, 409); + const revoked = (await (await request('/status')).json()).connect; + assert.equal(revoked.state, 'signin', 'live revocation wins over an older CLI link'); + assert.equal(revoked.revoked, true); + assert.equal(revoked.linked, false); + + for (const mode of ['publish_only', 'managed']) { + const start = await request('/auth/signin', { agent: 'connect', mode }); + assert.equal(start.status, 200); + const session = await start.json(); + for (let i = 0; i < 100; i++) { try { await fs.access(path.join(root, 'link-args')); break; } catch { await pause(10); } } + assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, 'link-args'), 'utf8')), ['connect', 'link', '--headless', ...(mode === 'publish_only' ? ['--publish-only'] : [])]); + await request('/auth/cancel', { id: session.id }); + await fs.rm(path.join(root, 'link-args')); + } + for (const mode of ['invalid', '--headless', null, true, {}]) assert.equal((await request('/auth/signin', { agent: 'connect', mode })).status, 400); + await assert.rejects(fs.access(path.join(root, 'link-args')), { code: 'ENOENT' }); + + const login = fileURLToPath(new URL('../docker/bin/t3-login', import.meta.url)); + const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"; + const command = ['bash', login, 'connect', 'link', '--publish-only', '--help'].map(quote).join(' '); + // A help-only launcher exits immediately; no account or device sign-in is used. + await fs.writeFile(launcher, `#!${process.execPath}\nconsole.log(JSON.stringify(process.argv.slice(2)));\n`, { mode: 0o700 }); + const result = await exec('script', ['-q', '-e', '-c', command, '/dev/null'], { env, timeout: 10000 }); + assert.match(result.stdout, /\["connect","link","--publish-only","--help"\]/); +}); diff --git a/tests/connect.test.mjs b/tests/connect.test.mjs index 8233eb6..0e07740 100644 --- a/tests/connect.test.mjs +++ b/tests/connect.test.mjs @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { connectState, jsonFrom, parseLinkOutput } from "../docker/setup/connect.mjs"; +import { connectState, createConnectPreferences, jsonFrom, parseLinkOutput } from "../docker/setup/connect.mjs"; const PROMPT = [ "T3 Connect", @@ -57,7 +57,7 @@ test("the saved state reads as off, signed out, waiting on a start, or on", () = const relayClient = { status: "available", executablePath: "/usr/local/bin/cloudflared", source: "override", version: "2026.5.2" }; const base = { desired: false, authenticated: false, linked: false, cloudUserId: null, relayUrl: null, publishAgentActivity: false, relayClient }; assert.deepEqual(connectState(base), { - state: "off", desired: false, authenticated: false, linked: false, relayHost: null, publishAgentActivity: false, + state: "off", desired: false, authenticated: false, linked: false, revoked: false, linkMode: "managed", relayHost: null, publishAgentActivity: false, relayClient: { status: "available", version: "2026.5.2" }, }); assert.equal(connectState({ ...base, desired: true, authenticated: true }).state, "pending"); @@ -82,3 +82,93 @@ test("the JSON is found after any lines the CLI prints first", () => { assert.equal(jsonFrom("{ broken"), null); assert.equal(jsonFrom(undefined), null); }); + +test("native preference updates preserve activity publishing when changing webhook holding", async () => { + const state = { linked: true, managedTunnelActive: true, publishAgentActivity: true, holdWebhooksWhileOffline: false }; + const writes = []; + const prefs = createConnectPreferences({ request: async (path, input) => { + if (input) { assert.equal(path, "/api/connect/preferences"); writes.push(input); Object.assign(state, input); } + else assert.equal(path, "/api/connect/link-state"); + return { ...state }; + } }); + assert.equal((await prefs.save({ holdWebhooksWhileOffline: true })).http, 200); + assert.deepEqual(writes[0], { publishAgentActivity: true, holdWebhooksWhileOffline: true }); + assert.equal((await prefs.save({ publishAgentActivity: false })).body.preferences.holdWebhooksWhileOffline, true); + assert.deepEqual(writes[1], { publishAgentActivity: false }, "omitting webhook holding preserves it on T3"); +}); + +test("notification-only links can publish; offline holding needs a tunnel and version support", async () => { + const state = { linked: true, managedTunnelActive: false, publishAgentActivity: false }; + let writes = 0; + const prefs = createConnectPreferences({ request: async (_path, input) => { if (input) writes++; return state; } }); + assert.equal((await prefs.read()).holdWebhooksWhileOffline, null); + assert.equal((await prefs.save({ holdWebhooksWhileOffline: true })).http, 409); + assert.equal(writes, 0); + assert.equal((await prefs.save({ publishAgentActivity: true })).http, 200); + state.linked = false; + assert.equal((await prefs.save({ publishAgentActivity: true })).http, 409); + assert.equal(writes, 1); +}); + +test("malformed preferences cannot reach T3 and a failed save remains retryable", async () => { + let calls = 0, fail = true; + const prefs = createConnectPreferences({ request: async (_path, input) => { + calls++; + if (input && fail) throw new Error("T3 Code is unavailable"); + return { linked: true, publishAgentActivity: false }; + } }); + for (const input of [null, [], {}, { publishAgentActivity: "true" }, { other: true }, { publishAgentActivity: true, holdWebhooksWhileOffline: true }]) { + assert.equal((await prefs.save(input)).http, 400); + } + assert.equal(calls, 0); + assert.equal((await prefs.save({ publishAgentActivity: true })).http, 502); + fail = false; + assert.equal((await prefs.save({ publishAgentActivity: true })).http, 200); +}); + +test("overlapping connection writes cannot overwrite each other", async () => { + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const prefs = createConnectPreferences({ request: async () => { await gate; return { linked: true, publishAgentActivity: true }; } }); + const first = prefs.save({ publishAgentActivity: false }); + assert.equal((await prefs.save({ publishAgentActivity: true })).http, 409); + release(); + assert.equal((await first).http, 200); +}); + +test("revoked links need relinking and cannot save preferences", async () => { + const cli = connectState({ desired: true, authenticated: true, linked: true, revoked: true, linkMode: "publish_only" }); + assert.equal(cli.state, "signin"); + assert.equal(cli.linked, false); + assert.equal(cli.revoked, true); + assert.equal(cli.linkMode, "publish_only"); + let writes = 0; + const prefs = createConnectPreferences({ request: async (_path, input) => { + if (input) writes++; + return { linked: true, linkRevoked: true, managedTunnelActive: true, publishAgentActivity: true, holdWebhooksWhileOffline: false }; + } }); + assert.equal((await prefs.read()).linked, false); + assert.equal((await prefs.read()).managedTunnelActive, false); + assert.equal((await prefs.save({ publishAgentActivity: false })).http, 409); + assert.equal(writes, 0); +}); + +test("older linked servers imply a managed tunnel when its optional flag is absent", async () => { + const prefs = createConnectPreferences({ request: async () => ({ linked: true, publishAgentActivity: false }) }); + assert.equal((await prefs.read()).managedTunnelActive, true); +}); + +test("the native save response is authoritative without a fallible follow-up read", async () => { + let reads = 0; + const state = { linked: true, managedTunnelActive: true, publishAgentActivity: false, holdWebhooksWhileOffline: true }; + const prefs = createConnectPreferences({ request: async (_path, input) => { + if (input) { Object.assign(state, input); return { ...state }; } + if (++reads > 1) throw new Error("A second read would fail after the setting was saved"); + return { ...state }; + } }); + const saved = await prefs.save({ publishAgentActivity: true }); + assert.equal(saved.http, 200); + assert.equal(saved.body.preferences.publishAgentActivity, true); + assert.equal(saved.body.preferences.holdWebhooksWhileOffline, true); + assert.equal(reads, 1); +}); diff --git a/tests/console-model.test.mjs b/tests/console-model.test.mjs index a7a7748..565b134 100644 --- a/tests/console-model.test.mjs +++ b/tests/console-model.test.mjs @@ -269,9 +269,9 @@ test("Muse offers opt-in install, sign-in and updates without claiming saved cre test("OpenCode v2 releases can be found by major prefix, preserving release-age and minimum-version markers", () => { const releases = [ - { version: "2.0.28", waiting: true, supported: true, requiresNightly: true }, - { version: "2.0.27", supported: true, requiresNightly: true }, - { version: "2.0.17", supported: false, requiresNightly: true }, + { version: "2.0.28", waiting: true, supported: true, minimumT3Version: "0.1.2" }, + { version: "2.0.27", supported: true, minimumT3Version: "0.1.2" }, + { version: "2.0.17", supported: false, minimumT3Version: "0.1.2" }, { version: "1.19.0", supported: true }, ]; assert.deepEqual(plain(M.matchReleases(releases, "2")).map(r => r.version), ["2.0.28", "2.0.27", "2.0.17"]); @@ -523,6 +523,12 @@ test("T3 Connect reads as one state with at most one next step", () => { assert.equal(on.action, null, "on, nothing is owed: turning it off is in its menu"); assert.deepEqual(on.menu.map((m) => m.cmd), ["connect.off"]); assert.equal(on.menu[0].danger, true); + assert.match(view(saved({ state: "on", preferences: { linked: true, managedTunnelActive: false } })).text, /through your own address/); + assert.match(view(saved({ state: "on", linkMode: "publish_only" })).text, /through your own address/); + assert.match(view(saved({ state: "on", preferences: { linked: true } })).text, /through T3’s relay/); + const revoked = view(saved({ state: "signin", revoked: true })); + assert.equal(revoked.action.label, "Set up again"); + assert.match(revoked.text, /removed from your T3 account/); assert.equal(view(saved({ state: "signin", desired: true })).action.cmd, "connect.setup"); assert.deepEqual(view(null), { state: "unknown", badge: null, text: "Couldn’t read its state just now.", action: null }); diff --git a/tests/harness-manager.test.mjs b/tests/harness-manager.test.mjs index 27b7e19..683d433 100644 --- a/tests/harness-manager.test.mjs +++ b/tests/harness-manager.test.mjs @@ -607,6 +607,27 @@ test("OpenCode v2 keeps the native key API floor for installs and existing selec assert.equal(result.harness.runnable, true); }); +test("OpenCode v2 installs a reviewed low-download package without changing other tool policy", async () => { + const fs = new MemoryFs(); + const world = createWorld(fs), run = world.run; + world.run = async (argv, options) => { + const spec = argv.at(-1); + if (argv[0] === "mise" && argv.includes("use") && spec.startsWith("npm:@opencode/cli") && !spec.includes("allow_low_downloads=true")) { + return world.fail("aube install failed: refusing to add @opencode/cli: only 0 weekly downloads (threshold: 1000)"); + } + return run(argv, options); + }; + const manager = managerFor(world); + const installed = await manager.install("opencode", { version: "2.0.18" }); + assert.equal(installed.ok, true, installed.error); + assert.equal(installed.harness.verifiedVersion, "2.0.18"); + assert.equal(await manager.latest("opencode"), "2.0.27"); + assert.equal((await manager.update("opencode")).ok, true); + assert.ok(world.useSpecs.every(spec => spec.includes("allow_builds=@opencode/cli") && spec.includes("allow_low_downloads=true"))); + assert.equal((await manager.install("codex")).ok, true); + assert.equal(world.useSpecs.at(-1), "codex@0.154.1", "other curated tools do not inherit this approval"); +}); + test("OpenCode v2 is an explicit distribution switch with scoped postinstall and one active selection", async () => { const fs = new MemoryFs(); const world = createWorld(fs); @@ -622,8 +643,8 @@ test("OpenCode v2 is an explicit distribution switch with scoped postinstall and assert.equal(switched.harness.verifiedVersion, "2.0.27"); assert.equal(switched.harness.minimumVersion, "2.0.18"); assert.equal(switched.harness.executable, world.execPath("npm:@opencode/cli", "2.0.27")); - assert.equal(world.useSpecs[1], "npm:@opencode/cli[allow_builds=@opencode/cli]@2.0.27"); - assert.ok(world.calls.some(call => call.includes("which --tool npm:@opencode/cli[allow_builds=@opencode/cli]@2.0.27 opencode"))); + assert.equal(world.useSpecs[1], "npm:@opencode/cli[allow_builds=@opencode/cli,allow_low_downloads=true]@2.0.27"); + assert.ok(world.calls.some(call => call.includes("which --tool npm:@opencode/cli[allow_builds=@opencode/cli,allow_low_downloads=true]@2.0.27 opencode"))); assert.equal(world.tools.opencode.some(item => item.source), false, "v1 no longer owns the command"); assert.equal(await fs.readFile(credential), '{"anthropic":{"type":"api","key":"test-key"}}'); assert.deepEqual(switched.harness.managedVersions, ["1.18.31", "2.0.27"]); @@ -643,7 +664,7 @@ test("OpenCode floating installs, updates and latest checks stay within the sele assert.equal(await manager.latest("opencode"), "2.0.28"); assert.equal((await manager.update("opencode")).harness.installedVersion, "2.0.28"); assert.equal((await manager.install("opencode")).harness.installedVersion, "2.0.28"); - assert.ok(world.useSpecs.at(-1).includes("[allow_builds=@opencode/cli]@2.0.28"), "updates retain the vendor's required postinstall"); + assert.ok(world.useSpecs.at(-1).includes("[allow_builds=@opencode/cli,allow_low_downloads=true]@2.0.28"), "updates retain the vendor's required postinstall"); }); test("OpenCode v1 remains installable and discoverable when the short-name feed only lists v2", async () => { @@ -669,7 +690,7 @@ test("OpenCode v1 remains installable and discoverable when the short-name feed const choices = await manager.versions("opencode"); assert.equal(choices.latest, "1.18.32"); assert.ok(choices.releases.some(release => release.version === "1.18.32" && !release.requiresNightly)); - assert.ok(choices.releases.some(release => release.version === "2.0.27" && release.requiresNightly)); + assert.ok(choices.releases.some(release => release.version === "2.0.27" && !release.requiresNightly && release.minimumT3Version === "0.1.2")); }); test("an explicit switch back to OpenCode v1 verifies that distribution even when v2 shadows its command", async () => { @@ -730,7 +751,7 @@ test("a failed v2 verification leaves the v1 selection and record usable", async const world = createWorld(fs); const manager = managerFor(world); await manager.install("opencode"); - world.runVersionOverride["npm:@opencode/cli[allow_builds=@opencode/cli]@2.0.27"] = "1.18.31"; + world.runVersionOverride["npm:@opencode/cli[allow_builds=@opencode/cli,allow_low_downloads=true]@2.0.27"] = "1.18.31"; const failed = await manager.install("opencode", { version: "2.0.27" }); assert.equal(failed.code, "version-mismatch"); const retained = await manager.resolve("opencode"); @@ -759,7 +780,7 @@ test("OpenCode uninstall removes managed versions of both distributions and pres test("OpenCode adopts an existing v2 mise selection without defaulting back to v1", async () => { const fs = new MemoryFs(); const world = createWorld(fs); - await world.run(["mise", "-C", HOME, "use", "-g", "npm:@opencode/cli[allow_builds=@opencode/cli]@2.0.27"]); + await world.run(["mise", "-C", HOME, "use", "-g", "npm:@opencode/cli[allow_builds=@opencode/cli,allow_low_downloads=true]@2.0.27"]); const manager = managerFor(world); assert.equal((await manager.resolve("opencode")).installedVersion, "2.0.27"); assert.equal(await manager.latest("opencode"), "2.0.27"); @@ -2145,7 +2166,8 @@ test("an agent's releases list newest first, marking the ones mise still holds b const opencode = await manager.versions("opencode"); assert.deepEqual(opencode.releases.map((r) => [r.version, r.supported]), [["2.0.27", true], ["2.0.20", true], ["2.0.19", true], ["2.0.18", true], ["2.0.17", false], ["1.18.31", true], ["1.14.0", false]]); assert.equal(opencode.latest, "1.18.31", "the default stays on v1"); - assert.equal(opencode.releases[0].requiresNightly, true); + assert.ok(opencode.releases.filter(r => r.version.startsWith("2.")).every(r => !r.requiresNightly && r.minimumT3Version === "0.1.2"), "OpenCode v2 is supported on Stable 0.1.2 and newer"); + assert.ok(opencode.releases.filter(r => r.version.startsWith("1.")).every(r => !r.requiresNightly && r.minimumT3Version === undefined), "the v2 runtime minimum does not apply to v1"); await assert.rejects(manager.versions("nope"), /unknown harness/); }); diff --git a/tests/t3-api.test.mjs b/tests/t3-api.test.mjs index 5c48387..41ead60 100644 --- a/tests/t3-api.test.mjs +++ b/tests/t3-api.test.mjs @@ -10,11 +10,146 @@ import assert from "node:assert/strict"; import test from "node:test"; import { antigravityFacts, createAntigravity } from "../docker/setup/antigravity.mjs"; -import { createT3Api, failureOf } from "../docker/setup/t3-api.mjs"; +import { createT3Api, failureOf, T3ApiError } from "../docker/setup/t3-api.mjs"; const tick = () => new Promise((resolve) => setImmediate(resolve)); const settle = async (n = 6) => { for (let i = 0; i < n; i += 1) await tick(); }; +test("native HTTP requests authenticate, refresh a refused session, and share the cached credential", async () => { + let issued = 0; + const requests = []; + const api = createT3Api({ url: "ws://127.0.0.1:3773/ws?orchestrationProtocol=1", + issueSession: async () => ({ token: `token-${++issued}`, sessionId: `session-${issued}`, expiresAt: new Date(Date.now() + 4 * 60 * 60_000).toISOString() }), + fetchImpl: async (url, options) => { + requests.push({ url, ...options }); + return options.headers.authorization === "Bearer token-1" ? new Response(null, { status: 401 }) : Response.json({ linked: true }); + }, + }); + assert.deepEqual(await api.http("/api/connect/link-state"), { linked: true }); + await api.http("/api/connect/preferences", { publishAgentActivity: true }); + assert.equal(issued, 2); + assert.equal(requests[2].url, "http://127.0.0.1:3773/api/connect/preferences"); + assert.equal(requests[2].method, "POST"); + assert.deepEqual(JSON.parse(requests[2].body), { publishAgentActivity: true }); + assert.equal(requests[2].redirect, "error"); + api.reset(); + await api.http("/api/connect/link-state"); + assert.equal(issued, 3); +}); + +test("native HTTP errors stay errors without exposing upstream response bodies", async () => { + let calls = 0; + const api = createT3Api({ url: "ws://127.0.0.1/ws", + issueSession: async () => ({ token: "private-token" }), + fetchImpl: async () => { calls++; return new Response("private upstream details", { status: 403 }); }, + }); + await assert.rejects(api.http("/api/connect/preferences", { publishAgentActivity: true }), /HTTP 403/); + assert.equal(calls, 1, "permission errors must not replay a mutation"); +}); + +test("HTTP renewal preserves the session of an active agent stream", async (t) => { + let clock = Date.now(); + const t3 = fakeT3(); + const { api, issued, revoked } = apiFor(t3, { + get expiresAt() { return new Date(clock + 4 * 60 * 60_000).toISOString(); }, + options: { now: () => clock, fetchImpl: async () => Response.json({ linked: true }), idleMs: 60_000 }, + }); + t.after(() => api.close()); + const sub = await api.subscribe("provider.auth.subscribe", { instanceId: "antigravity" }, () => {}); + t.after(() => sub.close()); + await settle(); + assert.equal(api.sessionId(), "s1"); + await api.http("/api/connect/link-state"); + assert.equal(issued.length, 2, "HTTP uses its own cached credential"); + assert.deepEqual(revoked, []); + clock += 3.5 * 60 * 60_000; + await api.http("/api/connect/link-state"); + assert.equal(issued.length, 3); + assert.deepEqual(revoked, ["s2"], "only the old HTTP credential is retired"); + assert.equal(api.sessionId(), "s1", "the stream keeps its original session"); + t3.setAuth({ phase: "awaiting_browser" }); + await settle(); + assert.equal(t3.sockets[0].closed, false); + sub.close(); api.close(); + await api.call("server.getConfig", {}); + assert.equal(api.sessionId(), "s4", "RPC renews after the original socket closes"); + assert.deepEqual(revoked, ["s2", "s1"]); +}); + +test("concurrent HTTP requests share one credential issuance", async (t) => { + let release, issued = 0; + const gate = new Promise(resolve => { release = resolve; }); + const api = createT3Api({ url: "ws://127.0.0.1/ws", + issueSession: async () => { const id = ++issued; await gate; return { token: `token-${id}`, sessionId: `s${id}`, expiresAt: new Date(Date.now() + 4 * 60 * 60_000).toISOString() }; }, + fetchImpl: async () => Response.json({ linked: true }), + }); + t.after(() => { release(); api.close(); }); + const requests = [api.http("/api/connect/link-state"), api.http("/api/connect/link-state")]; + await settle(); + release(); + await Promise.all(requests); + assert.equal(issued, 1); +}); + +test("a late HTTP authorization failure reuses an already refreshed credential", async (t) => { + let issued = 0, refuse = false, oldReads = 0, release; + const gate = new Promise(resolve => { release = resolve; }); + const revoked = []; + const api = createT3Api({ url: "ws://127.0.0.1/ws", + issueSession: async () => { const id = ++issued; return { token: `token-${id}`, sessionId: `s${id}`, expiresAt: new Date(Date.now() + 4 * 60 * 60_000).toISOString() }; }, + revokeSession: async id => { revoked.push(id); }, + fetchImpl: async (_url, options) => { + if (refuse && options.headers.authorization === "Bearer token-1") { + if (++oldReads === 2) await gate; + return new Response(null, { status: 401 }); + } + return Response.json({ linked: true }); + }, + }); + t.after(() => { release(); api.close(); }); + await api.http("/api/connect/link-state"); + refuse = true; + const first = api.http("/api/connect/link-state"), second = api.http("/api/connect/link-state"); + await first; + release(); await second; + assert.equal(oldReads, 2, "both requests initially used the refused credential"); + assert.equal(issued, 2, "a stale 401 must not rotate a second time"); + assert.deepEqual(revoked, ["s1"]); +}); + +test("restart discards and revokes an in-flight HTTP credential without clearing its replacement", async (t) => { + let issued = 0, release; + const gate = new Promise(resolve => { release = resolve; }); + const revoked = []; + const api = createT3Api({ url: "ws://127.0.0.1/ws", + issueSession: async () => { const id = ++issued; if (id === 1) await gate; return { token: `token-${id}`, sessionId: `s${id}`, expiresAt: new Date(Date.now() + 4 * 60 * 60_000).toISOString() }; }, + revokeSession: async id => { revoked.push(id); }, + fetchImpl: async () => Response.json({ linked: true }), + }); + t.after(() => { release(); api.close(); }); + const stale = assert.rejects(api.http("/api/connect/link-state"), /restarted/); + await settle(); + api.reset(); + await api.http("/api/connect/link-state"); + release(); await stale; + await api.http("/api/connect/link-state"); + assert.equal(issued, 2, "the replacement remains cached"); + assert.deepEqual(revoked, ["s1"], "the discarded credential must not linger for four hours"); +}); + +test("an HTTP mutation whose response arrives after restart is not confirmed or replayed", async (t) => { + let release, requests = 0; + const gate = new Promise(resolve => { release = resolve; }); + const api = createT3Api({ url: "ws://127.0.0.1/ws", + issueSession: async () => ({ token: "fixture", sessionId: "fixture" }), + fetchImpl: async () => { requests++; await gate; return Response.json({ publishAgentActivity: true }); }, + }); + t.after(() => { release(); api.close(); }); + const pending = assert.rejects(api.http("/api/connect/preferences", { publishAgentActivity: true }), /restarted/); + await settle(); api.reset(); release(); await pending; + assert.equal(requests, 1, "an old mutation must not retry against the restarted server"); +}); + /** A T3 with one Antigravity provider, an installer and a Google sign-in. */ function fakeT3({ tokens = new Set(["good"]), protocolVersion = 1, settingsFormat = "legacy" } = {}) { const t3 = { @@ -199,13 +334,14 @@ test("a server restart replaces the console credential before reconnecting", asy test("an in-flight credential from the previous release is discarded on restart", async () => { const t3 = fakeT3(); let issue; - const { api } = apiFor(t3, { options: { issueSession: () => new Promise((resolve) => { issue = resolve; }) } }); + const { api, revoked } = apiFor(t3, { options: { issueSession: () => new Promise((resolve) => { issue = resolve; }) } }); const pending = api.call("server.getConfig", {}); api.reset(); issue({ token: "good", sessionId: "old", expiresAt: "2099-01-01T00:00:00Z" }); - await assert.rejects(pending, /restarted while/); + await assert.rejects(pending, error => error instanceof T3ApiError && /^T3 Code restarted\b/.test(error.message)); assert.equal(api.sessionId(), null); assert.equal(t3.sockets.length, 0); + assert.deepEqual(revoked, ["old"], "the discarded RPC credential is retired"); api.close(); });