Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/runtime-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 и не выбирает похожую установленную версию.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 40 additions & 2 deletions host-runtime/scripts/trelio-runtime-session.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions host-runtime/scripts/trelio-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
143 changes: 143 additions & 0 deletions tests/trelio-runtime-session.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading