diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index 5dc5c3c8..10756ffe 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -38,27 +38,8 @@ const RECEIPT_OBSERVER_CLIENT = join( 'lease-broker', 'receipt-observer-client.py', ); -const LEASE_PROMOTE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'lease_promote.py'); const RECOVERY_TOOL = 'mosaic_context_recover'; -// Lazy lease promotion: a lease is promoted on the FIRST DENIED MUTATOR, not at -// session start. Two reasons that matter: -// -// 1. Promotion costs a whole model turn, because the receipt must be the entire -// message (hmac.compare_digest, "not a transcript substring"). Doing it at -// session start would collide with the Constitution's first-response mode -// declaration — the two cannot share a message. Deferring it means the mode -// declaration happens first and the receipt gets its own later turn, so no -// governance change is required. -// 2. A read-only session never pays for it at all. -// -// Bounded so a model that will not emit the receipt verbatim degrades to the -// current behaviour (denied mutators) rather than looping forever. -const MAX_PROMOTION_ATTEMPTS = 3; -let pendingReceiptChallenge: string | null = null; -let pendingReceiptText: string | null = null; -let promotionAttempts = 0; - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -145,63 +126,6 @@ function runPiLeaseRevoker(args: string[]): boolean { return result.status === 0; } -/** - * Mint a promotion challenge and return the instruction the model must follow. - * - * The receipt has to be the model's ENTIRE next message: the broker compares it - * with `hmac.compare_digest`, so any surrounding prose fails. Returning it as - * the tool_call block `reason` is what puts it in front of the model — pi feeds - * that string back as the tool result. - * - * This never posts the receipt itself. `receipt-observer-client.py` would accept - * any string, so self-posting would satisfy the broker while proving nothing; - * the whole point of the exchange is that a live model echoes the challenge. - */ -function beginPiPromotion(): string | undefined { - if (promotionAttempts >= MAX_PROMOTION_ATTEMPTS) return undefined; - const result = spawnSync('python3', [LEASE_PROMOTE, '--begin'], { - encoding: 'utf8', - timeout: 10_000, - env: process.env, - }); - if (result.status !== 0) return undefined; - try { - const reply = JSON.parse(String(result.stdout)) as { - ok?: boolean; - receipt?: string; - receipt_challenge?: string; - }; - if (reply.ok !== true || !reply.receipt || !reply.receipt_challenge) return undefined; - pendingReceiptChallenge = reply.receipt_challenge; - pendingReceiptText = reply.receipt; - promotionAttempts += 1; - return reply.receipt; - } catch { - return undefined; - } -} - -/** Complete promotion after the model emitted the receipt and message_end - * shipped it to the observer. Returns true when the lease reached VERIFIED. */ -function completePiPromotion(): boolean { - const challenge = pendingReceiptChallenge; - if (!challenge) return false; - pendingReceiptChallenge = null; - pendingReceiptText = null; - const result = spawnSync('python3', [LEASE_PROMOTE, '--complete', challenge], { - encoding: 'utf8', - timeout: 10_000, - env: process.env, - }); - if (result.status !== 0) return false; - try { - const reply = JSON.parse(String(result.stdout)) as { state?: string }; - return reply.state === 'VERIFIED'; - } catch { - return false; - } -} - function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined { const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], { input: `${JSON.stringify({ tool_name: toolName })}\n`, @@ -213,29 +137,6 @@ function checkPiMutatorGate(toolName: string): { block: true; reason: string } | const detail = String(result.stderr ?? '') .trim() .split('\n')[0]; - - // Only an UNVERIFIED lease is promotable. Any other denial (GATE_UNAVAILABLE, - // STALE_GENERATION, LEASE_EXPIRED, ANCESTRY_MISMATCH) means something is wrong - // that a receipt cannot fix — minting there would thrash the broker, since - // begin_verification revokes before it mints. - if (detail.includes('MUTATOR_UNVERIFIED') && pendingReceiptChallenge === null) { - const receipt = beginPiPromotion(); - if (receipt) { - return { - block: true, - reason: - `${detail}\n\n` + - 'This session holds an UNVERIFIED lease, so mutators are denied. To ' + - 'promote it, reply with the following text and NOTHING ELSE — no ' + - 'preamble, no explanation, no code fence, no trailing text. It is ' + - 'compared byte-for-byte, so any extra character fails:\n\n' + - `${receipt}\n\n` + - 'Emit exactly that as your entire next message. The lease will then be ' + - 'VERIFIED and you can retry this tool.', - }; - } - } - return { block: true, reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.', @@ -469,29 +370,7 @@ export default function register(pi: ExtensionAPI) { // Pi records only a finalized assistant entry at message_end. It never uses // after_provider_response, which occurs before stream consumption. pi.on('message_end', async (event) => { - const message = (event as unknown as { message?: unknown }).message; - recordPiMessageEnd(message); - // Complete ONLY when the message just observed IS the receipt. - // - // message_end also fires for the message that CONTAINED the blocked tool - // call — i.e. one turn BEFORE the model emits the receipt. Completing there - // makes observe_receipt compare against the wrong text, fail, and burn the - // challenge before the model ever answers. Gating on an exact text match - // mirrors the broker's own hmac.compare_digest semantics and waits for the - // right turn. - // - // Order still matters within this handler: recordPiMessageEnd must have - // shipped the message to the observer before observe_receipt asks about it. - if (pendingReceiptChallenge !== null && pendingReceiptText !== null) { - if (assistantMessageText(message) === pendingReceiptText) { - if (completePiPromotion()) { - promotionAttempts = 0; - } - // On failure the challenge is cleared, so the next denied mutator mints - // a fresh one — bounded by MAX_PROMOTION_ATTEMPTS, after which the - // session simply stays UNVERIFIED rather than looping. - } - } + recordPiMessageEnd((event as unknown as { message?: unknown }).message); }); // The recovery custom tool is the only Pi invocation that maps to the