From 0f84188387ed43b6398e08ae87dcb6f56baa3963 Mon Sep 17 00:00:00 2001 From: Vladislav Ivashchenko Date: Tue, 29 Sep 2026 12:22:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20pairing=20br?= =?UTF-8?q?idge=20=D0=B8=D0=B7=20hook=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/runtime-tests.yml | 2 +- AGENTS.md | 5 + README.md | 15 ++ .../scripts/trelio-runtime-session.mjs | 42 ++++- host-runtime/scripts/trelio-workspace.mjs | 4 + tests/trelio-runtime-session.test.mjs | 143 ++++++++++++++++++ 6 files changed, 208 insertions(+), 3 deletions(-) diff --git a/.github/workflows/runtime-tests.yml b/.github/workflows/runtime-tests.yml index f816b7e..981a5bd 100644 --- a/.github/workflows/runtime-tests.yml +++ b/.github/workflows/runtime-tests.yml @@ -154,7 +154,7 @@ jobs: - name: Run Windows hook launcher regression run: >- node --test - --test-name-pattern="configured platform hook launcher|event session identity|resume and compact|bounded cleanup" + --test-name-pattern="configured platform hook launcher|event session identity|pairing recovery|resume and compact|bounded cleanup" tests/trelio-runtime-session.test.mjs - name: Run Windows hook timeout regressions diff --git a/AGENTS.md b/AGENTS.md index 0f72f4d..d705e7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,11 @@ publication tooling также остаются вне этого публичн `hookInput.session_id`; inherited environment ID допустим только при отсутствии ID события. Все повторы захвата lock ограничены общим deadline, ошибки удаления stale lock не проглатываются и не разрешают recursive cleanup либо смену ACL. +- При отсутствии paired bridge hook возвращает точный pairing code и публичный + approval nextCall; обычное подтверждение клиента сохраняется. После approval + один retry исходного MCP завершает pairing и admission в той же задаче. + Pending SessionStart observation сохраняется до регистрации; stdout hook + содержит только JSON, без CLI status line успешного обмена и private verifier. - После неоднозначной mutation сначала установи live state; blind retry запрещён. - Server-returned paths и commands трактуются буквально. Runtime не сканирует plugin cache и не выбирает похожую установленную версию. diff --git a/README.md b/README.md index 2743288..fc51906 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,21 @@ Runtime и plugin выпускаются независимо. Совмести plugin version и не требует marketplace update. Новый plugin release нужен только при изменении stable shell или их публичного ABI. +### Восстановление подключения из hook + +Первый protected call без paired bridge возвращает +`TRELIO_BRIDGE_PAIRING_REQUIRED` и exact `nextCall` к обычному MCP approval. +Агент сразу продолжает текущую задачу через этот вызов, сохраняя одобрение +клиента, и повторяет исходный MCP ровно один раз после успешного approval. +Hook сам завершает PKCE exchange и регистрацию runtime; он никогда не +одобряет собственную заявку. Пользовательский отказ или запрет подключения +останавливает recovery. Новый чат и специальный промпт не нужны. + +В recovery входят только публичные pairing ID/device name; verifier, токен +и ключи остаются локально. При отсутствии корректной заявки выдаётся typed +login action. Pending SessionStart observation сохраняется до регистрации, +а CLI status line обмена подавляется, чтобы stdout содержал один JSON. + ## Публичный ABI Stable shell передаёт runtime: diff --git a/host-runtime/scripts/trelio-runtime-session.mjs b/host-runtime/scripts/trelio-runtime-session.mjs index 4fc6af3..27222a7 100644 --- a/host-runtime/scripts/trelio-runtime-session.mjs +++ b/host-runtime/scripts/trelio-runtime-session.mjs @@ -478,7 +478,9 @@ const createRuntimeState = async ({ // probe, pairing request or registration, so divergent credentials still // share one bounded deadline with the subsequent session registration. let registrationSignal = null; - const networkOptions = {}; + // Hook stdout is a single JSON protocol response. A completed pairing must + // not prepend the CLI login status line before updatedInput. + const networkOptions = { onStatus: () => undefined }; Object.defineProperty(networkOptions, "signal", { enumerable: true, get: () => { @@ -604,7 +606,11 @@ const runPreToolUse = async (hookInput) => { const registeredState = await readRuntimeState(filePath); if (registeredState) return registeredState; const initialObservation = await readPendingObservation(filePath); - await fs.rm(filePath, { force: true }).catch(() => undefined); + // Keep SessionStart evidence across a pairing/registration failure. The + // next PreToolUse may not include a model, and asking for a new chat would + // discard exactly the observation needed to resume this same request. + // Successful registration atomically replaces the pending record. + if (!initialObservation) await fs.rm(filePath, { force: true }).catch(() => undefined); return createRuntimeState({ hookInput, clientSessionId, @@ -846,6 +852,38 @@ export const formatRuntimeHookFailure = (error) => { && SAFE_ERROR_CODE_PATTERN.test(rawCode) ? rawCode : HOOK_FAILED_CODE; + if (code === "TRELIO_BRIDGE_PAIRING_REQUIRED") { + // Pairing is a recoverable transport prerequisite, not a generic hook + // failure. Project only the approved public request identity: serializing + // error.pairing would expose its local PKCE verifier. Never derive an + // executable action from an arbitrary exception message or tool input. + const pairing = error?.pairing; + const hasApprovalTarget = UUID_PATTERN.test(pairing?.pairingId || "") + && typeof pairing?.deviceName === "string" + && pairing.deviceName.trim().length > 0 + && pairing.deviceName.length <= 120; + const nextCall = hasApprovalTarget + ? { + tool: "approve_agent_workspace_bridge_pairing", + arguments: { pairingId: pairing.pairingId, deviceName: pairing.deviceName }, + } + : { + tool: "continue_trelio_workspace_action", + arguments: { schemaVersion: 1, operation: "login", parameters: {} }, + }; + return `${code}: hook запущен; исходный MCP-вызов остановлен до отправки. ` + + `Для продолжения текущего запроса сразу выполни nextCall=${JSON.stringify(nextCall)}. ` + + "Это штатное восстановление bridge: не заканчивай ответ предложением " + + "пользователю написать специальный промпт или начать новый чат. " + + "Сохрани обычное одобрение MCP-клиента; не подтверждай его за пользователя. " + + "Если login вернёт pairing request, передай его exact pairingId/deviceName " + + "в approve_agent_workspace_bridge_pairing. После успешного approval повтори " + + "ровно один раз исходный остановленный MCP-вызов с прежними аргументами " + + "и clientRequestId, без ручного runtimeSessionProof: hook сам завершит " + + "обмен и регистрацию. При отказе пользователя, явном запрете подключения " + + "или повторном сбое остановись с точной причиной. Не сбрасывай OAuth, " + + "Hooks или session-файлы; не выводи pairing-данные в ответ пользователю.\n"; + } const rawMessage = error instanceof Error ? error.message : String(error); const message = /[.!?]$/u.test(rawMessage.trim()) ? rawMessage.trim() diff --git a/host-runtime/scripts/trelio-workspace.mjs b/host-runtime/scripts/trelio-workspace.mjs index ef49571..9a8b207 100755 --- a/host-runtime/scripts/trelio-workspace.mjs +++ b/host-runtime/scripts/trelio-workspace.mjs @@ -4063,6 +4063,10 @@ export class BridgePairingRequiredError extends Error { `Заявка действует до ${pairing.expiresAt}.`, "Сразу вызовите MCP tool approve_agent_workspace_bridge_pairing с этим pairingId и deviceName, затем повторите исходную bridge-команду. Не показывайте пользователю код и не просите отдельную фразу подтверждения в чате: если MCP-клиент требует подтверждение tool-вызова, он сам покажет одну штатную кнопку.", ].join("\n")); + // Preserve the recovery discriminator through hook/MCP error projection. + // The private verifier remains on the error only for local bookkeeping; + // consumers must project the two public approval arguments explicitly. + this.code = "TRELIO_BRIDGE_PAIRING_REQUIRED"; this.pairing = pairing; } } diff --git a/tests/trelio-runtime-session.test.mjs b/tests/trelio-runtime-session.test.mjs index 7312cf3..29813eb 100644 --- a/tests/trelio-runtime-session.test.mjs +++ b/tests/trelio-runtime-session.test.mjs @@ -1524,3 +1524,146 @@ test("SessionEnd removes the local key before a bounded remote cleanup", async ( await rm(temporaryHome, { recursive: true, force: true }); } }); + +test("pairing recovery resumes the original call with one JSON proof after client approval", async (t) => { + const temporaryHome = await mkdtemp(path.join(os.tmpdir(), "trelio-hook-pairing-")); + const pairingId = crypto.randomUUID(); + const runtimeSessionId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + const deviceName = 'Synthetic "Windows" device'; + const token = "twb_synthetic-hook-pairing"; + let challenge; + let approved = false; + let creates = 0; + let exchanges = 0; + let registrations = 0; + let registeredPublicKey; + let serverError; + const server = createServer(async (request, response) => { + response.setHeader("content-type", "application/json"); + try { + if (request.url === "/api/agent-workspaces/bridge-pairings") { + assert.equal(request.headers.authorization, undefined); + challenge = (await readRequestBody(request)).codeChallenge; + creates += 1; + response.end(JSON.stringify({ pairingId, deviceName, expiresAt: new Date(Date.now() + 120_000).toISOString() })); + } else if (request.url === `/api/agent-workspaces/bridge-pairings/${pairingId}/exchange`) { + assert.equal(request.headers.authorization, undefined); + const { codeVerifier } = await readRequestBody(request); + assert.equal(crypto.createHash("sha256").update(codeVerifier).digest("base64url"), challenge); + exchanges += 1; + response.statusCode = approved ? 200 : 409; + response.end(JSON.stringify(approved + ? { accessToken: token } + : { code: "BRIDGE_PAIRING_PENDING", message: "Awaiting client approval" })); + } else { + assert.equal(request.headers.authorization, `Bearer ${token}`); + if (request.url === "/api/agent-workspaces/bridge-compatibility") { + response.end(JSON.stringify(buildTestBridgeCompatibility(request, TEST_PLUGIN_VERSION))); + } else if (request.url === "/api/agent-workspaces/runtime-policy/sessions") { + assert.equal(approved, true, "registration must never precede approval"); + const body = await readRequestBody(request); + assert.equal(body.clientSessionId, sessionId); + assert.equal(body.observation.modelId, "gpt-6-astra"); + registeredPublicKey = body.publicKeySpki; + registrations += 1; + response.end(JSON.stringify({ schemaVersion: 1, runtimeSessionId, expiresAt: new Date(Date.now() + 60_000).toISOString() })); + } else { + assert.fail(`Unexpected request: ${request.url}`); + } + } + } catch (error) { + serverError = error; + response.statusCode = 500; + response.end(JSON.stringify({ code: "FIXTURE_FAILURE" })); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(temporaryHome, { recursive: true, force: true }); + if (serverError) throw serverError; + }); + const origin = `http://127.0.0.1:${server.address().port}`; + const environment = { + HOME: temporaryHome, USERPROFILE: temporaryHome, + LOCALAPPDATA: path.join(temporaryHome, "AppData", "Local"), + CODEX_HOME: path.join(temporaryHome, ".codex"), CODEX_THREAD_ID: sessionId, + TRELIO_WORKSPACE_ORIGIN: origin, TRELIO_WORKSPACE_DISABLE_KEYCHAIN: "1", + CLAUDE_CODE_ENTRYPOINT: "", CLAUDE_EFFORT: "", + }; + const configDirectory = resolveWorkspaceBridgeConfigDirectory({ environment, homeDirectory: temporaryHome }); + const stateDigest = crypto.createHash("sha256").update(`${origin}\n${sessionId}`).digest("hex"); + const statePath = path.join(configDirectory, "runtime-sessions", `${stateDigest}.json`); + // Deliberately put model evidence only in SessionStart. Pairing must preserve + // that evidence so a retry never requires a new chat or model self-report. + const started = await runHook({ hook_event_name: "SessionStart", session_id: sessionId, model: "gpt-6-astra" }, environment); + assert.deepEqual(started, { exitCode: 0, stdout: "", stderr: "" }); + const pendingBefore = await readFile(statePath, "utf8"); + const input = { + hook_event_name: "PreToolUse", session_id: sessionId, + tool_name: "mcp__trelio__get_agent_instructions", tool_input: { companySlug: "example" }, + }; + const first = await runHook(input, environment); + const reason = assertDeniedHook(first); + assert.match(reason, /^TRELIO_BRIDGE_PAIRING_REQUIRED:/u); + assert.doesNotMatch(reason, /TRELIO_RUNTIME_HOOK_FAILED|Устраните указанную причину/u); + const nextCall = JSON.parse(reason.match(/nextCall=(.+?)\. Это штатное/u)[1]); + assert.deepEqual(nextCall, { tool: "approve_agent_workspace_bridge_pairing", arguments: { pairingId, deviceName } }); + assert.match(reason, /ровно один раз исходный остановленный MCP-вызов/u); + const pending = JSON.parse(await readFile(path.join(configDirectory, "pairings.json"), "utf8")); + const verifier = pending[origin].codeVerifier; + assert.equal(typeof verifier, "string"); + assert.equal(first.stdout.includes(verifier), false); + assert.equal(first.stdout.includes(token), false); + assert.equal(first.stdout.includes("companySlug"), false); + assert.equal(await readFile(statePath, "utf8"), pendingBefore); + + // A premature retry must stay denied and reuse the exact pending request. + assert.equal(assertDeniedHook(await runHook(input, environment)), reason); + assert.equal(creates, 1); + assert.equal(registrations, 0); + assert.equal(await readFile(statePath, "utf8"), pendingBefore); + // The actual approval tool is outside admission. The mock server models its + // authenticated approval result, never an approval performed by the hook. + assert.deepEqual(await runHook({ ...input, tool_name: `mcp__trelio__${nextCall.tool}`, tool_input: nextCall.arguments }, environment), { + exitCode: 0, stdout: "", stderr: "", + }); + approved = true; + const resumed = await runHook(input, environment); + assert.equal(resumed.exitCode, 0, resumed.stderr); + assert.equal(resumed.stderr, ""); + // Parsing the entire stdout catches the former login status line corruption. + const output = JSON.parse(resumed.stdout).hookSpecificOutput; + assert.equal(output.permissionDecision, "allow"); + const { runtimeSessionProof: proof, ...originalInput } = output.updatedInput; + assert.deepEqual(originalInput, input.tool_input); + assert.equal(proof.runtimeSessionId, runtimeSessionId); + const signedBytes = Buffer.from(["trelio-runtime-proof-v1", proof.runtimeSessionId, "get_agent_instructions", proof.issuedAt, proof.nonce].join("\n")); + assert.equal(crypto.verify(null, signedBytes, crypto.createPublicKey({ key: Buffer.from(registeredPublicKey, "base64url"), type: "spki", format: "der" }), Buffer.from(proof.signature, "base64url")), true); + assert.equal(resumed.stdout.includes(verifier), false); + assert.equal(resumed.stdout.includes(token), false); + await assert.rejects(readFile(path.join(configDirectory, "pairings.json")), { code: "ENOENT" }); + assert.equal(creates, 1); + assert.equal(exchanges, 2); + assert.equal(registrations, 1); + const subsequent = JSON.parse((await runHook(input, environment)).stdout).hookSpecificOutput; + assert.equal(subsequent.permissionDecision, "allow"); + assert.notEqual(subsequent.updatedInput.runtimeSessionProof.nonce, proof.nonce); + assert.equal(exchanges, 2); + assert.equal(registrations, 1); +}); + +test("pairing recovery without a valid public target requests only the typed login action", () => { + const error = new Error("untrusted diagnostic text containing a verifier must not be projected"); + error.code = "TRELIO_BRIDGE_PAIRING_REQUIRED"; + error.pairing = { pairingId: "invalid", deviceName: "device", codeVerifier: "private synthetic verifier" }; + const reason = formatRuntimeHookFailure(error); + const nextCall = JSON.parse(reason.match(/nextCall=(.+?)\. Это штатное/u)[1]); + assert.deepEqual(nextCall, { + tool: "continue_trelio_workspace_action", + arguments: { schemaVersion: 1, operation: "login", parameters: {} }, + }); + assert.doesNotMatch(reason, /untrusted diagnostic|private synthetic|"pairingId"/u); +});