diff --git a/server/external-agent/broker-registry.ts b/server/external-agent/broker-registry.ts index 68079ae6..7b3b3bef 100644 --- a/server/external-agent/broker-registry.ts +++ b/server/external-agent/broker-registry.ts @@ -105,14 +105,9 @@ export class EditorConnectionRegistry { if (!trustedInternalCall && registrationCapability && !validRenewal) { throw new ExternalEditorCallError('stale', 'Editor registration capability is stale.'); } - // A different browser window may take over the active connection for the - // same project. Single-window desktop users never open one project in two - // windows, but a reloaded/fresh window (a new random editor id) must be able - // to (re)connect without a persistent "already has an active editor" - // rejection. The old entry is replaced below by this.editors.set, and the - // persisted ownership claim (validated at validateOwnershipClaim) still - // fences stale takeovers. Offline/multi-writer safety comes from the - // serialized project-store mutations and the ownership epoch checks. + // The project-store ownership claim fences competing tabs/windows before + // this registry is reached. A refresh reuses the tab-scoped editor id; + // registrations with a different identity cannot obtain a live claim. validateOwnershipClaim(ownership, { projectId, editorInstanceId, baseRevision }); const ownershipEpoch = ownership?.epoch; if (previous && bindingChanged(previous, editorInstanceId, baseRevision, ownershipEpoch)) { diff --git a/server/external-agent/broker.ts b/server/external-agent/broker.ts index 630c7ae9..6e19e639 100644 --- a/server/external-agent/broker.ts +++ b/server/external-agent/broker.ts @@ -48,7 +48,7 @@ export interface ExternalEditorCancellation { id: string; outcome: Exclude; message: string; - /** Edit sessions orphaned by the owner transport disconnect; the editor discards them. */ + /** Legacy field accepted by older editors. New brokers preserve orphaned drafts for recovery. */ ownerGone?: string[]; } @@ -62,6 +62,7 @@ const queues = new Map(); const pending = new Map(); const waiters = new Map void>>(); const editSessionOwners = new Map(); +const orphanedEditSessions = new Map(); const cancellationQueues = new Map(); const cancellationWaiters = new Map void>>(); @@ -133,18 +134,47 @@ function terminalMessage(value: unknown): string { function recordEditSessionOwner(call: QueuedCall, value: unknown): void { if ( - call.name !== 'begin_edit_session' + call.name !== 'begin_edit_session' && call.name !== 'recover_edit_session' || !value || typeof value !== 'object' || Array.isArray(value) ) return; if (!('editSessionId' in value)) return; + if (call.name === 'recover_edit_session' && call.arguments.action !== 'resume') return; const editSessionId = value.editSessionId; if (typeof editSessionId !== 'string' || !editSessionId.trim()) return; editSessionOwners.set(editSessionId.trim(), { ownerId: call.ownerId, binding: { ...call.binding }, }); + orphanedEditSessions.delete(editSessionId.trim()); +} + +function sessionRecoveryResult(ownerId: string, binding: EditorBinding, value: unknown): unknown { + if (!Array.isArray(value)) return value; + return value.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const record = entry as Record; + const id = typeof record.editSessionId === 'string' ? record.editSessionId : ''; + const owner = editSessionOwners.get(id); + // The editor persists draft sessions, while transport ownership is held in + // this broker process. After an MCP/server crash or restart the durable + // draft can therefore come back without an in-memory owner entry. Treat an + // ownerless active draft as orphaned so a new authenticated transport can + // explicitly resume or discard it instead of leaving the project locked. + const activeDraft = record.status === 'drafting' || record.status === 'awaiting_review'; + const orphaned = activeDraft && (orphanedEditSessions.has(id) || !owner); + if (orphaned && !orphanedEditSessions.has(id)) { + orphanedEditSessions.set(id, { ...binding }); + } + return { + ...record, + ownerOnline: Boolean(owner), + orphaned, + recoveryActions: orphaned ? (record.stale === true ? ['discard'] : ['resume', 'discard']) : [], + ownedByCurrentTransport: owner?.ownerId === ownerId && sameBinding(owner.binding, binding), + }; + }); } function finishCall( @@ -159,7 +189,15 @@ function finishCall( wake(waiters, call.binding.projectId); if (outcome === 'applied') { recordEditSessionOwner(call, value); - call.resolve(value); + if (call.name === 'recover_edit_session' && call.arguments.action === 'discard') { + const sessionId = typeof call.arguments.editSessionId === 'string' + ? call.arguments.editSessionId.trim() + : ''; + if (sessionId) orphanedEditSessions.delete(sessionId); + } + call.resolve(call.name === 'list_edit_sessions' + ? sessionRecoveryResult(call.ownerId, call.binding, value) + : value); return true; } const message = terminalMessage(value); @@ -312,6 +350,19 @@ function requireOwnedEditSession( args: Record, ): void { if (editSessionOwnerMatches(ownerId, binding, args.editSessionId)) return; + const editSessionId = typeof args.editSessionId === 'string' ? args.editSessionId.trim() : ''; + const owner = editSessionOwners.get(editSessionId); + // A page refresh may renew the browser ownership lease and therefore change + // only the epoch. The tab-scoped editor identity and unchanged project + // revision prove this is the same editor, while a competing tab has a + // different identity and still fails closed. + if (owner + && owner.ownerId === ownerId + && sameEditorIdentity(owner.binding, binding) + && owner.binding.baseRevision === binding.baseRevision) { + owner.binding = { ...binding }; + return; + } throw new ExternalEditorCallError( 'rejected', 'The requested edit session does not belong to this MCP transport and editor binding.', @@ -355,7 +406,14 @@ export function invokeEditorTool( timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { const allowRevisionDrift = name === 'get_edit_session'; - const ownsSession = name !== 'begin_edit_session' && 'editSessionId' in args; + const recoveryTool = name === 'list_edit_sessions' || name === 'recover_edit_session'; + const ownsSession = name !== 'begin_edit_session' && !recoveryTool && 'editSessionId' in args; + if (name === 'recover_edit_session') { + const sessionId = typeof args.editSessionId === 'string' ? args.editSessionId.trim() : ''; + if (!sessionId || !orphanedEditSessions.has(sessionId)) { + throw new ExternalEditorCallError('rejected', 'Only an orphaned edit session can be recovered.'); + } + } if (ownsSession) { requireOwnedEditSession(ownerId, binding, args); } @@ -519,27 +577,10 @@ export function cancelEditorCallsForOwner( outcome: Extract = 'cancelled', message = 'MCP transport session closed before the editor call completed.', ): number { - const orphanedByEditor = new Map(); for (const [sessionId, owner] of editSessionOwners) { if (owner.ownerId !== ownerId) continue; editSessionOwners.delete(sessionId); - const key = editorKey(owner.binding.projectId, owner.binding.editorInstanceId); - const list = orphanedByEditor.get(key) ?? []; - list.push(sessionId); - orphanedByEditor.set(key, list); - } - // Let each connected editor discard the sessions its transport orphaned, so a - // crashed or closed MCP client cannot leave a drafting session wedged forever. - for (const [key, sessionIds] of orphanedByEditor) { - const queue = cancellationQueues.get(key) ?? []; - queue.push({ - id: '', - outcome, - message: `MCP transport session closed; ${sessionIds.length} edit session(s) orphaned.`, - ownerGone: sessionIds, - }); - cancellationQueues.set(key, queue); - wake(cancellationWaiters, key); + orphanedEditSessions.set(sessionId, { ...owner.binding }); } return cancelCalls((call) => call.ownerId === ownerId, outcome, message); } @@ -562,4 +603,5 @@ export function resetExternalAgentBrokerForTest(): void { cancellationQueues.clear(); cancellationWaiters.clear(); editSessionOwners.clear(); + orphanedEditSessions.clear(); } diff --git a/server/external-agent/broker.verify.ts b/server/external-agent/broker.verify.ts index 60b17808..03a9e185 100644 --- a/server/external-agent/broker.verify.ts +++ b/server/external-agent/broker.verify.ts @@ -111,6 +111,39 @@ adoptedPromise.catch(() => undefined); assert.equal(pendingEditorCallsForTest().length, 1, 'adopted call is queued'); const adopted = pendingEditorCallsForTest()[0]; assert.notEqual(adopted, undefined); +assert.equal(cancelEditorCallsForOwner('owner-old-binding'), 1); +await assert.rejects(adoptedPromise, hasOutcome('cancelled')); + +const currentBinding = editorBinding(projectId); +assert(currentBinding); +const ownerlessListPromise = invokeEditorTool( + 'owner-after-broker-restart', + currentBinding, + 'list_edit_sessions', + {}, +); +const ownerlessListCall = await nextEditorCall( + projectId, + editorId, + currentBinding.baseRevision, + new AbortController().signal, + registrationCapability, +); +assert(ownerlessListCall); +assert.equal(settleEditorCall(ownerlessListCall.id, 'applied', [{ + editSessionId: 'persisted-ownerless-draft', + status: 'drafting', + stale: false, +}], registrationCapability), true); +assert.deepEqual(await ownerlessListPromise, [{ + editSessionId: 'persisted-ownerless-draft', + status: 'drafting', + stale: false, + ownerOnline: false, + orphaned: true, + recoveryActions: ['resume', 'discard'], + ownedByCurrentTransport: false, +}], 'a persisted draft without an in-memory transport owner is recoverable after broker restart'); await import('./mcp.verify.ts'); await import('./broker-poll-refresh.verify.ts'); diff --git a/server/external-agent/mcp-binding.ts b/server/external-agent/mcp-binding.ts index da129bec..59938e8d 100644 --- a/server/external-agent/mcp-binding.ts +++ b/server/external-agent/mcp-binding.ts @@ -59,6 +59,7 @@ export function validateBrowserBinding( session: McpBindingSession, allowRevisionDrift = false, adoptSameIdentity = true, + requireSameRevisionForAdopt = false, ): EditorBinding | null { if (session.staleReason) throw new ExternalEditorCallError('stale', session.staleReason); if (!session.binding) return null; @@ -75,6 +76,7 @@ export function validateBrowserBinding( if (adoptSameIdentity && current && sameEditorIdentity(current, session.binding) + && (!requireSameRevisionForAdopt || current.baseRevision === session.binding.baseRevision) && editorBindingMatches(current)) { session.binding = current; return current; diff --git a/server/external-agent/mcp-check.verify.ts b/server/external-agent/mcp-check.verify.ts index 04172a95..3a944b5e 100644 --- a/server/external-agent/mcp-check.verify.ts +++ b/server/external-agent/mcp-check.verify.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { toMcpContent, toStructuredContent } from './mcp.ts'; +import { projectMcpReply } from './mcp-result.ts'; const object = { ok: true }; assert.equal(toStructuredContent(object), object); @@ -30,5 +31,7 @@ assert.deepEqual(toMcpContent(imageResult), [ mimeType: 'image/jpeg', }, ]); +const projectedImage = projectMcpReply(imageResult) as typeof imageResult; +assert.equal(projectedImage.__images[0]!.base64, 'jpeg-data', 'MCP projection preserves image bytes'); console.log('external-agent MCP structured content check passed'); diff --git a/server/external-agent/mcp-result.ts b/server/external-agent/mcp-result.ts index 26490fbf..0ab11f0a 100644 --- a/server/external-agent/mcp-result.ts +++ b/server/external-agent/mcp-result.ts @@ -26,7 +26,12 @@ function embeddedImages(result: unknown): EmbeddedImage[] { } export function projectMcpReply(value: unknown): unknown { - const sanitized = sanitizeJsonForArtifact(value); + const images = embeddedImages(value); + const source = images.length && value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value as Record) + .filter(([key]) => key !== '__images')) + : value; + const sanitized = sanitizeJsonForArtifact(source); if (!sanitized) { throw new ExternalEditorCallError( 'failed', @@ -39,7 +44,10 @@ export function projectMcpReply(value: unknown): unknown { 'The external result was too large and no recoverable artifact reference was available.', ); } - return JSON.parse(sanitized.body); + const projected = JSON.parse(sanitized.body) as unknown; + return images.length && projected && typeof projected === 'object' && !Array.isArray(projected) + ? { ...projected as Record, __images: images } + : projected; } export function mcpToolError(error: unknown): { diff --git a/server/external-agent/mcp.ts b/server/external-agent/mcp.ts index 846c6f26..f4898607 100644 --- a/server/external-agent/mcp.ts +++ b/server/external-agent/mcp.ts @@ -204,7 +204,8 @@ async function callTool( validateBrowserBinding( session, allowRevisionDrift, - MCP_CONTROL_TOOL_NAMES[name] !== true && !carriesSession, + MCP_CONTROL_TOOL_NAMES[name] !== true, + carriesSession, ); const control = await callControlTool(session, name, args, baseUrl); if (control !== undefined) return control; @@ -259,7 +260,7 @@ function makeServer(baseUrl: string, session: McpSession): Server { 'Bind this MCP transport with target_project before editing. A connected browser is preferred; an existing stored project can use the offline fallback when no browser owns it.', 'The target response and openchatcut_status report bindingMode. Offline bindings expose only server-direct data tools and require approvalMode="auto".', session.exposure.mode === 'progressive' - ? 'This client negotiated progressive tool exposure. Call ToolSearch or load_skill to reveal task tools; tools/list_changed is sent when the visible set grows.' + ? 'This client negotiated progressive tool exposure. Call ToolSearch for list_edit_sessions and recover_edit_session before session recovery; tools/list_changed is sent when the visible set grows.' : 'This client uses the compatibility tool surface. All currently available tools are listed.', 'Call begin_edit_session first, pass editSessionId to every editor tool, then call review_edit_session. Do not claim success until status is applied.', 'Manual approval and visual/canvas inspection, generation, upload, network, preset, render, and export tools require opening the returned editorUrl.', diff --git a/server/external-agent/offline-project-store.verify.ts b/server/external-agent/offline-project-store.verify.ts index 125834d4..d3ff6d47 100644 --- a/server/external-agent/offline-project-store.verify.ts +++ b/server/external-agent/offline-project-store.verify.ts @@ -251,6 +251,13 @@ try { serializedCommit.revision!, ); assert.equal(browserTakeover.status, 'claimed'); + const competingBrowser = await claimBrowserProjectOwnership( + projectId, + 'browser-competing-owner', + serializedCommit.revision!, + ); + assert.equal(competingBrowser.status, 'blocked', + 'a second browser editor cannot silently replace a live browser lease'); // A browser window re-claiming its OWN project (same ownerId, same revision) // recovers (claimed) even without a capability, because the route-level // capability check (broker capabilityMatches) already rejects forged/mismatched diff --git a/server/external-agent/project-edit-ownership.ts b/server/external-agent/project-edit-ownership.ts index 7f9a5d0d..9345e10c 100644 --- a/server/external-agent/project-edit-ownership.ts +++ b/server/external-agent/project-edit-ownership.ts @@ -84,17 +84,10 @@ export async function claimBrowserProjectOwnership( // Genuine anti-spoof is enforced at the registration-route capability layer // (broker capabilityMatches / the route's renewing check), so this claim // gate only needs to fence cross-layer writes, not same-owner recovery. - // A genuinely DIFFERENT browser window that holds the expected revision also - // takes over from a previously registered browser window. Single-window - // desktop users never open the same project in two windows, so there is no - // cross-browser exclusivity to enforce. We still refuse to steal from a live - // OFFLINE writer (external MCP / a serialized offline commit) or an - // epoch-pinned owner, so the browser cannot clobber a non-browser write in - // flight. Lost-update protection additionally holds via the CAS revision - // match in createProjectDocumentStoreOperation. - if (current && current.leaseExpiresAt > Date.now() && current.ownerKind !== 'browser') { - return { status: 'blocked' }; - } + // A different live browser/editor must never be silently replaced. Page + // refresh uses the same tab-scoped editor id and is covered by sameOwner; + // a second tab/window has a different id and must wait for lease release. + if (current && current.leaseExpiresAt > Date.now() && !sameOwner) return { status: 'blocked' }; if (current && current.epoch === Number.MAX_SAFE_INTEGER) return { status: 'blocked' }; const claim: ProjectEditOwnershipClaim = { projectId, diff --git a/src/agent/external-bridge-runtime.ts b/src/agent/external-bridge-runtime.ts index e064374d..42b06776 100644 --- a/src/agent/external-bridge-runtime.ts +++ b/src/agent/external-bridge-runtime.ts @@ -28,7 +28,7 @@ import { executeExternalGlobalReadTool } from './external-global-read'; export interface ExternalProposalSnapshot { proposal: Proposal | null; stale: boolean } /** Confirmation request for a real-project tool (generation/export/import/…) * issued from an external session; the user decides in the OpenChatCut UI. */ -export interface ExternalGuardRequest { id: string; sessionId: string; tool: string; summary: string; details: readonly ApprovalDetail[]; argsDigest: string; operationId?: string } +export interface ExternalGuardRequest { kind: 'real_tool_confirmation'; id: string; sessionId: string; tool: string; summary: string; details: readonly ApprovalDetail[]; argsDigest: string; operationId?: string } export interface ExternalBridgeBinding { projectId: string; editorInstanceId: string; baseRevision: string } const INDEX_UPDATE_WARNING = 'The edit was applied, but the project list timestamp could not be updated.'; const DEFAULT_PERSISTENCE: ExternalBridgePersistence = { saveProject, saveAutomaticVersion, saveExternalProposal }; @@ -102,6 +102,32 @@ export class ExternalBridgeRuntime { name, validateExternalInvocation(name, rawArgs), ); + if (name === 'list_edit_sessions') { + if (binding.projectId !== this.projectId) { + throw new ExternalEditSessionOutcomeError('stale', 'The editor call belongs to a different project.'); + } + return [...this.sessions.values()].map((session) => this.info(session)); + } + if (name === 'recover_edit_session') { + if (binding.projectId !== this.projectId) { + throw new ExternalEditSessionOutcomeError('stale', 'The editor call belongs to a different project.'); + } + const sessionId = externalSessionId(rawArgs); + const session = this.requireSession(sessionId); + if (!EXTERNAL_ACTIVE_STATUSES.has(session.status)) return this.info(session); + if (invocationArgs.action === 'discard') return this.discard(session); + if (invocationArgs.action !== 'resume') { + throw new ExternalEditSessionOutcomeError('rejected', 'action must be "resume" or "discard".'); + } + if (session.baseRevision !== revisionOf(this.getContext().getDoc())) { + await this.markTerminal(session, 'stale'); + throw new ExternalEditSessionOutcomeError( + 'stale', + `Edit session ${session.id} cannot be resumed because the project revision changed.`, + ); + } + return this.info(session); + } if (name === 'begin_edit_session') { await this.validateBinding(binding); throwIfExternalCallCancelled(signal); @@ -200,7 +226,7 @@ export class ExternalBridgeRuntime { details: presentation.details, }, (entry) => run.approvalRequested(entry)); this.onGuardRequest?.({ - id: guard.guardId, sessionId: session.id, tool, + kind: 'real_tool_confirmation', id: guard.guardId, sessionId: session.id, tool, summary: guard.summary, details: guard.details, argsDigest: guard.argsDigest, operationId: guard.operationId ? redactTextForAgentRuntime(guard.operationId) : undefined, @@ -209,6 +235,7 @@ export class ExternalBridgeRuntime { needs_confirmation: true, confirmationId: guard.guardId, tool, + status: 'pending', note: '这个操作会作用于真实工程。请在 OpenChatCut 中确认后重试同一次调用。', }; } @@ -224,7 +251,7 @@ export class ExternalBridgeRuntime { pendingGuard(): ExternalGuardRequest | null { const pending = this.approvalGate.pending(); return pending ? { - id: pending.guardId, sessionId: pending.sessionId, tool: pending.tool, + kind: 'real_tool_confirmation', id: pending.guardId, sessionId: pending.sessionId, tool: pending.tool, summary: pending.summary, details: pending.details, argsDigest: pending.argsDigest, operationId: pending.operationId ? redactTextForAgentRuntime(pending.operationId) : undefined, @@ -301,10 +328,14 @@ export class ExternalBridgeRuntime { private async begin(clientName: unknown, approvalMode: unknown): Promise { const active = findActiveExternalSession(this.sessions); if (active) { - throw new ExternalEditSessionOutcomeError( - 'rejected', - 'An edit session is already active. Resolve it before starting another.', - ); + const activeInfo = this.info(active); + const { editSessionId: _privateSessionId, ...safeActiveInfo } = activeInfo; + return { + conflict: true, + message: 'An edit session is already active. List sessions to inspect ownership and recovery actions.', + activeSession: safeActiveInfo, + nextAction: 'Call list_edit_sessions. Resume/discard is available only after the original owner disconnects.', + }; } const session = createExternalEditSession(this.getContext().getDoc(), clientName, approvalMode); const run = await ExternalSessionRunLedger.start(this.projectId, session.clientName, session.id, 'external-connected', executeTool); diff --git a/src/agent/external-draft-persistence.verify.ts b/src/agent/external-draft-persistence.verify.ts index 3adaeea2..43100d23 100644 --- a/src/agent/external-draft-persistence.verify.ts +++ b/src/agent/external-draft-persistence.verify.ts @@ -120,6 +120,20 @@ const reloaded = new ExternalBridgeRuntime( ); await reloaded.hydrate(persistedDraft); const reloadBinding = binding('editor-after-reload'); +const recoveredList = await reloaded.execute('list_edit_sessions', {}, reloadBinding); +assert(Array.isArray(recoveredList) && recoveredList.some((entry) => ( + entry && typeof entry === 'object' && 'editSessionId' in entry + && entry.editSessionId === editSessionId +)), 'a refreshed editor can discover its persisted draft'); +const recovered = await reloaded.execute('recover_edit_session', { + editSessionId, + action: 'resume', +}, reloadBinding); +assert.equal( + recovered && typeof recovered === 'object' && 'status' in recovered ? recovered.status : undefined, + 'drafting', + 'matching revision resumes the persisted draft', +); const freshGuarded = await reloaded.execute('read_export_history', guardedArgs, reloadBinding); assert.equal(needsConfirmation(freshGuarded), true, 'reload requires a fresh external approval'); const freshGuard = reloaded.pendingGuard(); diff --git a/src/agent/external-edit-session-cancellation.verify.ts b/src/agent/external-edit-session-cancellation.verify.ts index 4a54beab..dd0faae3 100644 --- a/src/agent/external-edit-session-cancellation.verify.ts +++ b/src/agent/external-edit-session-cancellation.verify.ts @@ -12,10 +12,35 @@ import { } from './external-edit-session'; import { executeExternalCall, + editorInstanceIdForProject, hydrateExternalBridge, + projectExternalReply, type ExternalResultSender, } from './useExternalAgentBridge'; import { base } from './external-edit-session-core.verify'; +const sessionValues = new Map(); +const sessionStorageStub = { + getItem: (key: string) => sessionValues.get(key) ?? null, + setItem: (key: string, value: string) => { sessionValues.set(key, value); }, +}; +const refreshIdentity = editorInstanceIdForProject('refresh-project', sessionStorageStub); +assert.equal( + editorInstanceIdForProject('refresh-project', sessionStorageStub), + refreshIdentity, + 'a page refresh reuses the tab-scoped editor identity', +); +assert.notEqual( + editorInstanceIdForProject('other-project', sessionStorageStub), + refreshIdentity, + 'different project bindings do not share editor identity', +); +const projectedImageReply = projectExternalReply({ + note: 'frame', + __images: [{ frame: 1, base64: 'a'.repeat(40_000), mimeType: 'image/jpeg' }], +}) as { note?: string; __images?: Array<{ base64?: string }> }; +assert.equal(projectedImageReply.note, 'frame'); +assert.equal(projectedImageReply.__images?.[0]?.base64?.length, 40_000, + 'browser-to-server projection preserves image bytes outside the text size guard'); const cancellationBeforeRegister = new ExternalCallCancellationRegistry(); cancellationBeforeRegister.cancel('late-call', 'transport closed'); assert.equal(cancellationBeforeRegister.tombstoneCount, 1); diff --git a/src/agent/external-edit-session-projection.verify.ts b/src/agent/external-edit-session-projection.verify.ts index eb8a3236..3ab6307e 100644 --- a/src/agent/external-edit-session-projection.verify.ts +++ b/src/agent/external-edit-session-projection.verify.ts @@ -48,6 +48,17 @@ assert.deepEqual( { password: '[REDACTED]', ok: true }, 'small connected results use the same redacted model projection', ); +const imageProjectionInvocation = await projectionLedger.requested('view_timeline_frames', {}); +await projectionLedger.started(imageProjectionInvocation); +const imageProjection = await projectionLedger.captureToolOutcome( + imageProjectionInvocation, + { kind: 'success' }, + { note: 'contact-sheet-metadata-'.repeat(1_000), __images: [{ frame: 12, base64: 'aGVsbG8=', mimeType: 'image/jpeg' }] }, +) as { __images?: Array<{ base64?: string }>; artifactId?: string }; +assert.equal(imageProjection.__images?.[0]?.base64, 'aGVsbG8=', + 'connected external replies preserve image bytes outside artifact serialization'); +assert.equal(typeof imageProjection.artifactId, 'string', + 'connected visual metadata remains durably archived'); assert.match(connectedArtifact.body, /recoverable-connected-result/); assert.doesNotMatch(connectedArtifact.body, /must-not-cross-the-external-boundary/); assert.match(connectedArtifact.body, /\[REDACTED\]/); diff --git a/src/agent/external-edit-session-runtime.verify.ts b/src/agent/external-edit-session-runtime.verify.ts index ab2380e7..9f9a7e81 100644 --- a/src/agent/external-edit-session-runtime.verify.ts +++ b/src/agent/external-edit-session-runtime.verify.ts @@ -61,14 +61,16 @@ assert( ); const begun = await runtime.execute('begin_edit_session', {}, runtimeBinding); assert(begun && typeof begun === 'object' && 'editSessionId' in begun); -await assert.rejects( - runtime.execute('begin_edit_session', {}, runtimeBinding), - (error: unknown) => error instanceof ExternalEditSessionOutcomeError - && error.outcome === 'rejected' - && error.message === 'An edit session is already active. Resolve it before starting another.' - && !error.message.includes(String(begun.editSessionId)), - 'active-session conflicts do not disclose the live edit session UUID', -); +const conflict = await runtime.execute('begin_edit_session', {}, runtimeBinding); +assert(conflict && typeof conflict === 'object' && 'conflict' in conflict && conflict.conflict === true); +assert('activeSession' in conflict, 'active-session conflicts return structured recovery metadata'); +assert.equal(JSON.stringify(conflict).includes(String(begun.editSessionId)), false, + 'active-session conflicts do not disclose the live edit session UUID'); +const listed = await runtime.execute('list_edit_sessions', {}, runtimeBinding); +assert(Array.isArray(listed) && listed.some((entry) => ( + entry && typeof entry === 'object' && 'editSessionId' in entry + && entry.editSessionId === begun.editSessionId +)), 'project sessions are discoverable before recovery'); await assert.rejects( runtime.execute('set_aspect_ratio', { editSessionId: begun.editSessionId, @@ -96,6 +98,11 @@ const guarded = await runtime.execute( runtimeBinding, ); assert(needsConfirmation(guarded), 'real-project tools ask for exact one-shot confirmation'); +assert.equal( + guarded && typeof guarded === 'object' && 'status' in guarded ? guarded.status : undefined, + 'pending', + 'real-project confirmation replies expose an explicit pending status', +); const guard = runtime.pendingGuard(); assert(guard && guard.tool === 'read_export_history', 'pending guard surfaces the requested tool'); let durableRun = (await loadAgentRuntimeSidecar('runtime-project')).runs diff --git a/src/agent/external-run-ledger.ts b/src/agent/external-run-ledger.ts index 432d280b..cc34d5c5 100644 --- a/src/agent/external-run-ledger.ts +++ b/src/agent/external-run-ledger.ts @@ -227,7 +227,17 @@ export class ExternalSessionRunLedger { if (result !== undefined && invocation.toolName === 'load_skill') { return this.captureExactSkillResult(invocation, outcome, result); } - const sanitized = result === undefined ? null : sanitizeJsonForArtifact(result); + const imagePayloads = result + && typeof result === 'object' + && !Array.isArray(result) + && Array.isArray((result as Record).__images) + ? (result as Record).__images as unknown[] + : []; + const archiveResult = imagePayloads.length + ? Object.fromEntries(Object.entries(result as Record) + .filter(([key]) => key !== '__images')) + : result; + const sanitized = result === undefined ? null : sanitizeJsonForArtifact(archiveResult); if (result !== undefined && !sanitized) { await this.recordProjectionFailure(invocation); throw new ExternalEditSessionOutcomeError( @@ -242,7 +252,7 @@ export class ExternalSessionRunLedger { : await this.recorder.archiveToolResult({ toolCallId: invocation.toolCallId, toolName: invocation.toolName, - result, + result: archiveResult, forceArchive: true, }); await this.recorder.recordToolOutcome({ @@ -259,6 +269,14 @@ export class ExternalSessionRunLedger { ); } if (result === undefined) return undefined; + if (imagePayloads.length) { + const metadata = artifact + ? artifactPlaceholder(artifact) + : compactToolResultForModel(JSON.parse(sanitized!.body)); + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? { ...metadata as Record, __images: imagePayloads } + : { result: metadata, __images: imagePayloads }; + } if (artifact) return artifactPlaceholder(artifact); return compactToolResultForModel(JSON.parse(sanitized!.body)); } @@ -414,4 +432,3 @@ export class ExternalSessionRunLedger { await this.recorder.finalize(status, summary); } } - diff --git a/src/agent/external-tool-shape.ts b/src/agent/external-tool-shape.ts index 239583c9..63a4e827 100644 --- a/src/agent/external-tool-shape.ts +++ b/src/agent/external-tool-shape.ts @@ -22,6 +22,25 @@ const SESSION_ID_PROPERTY = { }; export const EXTERNAL_SESSION_TOOLS: readonly ExternalRegisteredTool[] = [ + { + name: 'list_edit_sessions', + description: 'List edit sessions for the bound project, including recovery metadata for drafts whose MCP owner disconnected.', + input_schema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + { + name: 'recover_edit_session', + description: 'Resume or discard an orphaned edit session. Resume is allowed only when its checkpoint still matches the live project revision.', + input_schema: { + type: 'object', + properties: { + editSessionId: SESSION_ID_PROPERTY, + action: { type: 'string', enum: ['resume', 'discard'] }, + }, + required: ['editSessionId', 'action'], + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, { name: 'begin_edit_session', description: 'Start an isolated OpenChatCut edit draft. Manual mode waits for proposal review; auto mode applies only the staged proposal at review_edit_session. Real-project tools always require separate confirmation.', diff --git a/src/agent/useExternalAgentBridge.ts b/src/agent/useExternalAgentBridge.ts index e328cac6..5ac06dd7 100644 --- a/src/agent/useExternalAgentBridge.ts +++ b/src/agent/useExternalAgentBridge.ts @@ -77,8 +77,18 @@ function retryDelay(): Promise { } const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error); -function projectExternalReply(value: unknown): unknown { - const sanitized = sanitizeJsonForArtifact(value); +export function projectExternalReply(value: unknown): unknown { + const imagePayloads = value + && typeof value === 'object' + && !Array.isArray(value) + && Array.isArray((value as Record).__images) + ? (value as Record).__images as unknown[] + : []; + const source = imagePayloads.length + ? Object.fromEntries(Object.entries(value as Record) + .filter(([key]) => key !== '__images')) + : value; + const sanitized = sanitizeJsonForArtifact(source); if (!sanitized) { throw new ExternalEditSessionOutcomeError( 'failed', @@ -91,7 +101,10 @@ function projectExternalReply(value: unknown): unknown { 'The external result was too large and no recoverable artifact reference was available.', ); } - return JSON.parse(sanitized.body); + const projected = JSON.parse(sanitized.body) as unknown; + return imagePayloads.length && projected && typeof projected === 'object' && !Array.isArray(projected) + ? { ...projected as Record, __images: imagePayloads } + : projected; } function failedOutcome( @@ -324,6 +337,26 @@ interface ExternalRuntimeController { readiness: ExternalBridgeReadinessToken | null; } +const EDITOR_INSTANCE_STORAGE_PREFIX = 'openchatcut.external-editor-instance.'; + +export function editorInstanceIdForProject( + projectId: string, + storage: Pick | null = typeof sessionStorage === 'undefined' + ? null + : sessionStorage, +): string { + const key = `${EDITOR_INSTANCE_STORAGE_PREFIX}${projectId}`; + try { + const existing = storage?.getItem(key); + if (existing && /^[0-9a-f-]{36}$/i.test(existing)) return existing; + const created = crypto.randomUUID(); + storage?.setItem(key, created); + return created; + } catch { + return crypto.randomUUID(); + } +} + function installExternalRuntime( projectId: string, ctxRef: ContextRef, @@ -333,7 +366,10 @@ function installExternalRuntime( setReadiness: StateSetter, ): () => void { let alive = true; - const editorInstanceId = crypto.randomUUID(); + // sessionStorage is isolated per browser tab and survives reloads. Reusing + // this identity lets a refresh renew the same browser ownership lease while + // a second tab/window still receives an independent id and cannot adopt it. + const editorInstanceId = editorInstanceIdForProject(projectId); const runtimeIdentity = {}; const isCurrent = () => alive && runtimeRef.current?.runtimeIdentity === runtimeIdentity; const runtime = new ExternalBridgeRuntime( diff --git a/src/components/chat/ExternalProposalCard.tsx b/src/components/chat/ExternalProposalCard.tsx index d22d2ebd..e17bcfbd 100644 --- a/src/components/chat/ExternalProposalCard.tsx +++ b/src/components/chat/ExternalProposalCard.tsx @@ -89,7 +89,8 @@ function PendingGuardDialog({ guard, confirmGuard }: { >
- {t('外部 Agent 请求执行真实工程操作')} + {t('真实工具确认')} + {guard.id}
@@ -106,7 +107,7 @@ function ExternalProposal({ external, onPreviewState }: { if (!proposal) return null; return (