mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
ec8f6e5e03
* feat(browser): add copilot security contracts * fix(gateway): expose verified client identity to handlers * feat(browser): add secure per-tab copilot panel Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * refactor(browser): separate copilot gateway hint custody * fix(browser): preserve legacy pairing parse shape * fix(browser): harden copilot lifecycle custody Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * fix(browser): enforce copilot lifecycle boundaries Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * style(browser): format copilot sources Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * fix(browser): preserve copilot consent revocation Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * refactor(browser): split copilot custody owners Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * test(browser): normalize websocket array buffers Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * chore(protocol): regenerate Swift gateway models Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * refactor(browser): model copilot runtime entrypoints Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * fix(browser): honor extension build boundaries Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * test(gateway): assert targeted chat delivery Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * test(gateway): cover targeted delivery calls Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * fix(browser): declare copilot build dependencies Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * fix(ci): clear browser copilot gate failures Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * test(ci): cover copilot lint exclusion Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * fix(browser): gate copilot on relay custody Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> * test(browser): bound copilot relay frames Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com> --------- Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>
270 lines
7.9 KiB
JavaScript
270 lines
7.9 KiB
JavaScript
import { archiveCopilotSession } from "./copilot-background-shared.js";
|
|
import { waitForCopilotGatewayReady } from "./copilot-gateway.js";
|
|
|
|
/** Gateway cleanup owner. All destructive scope recovery runs through the lifecycle queue. */
|
|
export function createCopilotRecoveryController({
|
|
gateway,
|
|
recoveryGatewayFactory,
|
|
registry,
|
|
subscribedKeys,
|
|
sendsByTab,
|
|
currentGatewayScope,
|
|
getGatewayStatus,
|
|
getGatewayStatusRevision,
|
|
getLastReadyStatus,
|
|
isConfigTransitioning,
|
|
setReconciledGatewayStatus,
|
|
restoreDebuggerIfReleased,
|
|
broadcastTab,
|
|
broadcastStatus,
|
|
refreshPanelState,
|
|
runLifecycle,
|
|
}) {
|
|
let abortRetryTimer = null;
|
|
let abortRetryDelayMs = 250;
|
|
let staleRecovery = null;
|
|
let staleRecoveryRetryTimer = null;
|
|
|
|
async function drainArchives(gatewayScope = currentGatewayScope()) {
|
|
if (!gateway.ready || !gatewayScope) {
|
|
return;
|
|
}
|
|
for (const entry of registry.pendingArchives(gatewayScope)) {
|
|
try {
|
|
await archiveCopilotSession(gateway, entry);
|
|
subscribedKeys.delete(entry.sessionKey);
|
|
await registry.resolveArchive(gatewayScope, entry.sessionKey);
|
|
if (typeof entry.tabId === "number") {
|
|
await restoreDebuggerIfReleased(entry.tabId);
|
|
}
|
|
} catch {
|
|
// The watchdog retries after reconnect or after an active run reaches terminal state.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function abortEntry(entry) {
|
|
try {
|
|
await gateway.request("sessions.abort", {
|
|
key: entry.sessionKey,
|
|
runId: entry.activeRunId,
|
|
});
|
|
} catch {
|
|
scheduleAbortRetry(entry.gatewayScope);
|
|
return false;
|
|
}
|
|
sendsByTab.delete(entry.tabId);
|
|
const finished = await registry.finishRun(
|
|
entry.gatewayScope,
|
|
entry.sessionKey,
|
|
entry.activeRunId,
|
|
);
|
|
if (finished) {
|
|
await restoreDebuggerIfReleased(entry.tabId);
|
|
broadcastTab(entry.tabId, { type: "panel.turn-reset" });
|
|
void refreshPanelState(entry.tabId);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function drainAborts(gatewayScope = currentGatewayScope()) {
|
|
if (!gateway.ready || !gatewayScope) {
|
|
return;
|
|
}
|
|
for (const entry of registry.pendingAborts(gatewayScope)) {
|
|
await abortEntry(entry);
|
|
}
|
|
}
|
|
|
|
function clearAbortRetry() {
|
|
if (abortRetryTimer) {
|
|
clearTimeout(abortRetryTimer);
|
|
abortRetryTimer = null;
|
|
}
|
|
abortRetryDelayMs = 250;
|
|
}
|
|
|
|
function scheduleAbortRetry(gatewayScope = currentGatewayScope()) {
|
|
const statusRevision = getGatewayStatusRevision();
|
|
if (
|
|
abortRetryTimer ||
|
|
!gateway.ready ||
|
|
!gatewayScope ||
|
|
isConfigTransitioning() ||
|
|
currentGatewayScope() !== gatewayScope
|
|
) {
|
|
return;
|
|
}
|
|
const delayMs = abortRetryDelayMs;
|
|
abortRetryTimer = setTimeout(() => {
|
|
abortRetryTimer = null;
|
|
void (async () => {
|
|
if (
|
|
currentGatewayScope() !== gatewayScope ||
|
|
getGatewayStatusRevision() !== statusRevision ||
|
|
!gateway.ready
|
|
) {
|
|
return;
|
|
}
|
|
await drainAborts(gatewayScope);
|
|
if (
|
|
currentGatewayScope() !== gatewayScope ||
|
|
getGatewayStatusRevision() !== statusRevision ||
|
|
!gateway.ready
|
|
) {
|
|
return;
|
|
}
|
|
if (registry.pendingAborts(gatewayScope).length > 0) {
|
|
abortRetryDelayMs = Math.min(abortRetryDelayMs * 2, 5_000);
|
|
scheduleAbortRetry();
|
|
} else {
|
|
abortRetryDelayMs = 250;
|
|
const readyStatus = getLastReadyStatus();
|
|
if (getGatewayStatus().state === "error" && readyStatus) {
|
|
setReconciledGatewayStatus(readyStatus, statusRevision);
|
|
broadcastStatus({ ensureSetup: true, hydrateHistory: true });
|
|
}
|
|
}
|
|
})();
|
|
}, delayMs);
|
|
}
|
|
|
|
async function reconcileGatewayReady(status, statusRevision, gatewayScope, revocation) {
|
|
await revocation;
|
|
if (
|
|
!gatewayScope ||
|
|
statusRevision !== getGatewayStatusRevision() ||
|
|
isConfigTransitioning() ||
|
|
!gateway.ready ||
|
|
currentGatewayScope() !== gatewayScope
|
|
) {
|
|
return;
|
|
}
|
|
// A connection gap loses terminal events. Abort durable active custody
|
|
// before panels can send again.
|
|
await registry.queueActiveAborts(gatewayScope);
|
|
await drainAborts(gatewayScope);
|
|
await drainArchives(gatewayScope);
|
|
if (
|
|
statusRevision !== getGatewayStatusRevision() ||
|
|
isConfigTransitioning() ||
|
|
!gateway.ready ||
|
|
currentGatewayScope() !== gatewayScope
|
|
) {
|
|
return;
|
|
}
|
|
const hasPendingAborts = registry.pendingAborts(gatewayScope).length > 0;
|
|
setReconciledGatewayStatus(
|
|
hasPendingAborts ? { state: "error", label: "Could not stop the previous tab run" } : status,
|
|
hasPendingAborts ? 0 : statusRevision,
|
|
);
|
|
broadcastStatus(hasPendingAborts ? undefined : { ensureSetup: true, hydrateHistory: true });
|
|
}
|
|
|
|
function scheduleStaleRecovery() {
|
|
if (staleRecoveryRetryTimer) {
|
|
return;
|
|
}
|
|
staleRecoveryRetryTimer = setTimeout(() => {
|
|
staleRecoveryRetryTimer = null;
|
|
void drainStaleScopes();
|
|
}, 5_000);
|
|
}
|
|
|
|
function drainStaleScopes() {
|
|
if (staleRecovery) {
|
|
return staleRecovery;
|
|
}
|
|
if (staleRecoveryRetryTimer) {
|
|
clearTimeout(staleRecoveryRetryTimer);
|
|
staleRecoveryRetryTimer = null;
|
|
}
|
|
let retry = false;
|
|
const pending = runLifecycle(async () => {
|
|
const currentScope = currentGatewayScope();
|
|
const staleScopes = registry.gatewayScopes().filter((scope) => scope !== currentScope);
|
|
for (const staleScope of staleScopes) {
|
|
if (await recoverPersistedScope(staleScope)) {
|
|
continue;
|
|
}
|
|
await registry.closeInactiveScope(staleScope);
|
|
retry = true;
|
|
}
|
|
if (gateway.ready && getGatewayStatus().state === "ready") {
|
|
broadcastStatus({ ensureSetup: true, hydrateHistory: true });
|
|
}
|
|
}).catch(() => {
|
|
retry = true;
|
|
});
|
|
staleRecovery = pending;
|
|
void pending.then(() => {
|
|
if (staleRecovery === pending) {
|
|
staleRecovery = null;
|
|
}
|
|
if (retry) {
|
|
scheduleStaleRecovery();
|
|
}
|
|
});
|
|
return pending;
|
|
}
|
|
|
|
async function recoverPersistedScope(gatewayScope) {
|
|
const scopedEntries = registry.list().filter((entry) => entry.gatewayScope === gatewayScope);
|
|
const needsGateway =
|
|
registry.pendingArchives(gatewayScope).length > 0 ||
|
|
scopedEntries.some(
|
|
(entry) => !entry.provisional || entry.creationPending || entry.activeRunId,
|
|
);
|
|
if (!needsGateway) {
|
|
await registry.closeScope(gatewayScope);
|
|
return true;
|
|
}
|
|
const recoveryGateway = recoveryGatewayFactory();
|
|
try {
|
|
await waitForCopilotGatewayReady(recoveryGateway, gatewayScope);
|
|
await registry.queueActiveAborts(gatewayScope);
|
|
for (const entry of registry.pendingAborts(gatewayScope)) {
|
|
await recoveryGateway.request("sessions.abort", {
|
|
key: entry.sessionKey,
|
|
runId: entry.activeRunId,
|
|
});
|
|
const finished = await registry.finishRun(
|
|
entry.gatewayScope,
|
|
entry.sessionKey,
|
|
entry.activeRunId,
|
|
);
|
|
if (finished) {
|
|
await restoreDebuggerIfReleased(entry.tabId);
|
|
}
|
|
}
|
|
await registry.closeScope(gatewayScope);
|
|
for (const entry of registry.pendingArchives(gatewayScope)) {
|
|
await archiveCopilotSession(recoveryGateway, entry);
|
|
await registry.resolveArchive(gatewayScope, entry.sessionKey);
|
|
if (typeof entry.tabId === "number") {
|
|
await restoreDebuggerIfReleased(entry.tabId);
|
|
}
|
|
}
|
|
return (
|
|
registry.pendingAborts(gatewayScope).length === 0 &&
|
|
registry.pendingArchives(gatewayScope).length === 0
|
|
);
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
recoveryGateway.stop();
|
|
}
|
|
}
|
|
|
|
return {
|
|
abortEntry,
|
|
clearAbortRetry,
|
|
drainAborts,
|
|
drainArchives,
|
|
drainStaleScopes,
|
|
reconcileGatewayReady,
|
|
scheduleAbortRetry,
|
|
scheduleStaleRecovery,
|
|
};
|
|
}
|