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>
132 lines
4.5 KiB
JavaScript
132 lines
4.5 KiB
JavaScript
import { ed25519Utils, getPublicKeyAsync, signAsync } from "./copilot-runtime.js";
|
|
|
|
const IDENTITIES_KEY = "copilotDeviceIdentitiesV1";
|
|
const TOKENS_KEY = "copilotDeviceTokensV1";
|
|
// Main and stale-scope clients share one Chrome storage map. Serialize the
|
|
// full read-modify-write or a late client can erase another scope's credential.
|
|
const credentialStorageTails = new WeakMap();
|
|
|
|
function withCredentialStorage(storage, operation) {
|
|
const previous = credentialStorageTails.get(storage) ?? Promise.resolve();
|
|
const result = previous.catch(() => undefined).then(operation);
|
|
const tail = result.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
credentialStorageTails.set(storage, tail);
|
|
return result.finally(() => {
|
|
if (credentialStorageTails.get(storage) === tail) {
|
|
credentialStorageTails.delete(storage);
|
|
}
|
|
});
|
|
}
|
|
|
|
function toBase64Url(bytes) {
|
|
let binary = "";
|
|
for (const byte of bytes) {
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
|
|
function fromBase64Url(value) {
|
|
const padded = value
|
|
.replace(/-/g, "+")
|
|
.replace(/_/g, "/")
|
|
.padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
|
}
|
|
|
|
async function sha256Hex(bytes) {
|
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
export async function loadOrCreateCopilotIdentity(storage, gatewayScope) {
|
|
return await withCredentialStorage(storage, async () => {
|
|
const identities = (await storage.get([IDENTITIES_KEY]))[IDENTITIES_KEY];
|
|
const stored = identities?.[gatewayScope];
|
|
if (
|
|
typeof stored?.deviceId === "string" &&
|
|
typeof stored?.publicKey === "string" &&
|
|
typeof stored?.secretKey === "string"
|
|
) {
|
|
const secretKey = fromBase64Url(stored.secretKey);
|
|
return {
|
|
deviceId: stored.deviceId,
|
|
publicKey: stored.publicKey,
|
|
sign: async (payload) =>
|
|
toBase64Url(await signAsync(new TextEncoder().encode(payload), secretKey)),
|
|
};
|
|
}
|
|
const secretKey = ed25519Utils.randomSecretKey();
|
|
const publicKeyBytes = await getPublicKeyAsync(secretKey);
|
|
const identity = {
|
|
deviceId: await sha256Hex(publicKeyBytes),
|
|
publicKey: toBase64Url(publicKeyBytes),
|
|
secretKey: toBase64Url(secretKey),
|
|
};
|
|
await storage.set({
|
|
[IDENTITIES_KEY]: {
|
|
...(identities && typeof identities === "object" ? identities : {}),
|
|
[gatewayScope]: identity,
|
|
},
|
|
});
|
|
return {
|
|
deviceId: identity.deviceId,
|
|
publicKey: identity.publicKey,
|
|
sign: async (payload) =>
|
|
toBase64Url(await signAsync(new TextEncoder().encode(payload), secretKey)),
|
|
};
|
|
});
|
|
}
|
|
|
|
function tokenKey(gatewayScope, { clientId, deviceId, role }) {
|
|
return `${gatewayScope}\n${clientId}:${deviceId}:${role}`;
|
|
}
|
|
|
|
export function createCopilotTokenStore(storage, gatewayScope) {
|
|
return {
|
|
async load(params) {
|
|
return await withCredentialStorage(storage, async () => {
|
|
const tokens = (await storage.get([TOKENS_KEY]))[TOKENS_KEY];
|
|
const record = tokens?.[tokenKey(gatewayScope, params)];
|
|
return typeof record?.token === "string" && Array.isArray(record.scopes) ? record : null;
|
|
});
|
|
},
|
|
async store(params) {
|
|
await withCredentialStorage(storage, async () => {
|
|
const current = (await storage.get([TOKENS_KEY]))[TOKENS_KEY];
|
|
const tokens = current && typeof current === "object" ? { ...current } : {};
|
|
tokens[tokenKey(gatewayScope, params)] = {
|
|
token: params.token,
|
|
scopes: [...params.scopes],
|
|
};
|
|
await storage.set({ [TOKENS_KEY]: tokens });
|
|
});
|
|
},
|
|
async clear(params) {
|
|
await withCredentialStorage(storage, async () => {
|
|
const current = (await storage.get([TOKENS_KEY]))[TOKENS_KEY];
|
|
if (!current || typeof current !== "object") {
|
|
return;
|
|
}
|
|
const tokens = { ...current };
|
|
delete tokens[tokenKey(gatewayScope, params)];
|
|
await storage.set({ [TOKENS_KEY]: tokens });
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export function resolveCopilotClose(context) {
|
|
const details = context.connectFailure?.error?.details;
|
|
const tokenMismatch = details?.code === "AUTH_DEVICE_TOKEN_MISMATCH";
|
|
return {
|
|
retry:
|
|
details?.code === "PAIRING_REQUIRED" || (!tokenMismatch && details?.pauseReconnect !== true),
|
|
notify: !context.connectFailure,
|
|
pendingError: context.connectFailure?.error,
|
|
};
|
|
}
|