From 776f53944507597b77f68336668ace3fd0a1d648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 3 Nov 2026 14:10:47 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E4=BB=A5=E5=AE=9E=E4=BE=8B=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=20sctl=EF=BC=88=E7=A8=B3=E5=AE=9A=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=20ID=20=E4=B8=8E=E5=90=8D=E7=A7=B0=E3=80=81?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E7=BB=91=E5=AE=9A=E7=9A=84=E6=8F=A1=E6=89=8B?= =?UTF-8?q?=20HMAC=E3=80=81=E8=83=BD=E5=8A=9B=E5=A3=B0=E6=98=8E=E8=87=AA?= =?UTF-8?q?=E6=8A=A5=E4=BF=A1=E6=81=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../offscreen/external-access-connect.test.ts | 102 ++++++++++++++++-- .../offscreen/external-access-connect.ts | 30 ++++-- .../external_access/controller.test.ts | 72 +++++++++++-- .../external_access/controller.ts | 37 +++++-- .../external_access/service.test.ts | 28 +++-- .../external_access/test-locks.ts | 16 +++ .../service_worker/external_access/types.ts | 5 +- src/pkg/config/config.test.ts | 39 ++++++- src/pkg/config/config.ts | 37 ++++++- 9 files changed, 306 insertions(+), 60 deletions(-) create mode 100644 src/app/service/service_worker/external_access/test-locks.ts diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 990dc0a72..0a57de09b 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -21,9 +21,28 @@ import { CRYPTO, LIMITS } from "../service_worker/external_access/generated/prot initTestEnv(); -const CTX = CRYPTO.context; +// sctl Go Crypto.ScriptCat{Ext,Daemon}HMAC 生成的固定互操作向量。 +const vector = { + instanceId: "0123456789abcdef0123456789abcdef", + sessionKeyHex: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + nonceD: "aa11111111111111111111111111111111111111111111111111111111111111", + nonceE: "bb22222222222222222222222222222222222222222222222222222222222222", + pairingCode: "ABCD-EFGH", + sessionExtHmac: "044bb6d6f20efdd664e5c86e6c97127e1628784723b119149a93e7d0184ffa5c", + sessionDaemonHmac: "50bfad50a876b61b357ebfd9702feb1717fe7302acb08fbc87c588bf6d2c762d", + pairingExtHmac: "dd6e5d75790912cc8d530ac5cd7fef6d56b026ec48baa24d18a05c1f34a4848e", + pairingDaemonHmac: "f9954343b576e9388ea515635102cf74fb63db503c5a6cf08155ee6aa00fe037", + legacySessionExtHmac: "e522fd7aaa35892f1bbb76081e79946a8f8142d1c83c1dbc8804fe33dae99359", +}; // ──────────────────────────────────────────────── +const CTX = { + sessionExt: CRYPTO.context.scriptcatSessionExt + vector.instanceId, + sessionDaemon: CRYPTO.context.scriptcatSessionDaemon + vector.instanceId, + pairExt: CRYPTO.context.scriptcatPairExt + vector.instanceId, + pairDaemon: CRYPTO.context.scriptcatPairDaemon + vector.instanceId, +}; + // 独立参照实现(node:crypto)——同时充当 daemon 模拟器,钉住线格式与 sctl 互操作 // ──────────────────────────────────────────────── @@ -35,9 +54,9 @@ function nodeHmacHex(keyBytes: Uint8Array, context: string, a: string, b: string function nodePairingKeys(code: string): { mac: Buffer; enc: Buffer } { const ikm = Buffer.from(normalizePairingCode(code), "utf8"); - const salt = Buffer.from(CTX.pairKdfSalt, "utf8"); - const mac = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CTX.pairKdfInfoMac, "utf8"), 32)); - const enc = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CTX.pairKdfInfoEnc, "utf8"), 32)); + const salt = Buffer.from(CRYPTO.context.pairKdfSalt, "utf8"); + const mac = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CRYPTO.context.pairKdfInfoMac, "utf8"), 32)); + const enc = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CRYPTO.context.pairKdfInfoEnc, "utf8"), 32)); return { mac, enc }; } @@ -161,16 +180,85 @@ describe("ExternalAccessConnect", () => { vi.useRealTimers(); }); - function triggerConnect(param: ExternalAccessConnectParam): MockWebSocket { - (externalAccessConnect as any).startSession(param); + function triggerConnect(param: Omit): MockWebSocket { + (externalAccessConnect as unknown as { startSession(params: ExternalAccessConnectParam): void }).startSession({ + ...param, + instanceId: vector.instanceId, + }); return wsInstances[wsInstances.length - 1]; } const sessionParam = (key: string): ExternalAccessConnectParam => ({ url: "ws://127.0.0.1:8643", + instanceId: vector.instanceId, auth: { mode: "session", key }, }); + it.each(["session", "pairing"] as const)("%s 身份握手匹配 Go 向量并接受对应 daemon MAC", async (mode) => { + vi.spyOn(globalThis.crypto, "getRandomValues").mockImplementation((array) => { + if (!(array instanceof Uint8Array)) throw new Error("Expected nonce bytes"); + array.set(hexToBytes(vector.nonceE)); + return array; + }); + const auth = mode === "session" ? { mode, key: vector.sessionKeyHex } : { mode, code: vector.pairingCode }; + const ws = triggerConnect({ url: "ws://localhost:8643", auth }); + ws.simulateOpen(); + ws.simulateMessage({ + jsonrpc: "2.0", + id: "golden", + method: "$session.authenticate", + params: { nonceD: vector.nonceD }, + }); + await vi.waitFor(() => expect(ws.sentMessages).toHaveLength(1)); + expect(ws.sent(0).result).toEqual({ + mode, + nonceE: vector.nonceE, + hmac: mode === "session" ? vector.sessionExtHmac : vector.pairingExtHmac, + peer: { kind: "scriptcat", instanceId: vector.instanceId }, + }); + expect(ws.sent(0).result.hmac).not.toBe(vector.legacySessionExtHmac); + const key = + mode === "pairing" + ? nodeEncryptKey(nodePairingKeys(vector.pairingCode).enc, Buffer.from(vector.sessionKeyHex, "hex")) + : undefined; + ws.simulateMessage({ + jsonrpc: "2.0", + method: "$session.authenticated", + params: { hmac: mode === "session" ? vector.sessionDaemonHmac : vector.pairingDaemonHmac, key }, + }); + await vi.waitFor(() => + expect((externalAccessConnect as unknown as { handshakeComplete: boolean }).handshakeComplete).toBe(true) + ); + if (mode === "pairing") expect(relay.paired).toHaveBeenCalledWith(vector.sessionKeyHex); + }); + + it.each(["session", "pairing"] as const)("%s 拒绝未绑定实例的旧 daemon MAC", async (mode) => { + const auth = mode === "session" ? { mode, key: vector.sessionKeyHex } : { mode, code: vector.pairingCode }; + const ws = triggerConnect({ url: "ws://localhost:8643", auth }); + ws.simulateOpen(); + ws.simulateMessage({ + jsonrpc: "2.0", + id: "legacy", + method: "$session.authenticate", + params: { nonceD: vector.nonceD }, + }); + await vi.waitFor(() => expect(ws.sentMessages).toHaveLength(1)); + const macKey = mode === "session" ? hexToBytes(vector.sessionKeyHex) : nodePairingKeys(vector.pairingCode).mac; + const hmac = nodeHmacHex( + macKey, + mode === "session" ? CRYPTO.context.sessionDaemon : CRYPTO.context.pairDaemon, + ws.sent(0).result.nonceE, + vector.nonceD + ); + const key = + mode === "pairing" + ? nodeEncryptKey(nodePairingKeys(vector.pairingCode).enc, Buffer.from(vector.sessionKeyHex, "hex")) + : undefined; + ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac, key } }); + await vi.waitFor(() => expect(ws.readyState).toBe(MockWebSocket.CLOSED)); + expect(relay.paired).not.toHaveBeenCalled(); + }); + it("按配置原样连接 WebSocket URL,不限定回环地址或 ws 协议", () => { const url = "wss://bridge.example.com/external-access?profile=team"; const ws = triggerConnect({ url, auth: { mode: "session", key: "aa".repeat(32) } }); @@ -238,7 +326,6 @@ describe("ExternalAccessConnect", () => { const resp = ws.sent(0); expect(resp).toMatchObject({ jsonrpc: "2.0", id: "c1" }); expect(resp.result.mode).toBe("session"); - // 扩展侧 HMAC 用 sessionExt || nonceD || nonceE,参照实现逐字节复核 expect(resp.result.hmac).toBe(nodeHmacHex(new Uint8Array(K), CTX.sessionExt, nonceD, resp.result.nonceE)); const okHmac = nodeHmacHex(new Uint8Array(K), CTX.sessionDaemon, resp.result.nonceE, nonceD); @@ -310,7 +397,6 @@ describe("ExternalAccessConnect", () => { await vi.waitFor(() => expect(ws.sentMessages.length).toBe(1)); const resp = ws.sent(0); expect(resp.result.mode).toBe("pairing"); - // 配对模式用 pairExt 上下文 + Kp_mac;认证响应不含配对码本身 expect(resp.result.hmac).toBe(nodeHmacHex(new Uint8Array(mac), CTX.pairExt, nonceD, resp.result.nonceE)); expect(resp.result.code).toBeUndefined(); diff --git a/src/app/service/offscreen/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts index 8203abc17..566889276 100644 --- a/src/app/service/offscreen/external-access-connect.ts +++ b/src/app/service/offscreen/external-access-connect.ts @@ -41,6 +41,7 @@ export type ExternalAccessAuth = { mode: "session"; key: string } | { mode: "pai export interface ExternalAccessConnectParam { url: string; auth: ExternalAccessAuth; + instanceId: string; } // --------------------------------------------------------------------------------------------- @@ -105,7 +106,6 @@ export function normalizePairingCode(code: string): string { .replace(/[IL]/g, "1"); } -// HMAC-SHA-256(key, utf8(context || firstHex || secondHex)) → 小写 hex。 export async function computeHandshakeHmac( keyBytes: Bytes, context: string, @@ -150,18 +150,20 @@ export async function decryptLongTermKey(encKeyBytes: Bytes, ivB64: string, ciph // 收到 $session.authenticate 后,按当前模式计算响应,并带出握手参数以验证 $session.authenticated。 async function buildAuthResponse( auth: ExternalAccessAuth, + instanceId: string, nonceD: string ): Promise<{ payload: AuthResponsePayload; verifyKey: Bytes; nonceE: string; enc?: Bytes }> { const nonceE = randomNonceHex(); const mode: AuthMode = auth.mode; + const peer: AuthResponsePayload["peer"] = { kind: "scriptcat", instanceId }; if (auth.mode === "session") { const keyBytes = hexToBytes(auth.key); - const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.sessionExt, nonceD, nonceE); - return { payload: { mode, nonceE, hmac }, verifyKey: keyBytes, nonceE }; + const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.scriptcatSessionExt + instanceId, nonceD, nonceE); + return { payload: { mode, nonceE, hmac, peer }, verifyKey: keyBytes, nonceE }; } const { mac, enc } = await derivePairingKeys(auth.code); - const hmac = await computeHandshakeHmac(mac, CRYPTO.context.pairExt, nonceD, nonceE); - return { payload: { mode, nonceE, hmac }, verifyKey: mac, nonceE, enc }; + const hmac = await computeHandshakeHmac(mac, CRYPTO.context.scriptcatPairExt + instanceId, nonceD, nonceE); + return { payload: { mode, nonceE, hmac, peer }, verifyKey: mac, nonceE, enc }; } export class ExternalAccessConnect { @@ -281,12 +283,12 @@ export class ExternalAccessConnect { } private async handleHandshakeMessage(envelope: WSEnvelope, sessionEpoch: number): Promise { - const auth = this.currentParams?.auth; - if (!auth) return; + if (!this.currentParams) return; + const { auth, instanceId } = this.currentParams; if (envelope.method === "$session.authenticate" && envelope.id) { const { nonceD } = envelope.params as AuthChallengePayload; - const built = await buildAuthResponse(auth, nonceD); + const built = await buildAuthResponse(auth, instanceId, nonceD); if (sessionEpoch !== this.epoch) return; // 保存验证素材,供随后的 $session.authenticated 校验使用。 this.pendingVerify = { @@ -311,12 +313,18 @@ export class ExternalAccessConnect { private async verifyAuthOk(payload: AuthOkPayload, sessionEpoch: number): Promise { const pending = this.pendingVerify; - if (!pending) { + if (!pending || !this.currentParams) { this.ws?.close(); return; } - const context = pending.mode === "session" ? CRYPTO.context.sessionDaemon : CRYPTO.context.pairDaemon; - const expected = await computeHandshakeHmac(pending.verifyKey, context, pending.nonceE, pending.nonceD); + const context = + pending.mode === "session" ? CRYPTO.context.scriptcatSessionDaemon : CRYPTO.context.scriptcatPairDaemon; + const expected = await computeHandshakeHmac( + pending.verifyKey, + context + this.currentParams.instanceId, + pending.nonceE, + pending.nonceD + ); if (sessionEpoch !== this.epoch) return; if (!constantTimeEqualHex(expected, payload.hmac || "")) { this.logger.warn("$session.authenticated HMAC mismatch, closing"); diff --git a/src/app/service/service_worker/external_access/controller.test.ts b/src/app/service/service_worker/external_access/controller.test.ts index ca22731ad..81963fb04 100644 --- a/src/app/service/service_worker/external_access/controller.test.ts +++ b/src/app/service/service_worker/external_access/controller.test.ts @@ -3,6 +3,8 @@ import { ExternalAccessController } from "./controller"; import { SystemConfig } from "@App/pkg/config/config"; import { MessageQueue } from "@Packages/message/message_queue"; import type { WSEnvelope } from "./types"; +import { mockIdentityLock } from "./test-locks"; +import * as browserUtils from "@App/pkg/utils/utils"; // Captures the relay handlers ExternalAccessController registers on its Group so tests can feed decoded // envelopes the way the offscreen ExternalAccessConnect would over the wire. @@ -35,6 +37,8 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { let fake: ReturnType; beforeEach(() => { + mockIdentityLock(); + vi.spyOn(chrome.runtime, "getManifest").mockReturnValue({ ...chrome.runtime.getManifest(), version: "1.5.0" }); chrome.storage.local.clear(); chrome.storage.sync.clear(); chrome.storage.session.clear(); @@ -63,8 +67,8 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { ); } - async function initEnrolled(key = "deadbeef", clientId = "c-existing") { - systemConfig.setExternalAccessPairing({ key, clientId }); + async function initEnrolled(key = "deadbeef") { + systemConfig.setExternalAccessPairing({ key }); const controller = makeController(); await controller.initialize(); return controller; @@ -86,10 +90,14 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { }); it("已接入且 external_access_enabled 变为 true 时以会话模式拨号,携带 URL 与长期密钥", async () => { - await initEnrolled("abc123", "cid"); + await initEnrolled("abc123"); systemConfig.setExternalAccessEnabled(true); await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); - expect(connectClient.connect).toHaveBeenCalledWith({ url: DEFAULT_URL, auth: { mode: "session", key: "abc123" } }); + expect(connectClient.connect).toHaveBeenCalledWith({ + url: DEFAULT_URL, + auth: { mode: "session", key: "abc123" }, + instanceId: (await systemConfig.getExternalAccessIdentity()).instanceId, + }); }); describe("隐身(split)进程", () => { @@ -106,7 +114,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("已启用且已接入时启动也不拨号,状态保持 disabled", async () => { systemConfig.setExternalAccessEnabled(true); - const controller = await initEnrolled("abc123", "cid"); + const controller = await initEnrolled("abc123"); expect(connectClient.connect).not.toHaveBeenCalled(); expect(controller.getStatus().status).toBe("disabled"); }); @@ -127,10 +135,54 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { expect(connectClient.connect).toHaveBeenCalledWith({ url: DEFAULT_URL, auth: { mode: "pairing", code: "PAIR-CODE" }, + instanceId: (await systemConfig.getExternalAccessIdentity()).instanceId, }); expect(controller.getStatus().status).toBe("connecting"); }); + it.each(["Chrome", "Edge", "Firefox", "Safari", undefined])("能力声明包含名称与 %s 产品信息", async (product) => { + vi.spyOn(browserUtils, "getBrowserType").mockReturnValue({ + firefox: Number(product === "Firefox"), + webkit: Number(product === "Safari"), + chrome: product === "Edge" ? browserUtils.BrowserType.Edge : Number(product === "Chrome"), + unknown: Number(!product), + chromeVersion: 0, + device: 0, + }); + vi.spyOn(browserUtils, "getBrowserVersion").mockReturnValue(product ? 129 : 0); + await initEnrolled(); + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + const identity = await systemConfig.getExternalAccessIdentity(); + expect(connectClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + method: "$session.capabilities", + params: expect.objectContaining({ + schemaVersion: expect.any(String), + methods: expect.any(Array), + peer: { + name: identity.name, + ...(product ? { product, productVersion: "129" } : {}), + extensionVersion: chrome.runtime.getManifest().version, + }, + }), + }) + ); + }); + + it.each([63, 64, 65])("能力声明自报文本在 %i UTF-8 字节边界不拆字符", async (bytes) => { + const text = "a".repeat(bytes - 4) + "😀"; + vi.spyOn(chrome.runtime, "getManifest").mockReturnValue({ ...chrome.runtime.getManifest(), version: text }); + vi.spyOn(browserUtils, "getBrowserVersion").mockReturnValue(Number.NaN); + await chrome.storage.local.set({ system_external_access_identity: { instanceId: "ab".repeat(16), name: text } }); + await initEnrolled(); + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + const peer = connectClient.send.mock.calls[0][0].params.peer; + const expected = bytes <= 64 ? text : "a".repeat(bytes - 4); + expect(peer.name).toBe(expected); + expect(peer.extensionVersion).toBe(expected); + expect(peer).not.toHaveProperty("productVersion"); + }); + it("接入成功(paired)时持久化长期密钥 K", async () => { const controller = makeController(); await controller.initialize(); @@ -140,7 +192,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("hello 只展示 daemon 版本,不按产品版本阻断兼容的 bridge 调用", async () => { const controller = await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, @@ -148,7 +200,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { // hello 携带 daemonVersion,状态条据此显示「sctl v{daemonVersion}」。 expect(controller.getStatus()).toEqual({ status: "connected", daemonVersion: "0.1.0" }); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.0.1" }, @@ -165,7 +217,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("JSON-RPC request uses id to correlate the response", async () => { await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, @@ -188,7 +240,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("a suspended JSON-RPC request does not respond before the decision", async () => { bridgeHandle.mockResolvedValue(null); await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, @@ -211,7 +263,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("socket 断开时状态转为 host_unreachable 并清除 sctl 版本", async () => { const controller = await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, diff --git a/src/app/service/service_worker/external_access/controller.ts b/src/app/service/service_worker/external_access/controller.ts index 250afc9a0..746bac969 100644 --- a/src/app/service/service_worker/external_access/controller.ts +++ b/src/app/service/service_worker/external_access/controller.ts @@ -1,5 +1,6 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; -import type { SystemConfig } from "@App/pkg/config/config"; +import { getBrowserVersion } from "@App/pkg/utils/utils"; +import { externalAccessProduct, type SystemConfig } from "@App/pkg/config/config"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { ExternalAccessConnectClient } from "../../offscreen/client"; import type { ExternalAccessBridge } from "./bridge"; @@ -14,6 +15,11 @@ import { import type { Group } from "@Packages/message/server"; import { RPC_METHODS, SCHEMA_VERSION } from "./generated/protocol.generated"; +function truncatePeerText(text: string): string { + const { read } = new TextEncoder().encodeInto(text, new Uint8Array(64)); + return text.slice(0, read); +} + // Broadcast on every status transition so the Tools settings page updates live. export const ExternalAccessStatusChanged = "mcpStatusChanged"; @@ -86,7 +92,8 @@ export class ExternalAccessController { } this.setStatus("connecting"); const url = await this.systemConfig.getExternalAccessUrl(); - void this.connectClient.connect({ url, auth: { mode: "session", key: pairing.key } }); + const { instanceId } = await this.systemConfig.getExternalAccessIdentity(); + void this.connectClient.connect({ url, instanceId, auth: { mode: "session", key: pairing.key } }); } // Enrollment (接入): the user ran `sctl connect`, which printed a one-time code C shown only in the @@ -98,20 +105,33 @@ export class ExternalAccessController { this.active = true; this.setStatus("connecting"); const url = await this.systemConfig.getExternalAccessUrl(); - void this.connectClient.connect({ url, auth: { mode: "pairing", code } }); + const { instanceId } = await this.systemConfig.getExternalAccessIdentity(); + void this.connectClient.connect({ url, instanceId, auth: { mode: "pairing", code } }); } - private onEnvelope(envelope: WSEnvelope): void { + private async onEnvelope(envelope: WSEnvelope): Promise { switch (envelope.method) { case "$session.hello": { const { daemonVersion } = envelope.params as HelloPayload; this.daemonVersion = daemonVersion; this.setStatus("connected"); + const { name } = await this.systemConfig.getExternalAccessIdentity(); + const product = externalAccessProduct(); + const version = getBrowserVersion(); void this.connectClient.send({ jsonrpc: "2.0", id: uuidv4(), method: "$session.capabilities", - params: { schemaVersion: SCHEMA_VERSION, methods: Object.keys(RPC_METHODS) }, + params: { + schemaVersion: SCHEMA_VERSION, + methods: Object.keys(RPC_METHODS), + peer: { + name: truncatePeerText(name), + ...(product ? { product } : {}), + ...(Number.isFinite(version) && version > 0 ? { productVersion: String(version) } : {}), + extensionVersion: truncatePeerText(chrome.runtime.getManifest().version), + }, + }, }); break; } @@ -137,11 +157,8 @@ export class ExternalAccessController { } } - // Enrollment succeeded: persist the daemon-minted long-term key alongside a stable local client - // identity so future reconnects use session-mode auth. - private async onEnrolled(key: string): Promise { - const existing = await this.systemConfig.getExternalAccessPairing(); - this.systemConfig.setExternalAccessPairing({ key, clientId: existing.clientId || uuidv4() }); + private onEnrolled(key: string): void { + this.systemConfig.setExternalAccessPairing({ key }); } private onDisconnected(): void { diff --git a/src/app/service/service_worker/external_access/service.test.ts b/src/app/service/service_worker/external_access/service.test.ts index 52807a272..8066fc111 100644 --- a/src/app/service/service_worker/external_access/service.test.ts +++ b/src/app/service/service_worker/external_access/service.test.ts @@ -1,11 +1,13 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockIdentityLock } from "./test-locks"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ExternalAccessUIService } from "./service"; import { ExternalAccessOperationDAO } from "@App/app/repo/external_access"; import { ExternalAccessApprovalService } from "./approval"; import { ScriptDAO, ScriptCodeDAO } from "@App/app/repo/scripts"; import { TempStorageDAO } from "@App/app/repo/tempStorage"; import { createMockOPFS } from "@App/app/repo/test-helpers"; -import type { SystemConfig } from "@App/pkg/config/config"; +import { SystemConfig } from "@App/pkg/config/config"; +import { MessageQueue } from "@Packages/message/message_queue"; describe("ExternalAccessUIService(外部接入页端点)", () => { let approval: ExternalAccessApprovalService; @@ -14,13 +16,12 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { enroll: ReturnType; stop: ReturnType; }; - let systemConfig: { - setExternalAccessPairing: ReturnType; - setExternalAccessEnabled: ReturnType; - }; + let systemConfig: SystemConfig; let handlers: Record any>; let service: ExternalAccessUIService; + afterEach(() => vi.restoreAllMocks()); + beforeEach(() => { chrome.storage.local.clear(); createMockOPFS(); @@ -37,10 +38,10 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { enroll: vi.fn(), stop: vi.fn(), }; - systemConfig = { setExternalAccessPairing: vi.fn(), setExternalAccessEnabled: vi.fn() }; + systemConfig = new SystemConfig(new MessageQueue()); handlers = {}; const group = { on: (name: string, fn: (...args: any[]) => any) => (handlers[name] = fn) } as any; - service = new ExternalAccessUIService(group, controller as any, approval, systemConfig as unknown as SystemConfig); + service = new ExternalAccessUIService(group, controller as any, approval, systemConfig); service.init(); }); @@ -75,12 +76,17 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { expect(decide).toHaveBeenCalledWith("op", true, { enable: true, rememberSession: true }); }); - it("stopExternalAccess 废弃密钥 K、清本会话授权、停止并关闭开关", async () => { + it("stopExternalAccess 废弃密钥但保留实例身份、清本会话授权、停止并关闭开关", async () => { + mockIdentityLock(); + const identity = await systemConfig.getExternalAccessIdentity(); + systemConfig.setExternalAccessPairing({ key: "test-key" }); + systemConfig.setExternalAccessEnabled(true); const clear = vi.spyOn(approval, "clearSessionAllow").mockResolvedValue(undefined); await handlers["stopExternalAccess"](); - expect(systemConfig.setExternalAccessPairing).toHaveBeenCalledWith(undefined); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "" }); + expect(await new SystemConfig(new MessageQueue()).getExternalAccessIdentity()).toEqual(identity); expect(clear).toHaveBeenCalled(); expect(controller.stop).toHaveBeenCalled(); - expect(systemConfig.setExternalAccessEnabled).toHaveBeenCalledWith(false); + expect(await systemConfig.getExternalAccessEnabled()).toBe(false); }); }); diff --git a/src/app/service/service_worker/external_access/test-locks.ts b/src/app/service/service_worker/external_access/test-locks.ts new file mode 100644 index 000000000..6004b3dc0 --- /dev/null +++ b/src/app/service/service_worker/external_access/test-locks.ts @@ -0,0 +1,16 @@ +import { vi } from "vitest"; + +// happy-dom 没有 Web Locks;模拟同名锁的串行调度,使并发读取走真实的本地存储。 +export function mockIdentityLock() { + let tail = Promise.resolve(); + vi.spyOn(navigator, "locks", "get").mockReturnValue({ + request: (_name: string, run: () => Promise) => { + const result = tail.then(run); + tail = result.then( + () => undefined, + () => undefined + ); + return result; + }, + } as LockManager); +} diff --git a/src/app/service/service_worker/external_access/types.ts b/src/app/service/service_worker/external_access/types.ts index 614eddde7..af67b9d65 100644 --- a/src/app/service/service_worker/external_access/types.ts +++ b/src/app/service/service_worker/external_access/types.ts @@ -48,15 +48,16 @@ export interface AuthChallengePayload { nonceD: string; } -// ext->daemon: the extension's nonceE plus HMAC(key, ctx || nonceD || nonceE). `key` is the +// ext->daemon: the extension's nonceE plus HMAC(key, ctx || instanceId || nonceD || nonceE). `key` is the // long-term K in session mode, or the code-derived Kp_mac in pairing mode. export interface AuthResponsePayload { mode: AuthMode; + peer: { kind: "scriptcat"; instanceId: string }; nonceE: string; hmac: string; } -// daemon->ext: HMAC(key, ctx || nonceE || nonceD) proving the daemon also holds the key. In +// daemon->ext: HMAC(key, ctx || instanceId || nonceE || nonceD) proving the daemon also holds the key. In // pairing mode it additionally ships the freshly minted long-term K, AES-256-GCM encrypted under // the code-derived Kp_enc. export interface AuthOkPayload { diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index a31c26936..33cd7dcaf 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { SystemConfig } from "./config"; +import * as browserUtils from "../utils/utils"; +import { mockIdentityLock } from "@App/app/service/service_worker/external_access/test-locks"; import { MessageQueue } from "@Packages/message/message_queue"; describe("SystemConfig 双 storage 与懒迁移", () => { @@ -7,6 +9,7 @@ describe("SystemConfig 双 storage 与懒迁移", () => { let config: SystemConfig; beforeEach(() => { + mockIdentityLock(); // 清空 storage 数据 chrome.storage.sync.clear(); chrome.storage.local.clear(); @@ -19,6 +22,34 @@ describe("SystemConfig 双 storage 与懒迁移", () => { }); describe("local key 读写", () => { + it.each(["chrome", "edge", "firefox", "safari", "scriptcat"])( + "首次读取创建 %s 身份且跨配置对象保持稳定、不参与同步", + async (slug) => { + vi.spyOn(browserUtils, "getBrowserType").mockReturnValue({ + firefox: Number(slug === "firefox"), + webkit: Number(slug === "safari"), + chrome: slug === "edge" ? browserUtils.BrowserType.Edge : Number(slug === "chrome"), + unknown: Number(slug === "scriptcat"), + chromeVersion: 0, + device: 0, + }); + expect(await chrome.storage.local.get("system_external_access_identity")).toEqual({}); + const other = new SystemConfig(mq); + const [identity, concurrent] = await Promise.all([ + config.getExternalAccessIdentity(), + other.getExternalAccessIdentity(), + ]); + expect(identity.instanceId).toMatch(/^[0-9a-f]{32}$/); + expect(identity.name).toBe(`${slug}-${identity.instanceId.slice(0, 4)}`); + expect(concurrent).toEqual(identity); + expect(await new SystemConfig(mq).getExternalAccessIdentity()).toEqual(identity); + expect(await config.getExternalAccessIdentity()).toEqual(identity); + expect(await chrome.storage.local.get("system_external_access_identity")).toEqual({ + system_external_access_identity: identity, + }); + expect(await chrome.storage.sync.get("system_external_access_identity")).toEqual({}); + } + ); it("cloud_sync 应写入 local storage 而非 sync", async () => { const cloudSync = { enable: true, @@ -148,11 +179,11 @@ describe("SystemConfig 双 storage 与懒迁移", () => { }); it("external_access_pairing 默认未配对,写入后落 local storage 且不入 sync", async () => { - expect(await config.getExternalAccessPairing()).toEqual({ key: "", clientId: "" }); - config.setExternalAccessPairing({ key: "deadbeef", clientId: "cid-1" }); - expect(await config.getExternalAccessPairing()).toEqual({ key: "deadbeef", clientId: "cid-1" }); + expect(await config.getExternalAccessPairing()).toEqual({ key: "" }); + config.setExternalAccessPairing({ key: "deadbeef" }); + expect(await config.getExternalAccessPairing()).toEqual({ key: "deadbeef" }); const localData = await chrome.storage.local.get("system_external_access_pairing"); - expect(localData["system_external_access_pairing"]).toEqual({ key: "deadbeef", clientId: "cid-1" }); + expect(localData["system_external_access_pairing"]).toEqual({ key: "deadbeef" }); const syncData = await chrome.storage.sync.get("system_external_access_pairing"); expect(syncData["system_external_access_pairing"]).toBeUndefined(); }); diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index fbd630de8..7d058f387 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -7,7 +7,7 @@ import { matchLanguage } from "@App/locales/locales"; import { ExtVersion } from "@App/app/const"; import defaultTypeDefinition from "@App/template/scriptcat.d.tpl"; import type { ScriptTemplateOverrides } from "@App/pkg/utils/script_template"; -import { toCamelCase } from "../utils/utils"; +import { BrowserType, getBrowserType, toCamelCase } from "../utils/utils"; import EventEmitter from "eventemitter3"; import { STORAGE_LOCAL_KEYS } from "./consts"; @@ -46,13 +46,25 @@ export type FaviconService = "none" | "scriptcat" | "google" | "duckduckgo" | "i export type ExternalAccessWritePolicy = "approval" | "allow"; export type ExternalAccessSourceReadPolicy = "approval" | "allow"; -// MCP 配对成功后落地的长期共享密钥 K(小写 hex)与本扩展实例的客户端身份。 // key 为空串表示尚未配对。仅存 chrome.storage.local,绝不跨设备同步。 export type ExternalAccessPairing = { key: string; - clientId: string; }; +export type ExternalAccessIdentity = { + instanceId: string; + name: string; +}; + +export function externalAccessProduct() { + const browser = getBrowserType(); + if (browser.firefox) return "Firefox"; + if (browser.webkit) return "Safari"; + if (browser.chrome & BrowserType.Edge) return "Edge"; + if (browser.chrome & BrowserType.Chrome) return "Chrome"; + return undefined; +} + export type CATFileStorage = { filesystem: FileSystemType; params: { [key: string]: any }; @@ -713,7 +725,24 @@ export class SystemConfig { } getExternalAccessPairing() { - return this._get("external_access_pairing", { key: "", clientId: "" }); + return this._get("external_access_pairing", { key: "" }); + } + + async getExternalAccessIdentity(): Promise { + // 页面、后台及隐身上下文可能同时首次读取;跨上下文锁保证只生成一份身份。 + // 身份没有历史同步数据,不能走普通配置的 sync → local 迁移路径。 + return navigator.locks.request("scriptcat-external-access-identity", async () => { + const key = "system_external_access_identity"; + const stored = await chrome.storage.local.get(key); + if (stored[key]) return stored[key] as ExternalAccessIdentity; + const instanceId = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0") + ).join(""); + const slug = externalAccessProduct()?.toLowerCase() ?? "scriptcat"; + const identity = { instanceId, name: `${slug}-${instanceId.slice(0, 4)}` }; + await chrome.storage.local.set({ [key]: identity }); + return identity; + }); } setBlacklist(blacklist: string) { From e54347d64d81f8d3b2f5028692b34202254ad3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 3 Nov 2026 14:37:54 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E8=A2=AB=20sctl=20=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E6=97=B6=E5=81=9C=E6=AD=A2=E9=87=8D=E8=BF=9E=E5=B9=B6=E7=BB=99?= =?UTF-8?q?=E5=87=BA=E5=8E=9F=E5=9B=A0=EF=BC=8C=E6=94=AF=E6=8C=81=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=97=B6=E5=91=BD=E5=90=8D=E4=B8=8E=E6=94=B9=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../offscreen/event_page_manager.test.ts | 27 +-- .../offscreen/external-access-connect.test.ts | 94 ++++++++-- .../offscreen/external-access-connect.ts | 85 ++++++--- src/app/service/service_worker/client.ts | 20 +- .../external_access/controller.test.ts | 158 ++++++++++++++-- .../external_access/controller.ts | 176 ++++++++++++++---- .../external_access/service.test.ts | 19 +- .../service_worker/external_access/service.ts | 24 ++- .../service_worker/external_access/types.ts | 20 +- src/locales/de-DE/external_access.json | 1 + src/locales/en-US/external_access.json | 1 + src/locales/ja-JP/external_access.json | 1 + src/locales/ko-KR/external_access.json | 1 + src/locales/pt-BR/external_access.json | 1 + src/locales/ru-RU/external_access.json | 1 + src/locales/tr-TR/external_access.json | 1 + src/locales/vi-VN/external_access.json | 1 + src/locales/zh-CN/external_access.json | 1 + src/locales/zh-TW/external_access.json | 1 + .../sections/ExternalAccessSection.test.tsx | 29 ++- .../Tools/sections/ExternalAccessSection.tsx | 24 +-- src/pkg/config/config.ts | 20 +- 22 files changed, 550 insertions(+), 156 deletions(-) diff --git a/src/app/service/offscreen/event_page_manager.test.ts b/src/app/service/offscreen/event_page_manager.test.ts index c133baf71..58536cffb 100644 --- a/src/app/service/offscreen/event_page_manager.test.ts +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -34,6 +34,20 @@ afterEach(() => { } }); +// fast 项目共享模块环境。切换平台时重载了 LoggerCore,也要重建它的单例, +// 否则随后运行的测试无法创建 Server 或发布 MessageQueue 消息。 +const resetPlatformModules = async () => { + vi.resetModules(); + const loggerModule = await import("../../logger/core.js"); + const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType; + new LoggerCore({ + level: "trace", + consoleLevel: "trace", + writer: new loggerModule.EmptyWriter(), + labels: { env: "test" }, + }); +}; + const loadKeepAliveEnabledManager = async () => { vi.stubGlobal("scheduler", { postTask: vi.fn(), @@ -52,22 +66,13 @@ const loadKeepAliveEnabledManager = async () => { } } ); - vi.resetModules(); - const loggerModule = await import("../../logger/core.js"); - const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType; - const logger = new LoggerCore({ - level: "trace", - consoleLevel: "trace", - writer: new loggerModule.EmptyWriter(), - labels: { env: "test" }, - }); - logger.logger().debug("test start"); + await resetPlatformModules(); return { ...(await import("./event_page_manager.js")), ...(await import("./keep_alive.js")), probeImages }; }; const loadChromeKeepAlive = async () => { isFirefoxMock.mockReturnValue(false); - vi.resetModules(); + await resetPlatformModules(); return await import("./keep_alive.js"); }; diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 0a57de09b..3260ee28d 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -1,3 +1,4 @@ +import type { WSEnvelope } from "../service_worker/external_access/types"; import { initTestEnv } from "@Tests/utils"; import { ExternalAccessConnect, @@ -111,9 +112,9 @@ class MockWebSocket { this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) })); } - simulateClose() { + simulateClose(code = 1000) { this.readyState = MockWebSocket.CLOSED; - this.onclose?.(new CloseEvent("close")); + this.onclose?.(new CloseEvent("close", { code })); } simulateError() { @@ -152,7 +153,7 @@ describe("ExternalAccessConnect", () => { let externalAccessConnect: ExternalAccessConnect; let relay: { envelope: ReturnType; - paired: ReturnType; + established: ReturnType; disconnected: ReturnType; }; @@ -168,7 +169,7 @@ describe("ExternalAccessConnect", () => { externalAccessConnect = new ExternalAccessConnect(group, mockMessage); relay = { envelope: vi.fn().mockResolvedValue(undefined), - paired: vi.fn().mockResolvedValue(undefined), + established: vi.fn().mockResolvedValue(undefined), disconnected: vi.fn().mockResolvedValue(undefined), }; (externalAccessConnect as any).relay = relay; @@ -188,13 +189,31 @@ describe("ExternalAccessConnect", () => { return wsInstances[wsInstances.length - 1]; } + function declareCapabilities() { + ( + externalAccessConnect as unknown as { + sendEnvelope(env: WSEnvelope): void; + } + ).sendEnvelope({ jsonrpc: "2.0", id: "cap", method: "$session.capabilities", params: {} }); + } + const sessionParam = (key: string): ExternalAccessConnectParam => ({ url: "ws://127.0.0.1:8643", instanceId: vector.instanceId, auth: { mode: "session", key }, }); - it.each(["session", "pairing"] as const)("%s 身份握手匹配 Go 向量并接受对应 daemon MAC", async (mode) => { + it("打开后认证前的 1008 拒绝停止所有自动重连", async () => { + vi.useFakeTimers(); + const ws = triggerConnect(sessionParam(vector.sessionKeyHex)); + ws.simulateOpen(); + ws.simulateClose(1008); + expect(relay.disconnected).toHaveBeenCalledWith("refused", "session"); + await vi.advanceTimersByTimeAsync(120_000); + expect(wsInstances).toHaveLength(1); + }); + + it.each(["session", "pairing"] as const)("%s 身份握手匹配 Go 向量;名称冲突不提交密钥且不重试", async (mode) => { vi.spyOn(globalThis.crypto, "getRandomValues").mockImplementation((array) => { if (!(array instanceof Uint8Array)) throw new Error("Expected nonce bytes"); array.set(hexToBytes(vector.nonceE)); @@ -229,7 +248,19 @@ describe("ExternalAccessConnect", () => { await vi.waitFor(() => expect((externalAccessConnect as unknown as { handshakeComplete: boolean }).handshakeComplete).toBe(true) ); - if (mode === "pairing") expect(relay.paired).toHaveBeenCalledWith(vector.sessionKeyHex); + expect(relay.established).not.toHaveBeenCalled(); + declareCapabilities(); + vi.useFakeTimers(); + ws.simulateMessage({ + jsonrpc: "2.0", + id: "cap", + error: { code: -32000, message: "conflict", data: { code: "CONFLICT" } }, + }); + ws.simulateClose(); + await vi.advanceTimersByTimeAsync(120_000); + expect(relay.disconnected).toHaveBeenCalledWith("name-taken", mode); + expect(relay.established).not.toHaveBeenCalled(); + expect(wsInstances).toHaveLength(1); }); it.each(["session", "pairing"] as const)("%s 拒绝未绑定实例的旧 daemon MAC", async (mode) => { @@ -256,7 +287,7 @@ describe("ExternalAccessConnect", () => { : undefined; ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac, key } }); await vi.waitFor(() => expect(ws.readyState).toBe(MockWebSocket.CLOSED)); - expect(relay.paired).not.toHaveBeenCalled(); + expect(relay.established).not.toHaveBeenCalled(); }); it("按配置原样连接 WebSocket URL,不限定回环地址或 ws 协议", () => { @@ -404,8 +435,12 @@ describe("ExternalAccessConnect", () => { const key = nodeEncryptKey(enc, K); ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac: okHmac, key } }); - await vi.waitFor(() => expect(relay.paired).toHaveBeenCalledTimes(1)); - expect(relay.paired.mock.calls[0][0]).toBe(K.toString("hex")); + await vi.waitFor(() => + expect((externalAccessConnect as unknown as { handshakeComplete: boolean }).handshakeComplete).toBe(true) + ); + declareCapabilities(); + ws.simulateMessage({ jsonrpc: "2.0", id: "cap", result: {} }); + await vi.waitFor(() => expect(relay.established).toHaveBeenCalledWith(K.toString("hex"))); expect((externalAccessConnect as any).handshakeComplete).toBe(true); // 握手后内部凭据切到会话模式,供后续自动重连 expect((externalAccessConnect as any).currentParams.auth).toEqual({ mode: "session", key: K.toString("hex") }); @@ -425,15 +460,16 @@ describe("ExternalAccessConnect", () => { const closeSpy = vi.spyOn(ws, "close"); ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac: okHmac } }); await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); - expect(relay.paired).not.toHaveBeenCalled(); + expect(relay.established).not.toHaveBeenCalled(); }); }); // ──────────────────────────────────────────────── describe("握手后信封路由与心跳", () => { - async function completeSessionHandshake(): Promise { - const K = randomBytes(32); - const ws = triggerConnect(sessionParam(K.toString("hex"))); + async function completeSessionHandshake( + K = randomBytes(32), + ws = triggerConnect(sessionParam(K.toString("hex"))) + ): Promise { ws.simulateOpen(); const nonceD = randomBytes(32).toString("hex"); ws.simulateMessage({ jsonrpc: "2.0", id: "c1", method: "$session.authenticate", params: { nonceD } }); @@ -445,6 +481,38 @@ describe("ExternalAccessConnect", () => { return ws; } + it("能力声明前不重置退避,接受后普通关闭从 2 秒重试", async () => { + vi.useFakeTimers(); + const key = randomBytes(32); + triggerConnect(sessionParam(key.toString("hex"))).simulateClose(); + await vi.advanceTimersByTimeAsync(2000); + const beforeCapabilities = await completeSessionHandshake(key, wsInstances[1]); + beforeCapabilities.simulateClose(); + await vi.advanceTimersByTimeAsync(3999); + expect(wsInstances).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + const ws = await completeSessionHandshake(key, wsInstances[2]); + declareCapabilities(); + ws.simulateMessage({ jsonrpc: "2.0", id: "cap", result: {} }); + await vi.advanceTimersByTimeAsync(0); + expect(relay.established).toHaveBeenCalledWith(undefined); + ws.simulateClose(1008); + await vi.advanceTimersByTimeAsync(1999); + expect(wsInstances).toHaveLength(3); + await vi.advanceTimersByTimeAsync(1); + expect(wsInstances).toHaveLength(4); + expect(relay.disconnected).toHaveBeenCalledWith(undefined, "session"); + }); + + it("认证后能力声明迟迟不答复也会超时关闭", async () => { + vi.useFakeTimers(); + const ws = await completeSessionHandshake(); + declareCapabilities(); + await vi.advanceTimersByTimeAsync(LIMITS.authTimeoutMs + 1); + expect(ws.readyState).toBe(MockWebSocket.CLOSED); + expect(relay.established).not.toHaveBeenCalled(); + }); + it("业务请求和 $/cancelRequest 转发给 SW", async () => { const ws = await completeSessionHandshake(); ws.simulateMessage({ diff --git a/src/app/service/offscreen/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts index 566889276..e4d950ee3 100644 --- a/src/app/service/offscreen/external-access-connect.ts +++ b/src/app/service/offscreen/external-access-connect.ts @@ -10,6 +10,7 @@ import type { AuthMode, AuthOkPayload, AuthResponsePayload, + ExternalAccessRejectionReason, WSEnvelope, } from "../service_worker/external_access/types"; @@ -174,6 +175,10 @@ export class ExternalAccessConnect { private epoch = 0; private currentParams: ExternalAccessConnectParam | null = null; private handshakeComplete = false; + private opened = false; + private capabilitiesId?: string; + private pairedKey?: string; + private conflict = false; // 已发出认证响应、等待 $session.authenticated 校验期间保留的握手素材。 private pendingVerify: { verifyKey: Bytes; nonceD: string; nonceE: string; mode: AuthMode; enc?: Bytes } | null = null; @@ -207,7 +212,6 @@ export class ExternalAccessConnect { private startSession(params: ExternalAccessConnectParam): void { this.dispose(); this.currentParams = params; - this.epoch++; this.connect(this.epoch); } @@ -226,22 +230,39 @@ export class ExternalAccessConnect { }, CONFIG.CONNECT_TIMEOUT); this.ws.onopen = () => this.handleOpen(sessionEpoch); - this.ws.onmessage = (ev) => void this.handleMessage(ev, sessionEpoch); - this.ws.onclose = () => this.handleClose(sessionEpoch); + // daemon 可连续发 authenticated 与 hello。按线上的顺序完成异步密码校验,再处理后续帧和关闭事件。 + let frames: Promise | undefined; + this.ws.onmessage = (ev) => { + const processing = (frames ?? Promise.resolve()) + .then(() => this.handleMessage(ev, sessionEpoch)) + .catch((error: unknown) => { + this.logger.warn("Failed to process WebSocket frame", Logger.E(error)); + if (sessionEpoch === this.epoch) this.ws?.close(); + }); + frames = processing; + void processing.then(() => { + if (frames === processing) frames = undefined; + }); + }; + this.ws.onclose = ({ code }) => { + if (frames) void frames.then(() => this.handleClose(sessionEpoch, code)); + else this.handleClose(sessionEpoch, code); + }; this.ws.onerror = (ev) => this.handleError(ev, sessionEpoch); } catch (e) { this.logger.error("WebSocket creation failed", Logger.E(e)); - this.handleError(e, sessionEpoch); + this.handleClose(sessionEpoch, 1006); } } private handleOpen(sessionEpoch: number): void { if (sessionEpoch !== this.epoch) return; + this.opened = true; this.logger.info("WebSocket connected, awaiting $session.authenticate"); this.clearTimer("connectTimeoutTimer"); - // 握手必须在 authTimeoutMs 内完成,否则断开重连(PROTOCOL §3)。 + // 认证与能力声明必须在 authTimeoutMs 内完成,否则接入/改名调用可能永不返回。 this.authTimeoutTimer = setTimeout(() => { - if (sessionEpoch === this.epoch && !this.handshakeComplete) { + if (sessionEpoch === this.epoch) { this.logger.warn("Auth handshake timeout"); this.ws?.close(); } @@ -266,6 +287,22 @@ export class ExternalAccessConnect { return; } + if (!envelope.method && envelope.id && envelope.id === this.capabilitiesId) { + this.capabilitiesId = undefined; + if (envelope.error) { + this.conflict = envelope.error.data?.code === "CONFLICT"; + this.ws?.close(); + } else { + const key = this.pairedKey; + if (key && this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } }; + this.pairedKey = undefined; + this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; + this.clearTimer("authTimeoutTimer"); + await this.relay.established(key); + } + return; + } + switch (envelope.method) { case "$session.ping": if (envelope.id) this.rawSend({ jsonrpc: "2.0", id: envelope.id, result: {} }); @@ -332,7 +369,7 @@ export class ExternalAccessConnect { return; } - // 配对模式:解密 daemon 下发的长期密钥 K,持久化交给 SW,并把自身切到会话模式供后续重连。 + // daemon 在接受名称之前已消费配对码并下发密钥;名称冲突时这把密钥不能落盘。 if (pending.mode === "pairing") { if (!pending.enc || !payload.key) { this.logger.warn("pairing $session.authenticated missing key, closing"); @@ -341,14 +378,11 @@ export class ExternalAccessConnect { } const key = await decryptLongTermKey(pending.enc, payload.key.iv, payload.key.ciphertext); if (sessionEpoch !== this.epoch) return; - if (this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } }; - void this.relay.paired(key); + this.pairedKey = key; } this.pendingVerify = null; this.handshakeComplete = true; - this.clearTimer("authTimeoutTimer"); - this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; this.logger.info("Auth handshake complete"); } @@ -359,6 +393,7 @@ export class ExternalAccessConnect { this.logger.warn("Dropped outbound JSON-RPC message before handshake", { method: envelope.method }); return; } + if (envelope.method === "$session.capabilities") this.capabilitiesId = envelope.id; this.rawSend(envelope); } @@ -368,16 +403,19 @@ export class ExternalAccessConnect { } } - private handleClose(sessionEpoch: number): void { - if (sessionEpoch !== this.epoch) return; - this.clearTimer("connectTimeoutTimer"); - this.clearTimer("authTimeoutTimer"); + private handleClose(sessionEpoch: number, code: number): void { + if (sessionEpoch !== this.epoch || !this.currentParams) return; + const reason: ExternalAccessRejectionReason | undefined = this.conflict + ? "name-taken" + : this.opened && !this.handshakeComplete && code === 1008 + ? "refused" + : undefined; + const mode = this.currentParams.auth.mode; this.ws = null; - this.handshakeComplete = false; - this.pendingVerify = null; - this.logger.debug("WebSocket connection closed"); - void this.relay.disconnected(); - this.scheduleReconnect(); + this.dispose(); + if (reason) this.currentParams = null; + void this.relay.disconnected(reason, mode); + if (!reason) this.scheduleReconnect(); } private handleError(ev: Event | Error | unknown, sessionEpoch: number): void { @@ -386,7 +424,7 @@ export class ExternalAccessConnect { event: ev instanceof Event ? ev.type : undefined, error: ev instanceof Error ? ev.message : String(ev), }); - this.scheduleReconnect(); + // 浏览器会紧接着发 close,只有 close 才能区分网络错误与 1008 拒绝。 } private scheduleReconnect(): void { @@ -410,6 +448,11 @@ export class ExternalAccessConnect { } private dispose(): void { + this.epoch++; + this.opened = false; + this.capabilitiesId = undefined; + this.pairedKey = undefined; + this.conflict = false; this.clearTimer("reconnectTimer"); this.clearTimer("connectTimeoutTimer"); this.clearTimer("authTimeoutTimer"); diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 0f6a0aa66..27ca0a303 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -47,7 +47,7 @@ import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { type TSetValuesParams } from "./value"; import type { LocalBackupExport } from "./synchronize"; import type { ExternalAccessUIService } from "./external_access/service"; -import type { WSEnvelope } from "./external_access/types"; +import type { AuthMode, ExternalAccessRejectionReason, WSEnvelope } from "./external_access/types"; import type { NetworkRuleMutationResult, NetworkRuleCreateInput, @@ -662,13 +662,17 @@ export class ExternalAccessClient extends Client { super(msgSender, "serviceWorker/externalAccess"); } - getBridgeStatus(): Promise> { + getBridgeStatus(): ReturnType { return this.doThrow("status"); } // Enrollment (接入): dial the daemon with the one-time code the user read from `sctl connect`. - enroll(code: string) { - return this.do("enroll", code); + enroll(code: string, name: string): ReturnType { + return this.doThrow("enroll", { code, name }); + } + + rename(name: string): ReturnType { + return this.doThrow("rename", name); } getOperation(operationId: string): ReturnType { @@ -714,11 +718,11 @@ export class ExternalAccessConnectRelayClient extends Client { return this.do("envelope", envelope); } - paired(key: string): Promise { - return this.do("paired", { key }); + established(key?: string): Promise { + return this.do("established", { key }); } - disconnected(): Promise { - return this.do("disconnected"); + disconnected(reason: ExternalAccessRejectionReason | undefined, mode: AuthMode): Promise { + return this.do("disconnected", { reason, mode }); } } diff --git a/src/app/service/service_worker/external_access/controller.test.ts b/src/app/service/service_worker/external_access/controller.test.ts index 81963fb04..bbd776c98 100644 --- a/src/app/service/service_worker/external_access/controller.test.ts +++ b/src/app/service/service_worker/external_access/controller.test.ts @@ -13,8 +13,9 @@ function makeFakeGroup() { return { group: { on: (name: string, fn: (params: any) => any) => (handlers[name] = fn) } as any, relayEnvelope: (env: WSEnvelope) => handlers["envelope"](env), - relayPaired: (key: string) => handlers["paired"]({ key }), - relayDisconnected: () => handlers["disconnected"](undefined), + established: (key?: string) => handlers["established"]({ key }), + relayDisconnected: (reason?: "refused" | "name-taken", mode = "session") => + handlers["disconnected"]({ reason, mode }), }; } @@ -78,14 +79,14 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { const controller = makeController(); await controller.initialize(); expect(connectClient.connect).not.toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); it("已启用但未接入时不拨号,状态为 pending_enrollment", async () => { const controller = makeController(); await controller.initialize(); systemConfig.setExternalAccessEnabled(true); - await vi.waitFor(() => expect(controller.getStatus().status).toBe("pending_enrollment")); + await vi.waitFor(async () => expect((await controller.getStatus()).status).toBe("pending_enrollment")); expect(connectClient.connect).not.toHaveBeenCalled(); }); @@ -116,28 +117,58 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { systemConfig.setExternalAccessEnabled(true); const controller = await initEnrolled("abc123"); expect(connectClient.connect).not.toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); it("enroll(code) 不以配对模式拨号", async () => { const controller = makeController(); await controller.initialize(); - await controller.enroll("PAIR-CODE"); + await controller.enroll("PAIR-CODE", "private"); expect(connectClient.connect).not.toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); }); + it.each(["", "Bad", " ", "a".repeat(33), "a_b", "工作"])("无效名称 %j 不拨号、不改名", async (invalid) => { + const controller = makeController(); + await controller.initialize(); + const { name } = await systemConfig.getExternalAccessIdentity(); + expect(await controller.enroll("code", invalid)).toBe("invalid-name"); + expect(await controller.rename(invalid)).toBe("invalid-name"); + expect(connectClient.connect).not.toHaveBeenCalled(); + expect((await controller.getStatus()).name).toBe(name); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(name); + }); + + it("未连接时改名保存到下一次连接", async () => { + const controller = makeController(); + await controller.initialize(); + const identity = await systemConfig.getExternalAccessIdentity(); + expect(await controller.rename("work")).toBe("applies-next-connection"); + expect(await systemConfig.getExternalAccessIdentity()).toEqual({ ...identity, name: "work" }); + expect((await controller.getStatus()).name).toBe("work"); + expect(connectClient.connect).not.toHaveBeenCalled(); + systemConfig.setExternalAccessPairing({ key: "test-key" }); + systemConfig.setExternalAccessEnabled(true); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + expect(connectClient.send.mock.lastCall?.[0].params.peer.name).toBe("work"); + }); + it("enroll(code) 以配对模式拨号", async () => { const controller = makeController(); await controller.initialize(); - await controller.enroll("PAIR-CODE"); + const result = controller.enroll("PAIR-CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalled()); expect(connectClient.connect).toHaveBeenCalledWith({ url: DEFAULT_URL, auth: { mode: "pairing", code: "PAIR-CODE" }, instanceId: (await systemConfig.getExternalAccessIdentity()).instanceId, }); - expect(controller.getStatus().status).toBe("connecting"); + expect(await controller.getStatus()).toMatchObject({ status: "connecting", name: "work" }); + await fake.established("newkey"); + expect(await result).toBe("ok"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "newkey" }); }); it.each(["Chrome", "Edge", "Firefox", "Safari", undefined])("能力声明包含名称与 %s 产品信息", async (product) => { @@ -183,14 +214,85 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { expect(peer).not.toHaveProperty("productVersion"); }); - it("接入成功(paired)时持久化长期密钥 K", async () => { - const controller = makeController(); - await controller.initialize(); - fake.relayPaired("newkey"); - await vi.waitFor(async () => expect((await systemConfig.getExternalAccessPairing()).key).toBe("newkey")); + it("被拒后重新接入时名称冲突要求新配对码,并保留原密钥", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + const publish = vi.spyOn(mq, "publish"); + await fake.relayDisconnected("refused"); + const rejected = await controller.getStatus(); + expect(rejected).toMatchObject({ status: "rejected", reason: "refused" }); + await vi.waitFor(() => expect(publish).toHaveBeenCalledWith("mcpStatusChanged", rejected)); + const result = controller.enroll("CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + expect((await controller.getStatus()).status).toBe("connecting"); + await fake.relayDisconnected("name-taken", "pairing"); + expect(await result).toBe("name-taken"); + expect((await controller.getStatus()).status).toBe("pending_enrollment"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "deadbeef" }); + const restarted = makeController(); + await restarted.initialize(); + expect((await restarted.getStatus()).status).toBe("pending_enrollment"); + expect(connectClient.connect).toHaveBeenCalledTimes(2); + }); + + it("停止接入优先于尚未落盘的拒绝通知", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + const disconnected = fake.relayDisconnected("refused"); + controller.stop(); + await disconnected; + expect((await controller.getStatus()).status).toBe("disabled"); + }); + + it("SW 重新启动不会自动重试已拒绝的实例", async () => { + systemConfig.setExternalAccessEnabled(true); + await initEnrolled(); + await fake.relayDisconnected("refused"); + const restarted = makeController(); + await restarted.initialize(); + expect(await restarted.getStatus()).toMatchObject({ status: "rejected", reason: "refused" }); + expect(connectClient.connect).toHaveBeenCalledTimes(1); + }); + + it.each(["toggle", "rename"])("被拒后 %s 重新拨号", async (action) => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + await fake.relayDisconnected("name-taken"); + expect(await controller.getStatus()).toMatchObject({ status: "rejected", reason: "name-taken" }); + if (action === "toggle") { + systemConfig.setExternalAccessEnabled(false); + await vi.waitFor(async () => expect((await controller.getStatus()).status).toBe("disabled")); + systemConfig.setExternalAccessEnabled(true); + } else expect(await controller.rename("work")).toBe("applies-next-connection"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + expect((await controller.getStatus()).status).toBe("connecting"); + }); + + it.each([false, true])("连接时改名以能力声明确认结果,冲突=%s", async (conflict) => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + await fake.established(); + const old = (await controller.getStatus()).name; + const result = controller.rename("work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + const hello = { jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } } as const; + await fake.relayEnvelope(hello); + expect(connectClient.send.mock.lastCall?.[0].params.peer.name).toBe("work"); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(old); + if (conflict) { + await fake.relayDisconnected("name-taken"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(3)); + await fake.relayEnvelope(hello); + expect(connectClient.send.mock.lastCall?.[0].params.peer.name).toBe(old); + } + await fake.established(); + expect(await result).toBe(conflict ? "name-taken" : "ok"); + expect(await controller.getStatus()).toMatchObject({ status: "connected", name: conflict ? old : "work" }); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(conflict ? old : "work"); }); it("hello 只展示 daemon 版本,不按产品版本阻断兼容的 bridge 调用", async () => { + systemConfig.setExternalAccessEnabled(true); const controller = await initEnrolled(); await fake.relayEnvelope({ jsonrpc: "2.0", @@ -198,14 +300,24 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { params: { daemonVersion: "0.1.0" }, }); // hello 携带 daemonVersion,状态条据此显示「sctl v{daemonVersion}」。 - expect(controller.getStatus()).toEqual({ status: "connected", daemonVersion: "0.1.0" }); + await fake.established(); + expect(await controller.getStatus()).toEqual({ + status: "connected", + daemonVersion: "0.1.0", + name: (await systemConfig.getExternalAccessIdentity()).name, + }); await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.0.1" }, }); - expect(controller.getStatus()).toEqual({ status: "connected", daemonVersion: "0.0.1" }); + await fake.established(); + expect(await controller.getStatus()).toEqual({ + status: "connected", + daemonVersion: "0.0.1", + name: (await systemConfig.getExternalAccessIdentity()).name, + }); fake.relayEnvelope({ jsonrpc: "2.0", id: "req", @@ -262,16 +374,22 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { }); it("socket 断开时状态转为 host_unreachable 并清除 sctl 版本", async () => { + systemConfig.setExternalAccessEnabled(true); const controller = await initEnrolled(); await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, }); - fake.relayDisconnected(); + await fake.established(); + await fake.relayDisconnected(); expect(bridgeCancel).toHaveBeenCalledWith(); - // 断开后不再连接,之前 hello 报告的版本不应残留。 - expect(controller.getStatus()).toEqual({ status: "host_unreachable", daemonVersion: undefined }); + // 重试期间之前 hello 报告的版本不应残留。 + expect(await controller.getStatus()).toEqual({ + status: "host_unreachable", + daemonVersion: undefined, + name: (await systemConfig.getExternalAccessIdentity()).name, + }); }); it("stop() 发送 shutdown、断开并置为 disabled", async () => { @@ -282,6 +400,6 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { expect.objectContaining({ jsonrpc: "2.0", method: "$session.shutdown" }) ); expect(connectClient.disconnect).toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); }); diff --git a/src/app/service/service_worker/external_access/controller.ts b/src/app/service/service_worker/external_access/controller.ts index 746bac969..274439e1a 100644 --- a/src/app/service/service_worker/external_access/controller.ts +++ b/src/app/service/service_worker/external_access/controller.ts @@ -6,6 +6,10 @@ import type { ExternalAccessConnectClient } from "../../offscreen/client"; import type { ExternalAccessBridge } from "./bridge"; import { type HelloPayload, + type AuthMode, + type ExternalAccessEnrollResult, + type ExternalAccessRenameResult, + type ExternalAccessRejectionReason, type ExternalAccessBridgeRequest, type ExternalAccessBridgeResponse, type ExternalAccessBridgeStatus, @@ -20,8 +24,13 @@ function truncatePeerText(text: string): string { return text.slice(0, read); } +function validName(name: string): boolean { + return typeof name === "string" && /^[a-z0-9-]{1,32}$/.test(name); +} + // Broadcast on every status transition so the Tools settings page updates live. export const ExternalAccessStatusChanged = "mcpStatusChanged"; +const REJECTION_KEY = "externalAccessRejection"; // The subset of the offscreen WS driver ExternalAccessController needs: open/close the socket and push // outbound envelopes onto the wire. The socket itself, plus the auth handshake and reconnect @@ -37,14 +46,17 @@ type ConnectDriver = Pick; + private generation = 0; + private reason?: ExternalAccessRejectionReason; + private pending?: { resolve: (result: ExternalAccessEnrollResult) => void; renameTo?: string; rollback?: boolean }; constructor( private readonly systemConfig: SystemConfig, @@ -58,8 +70,10 @@ export class ExternalAccessController { // Offscreen relays every decoded business envelope (plus the newly enrolled key and socket // disconnects) back here; register before anything can arrive. this.group.on("envelope", (envelope: WSEnvelope) => this.onEnvelope(envelope)); - this.group.on("paired", (payload: { key: string }) => this.onEnrolled(payload.key)); - this.group.on("disconnected", () => this.onDisconnected()); + this.group.on("established", ({ key }: { key?: string }) => this.onEstablished(key)); + this.group.on("disconnected", ({ reason, mode }: { reason?: ExternalAccessRejectionReason; mode: AuthMode }) => + this.onDisconnected(reason, mode) + ); // split 模式下隐身进程与普通进程共用 chrome.storage.local 里的密钥和脚本,由普通进程代表这份安装连接 sctl; // 隐身进程再连只会与普通进程互相顶替(sctl#14)。 @@ -73,6 +87,17 @@ export class ExternalAccessController { } }); if (await this.systemConfig.getExternalAccessEnabled()) { + // WS 关闭后 SW 可能休眠;拒绝结论需跨 SW 唤醒保留,但不跨浏览器会话。 + const saved = await chrome.storage.session.get(REJECTION_KEY); + const reason = saved[REJECTION_KEY]; + if (reason === "pending_enrollment") { + this.setStatus("pending_enrollment"); + return; + } + if (reason === "refused" || reason === "name-taken") { + this.setStatus("rejected", reason); + return; + } await this.connect(); } } @@ -80,10 +105,19 @@ export class ExternalAccessController { // Session-mode connect using the stored long-term key. No-op if already driving a connection. // `active` is set synchronously before the first await so a re-fired external_access_enabled listener can't // race a second dial through the guard. Enabled-but-unenrolled parks in "pending_enrollment". - private async connect(): Promise { - if (this.active) return; + private connect(): Promise { + if (this.active) return this.connecting ?? Promise.resolve(); this.active = true; + const connecting = this.dialSession(); + this.connecting = connecting; + return connecting; + } + + private async dialSession(): Promise { + const generation = ++this.generation; + await chrome.storage.session.remove(REJECTION_KEY); const pairing = await this.systemConfig.getExternalAccessPairing(); + if (generation !== this.generation) return; if (!pairing.key) { // Enabled but never enrolled — nothing to authenticate with; release so a later enroll() dials. this.active = false; @@ -93,20 +127,65 @@ export class ExternalAccessController { this.setStatus("connecting"); const url = await this.systemConfig.getExternalAccessUrl(); const { instanceId } = await this.systemConfig.getExternalAccessIdentity(); - void this.connectClient.connect({ url, instanceId, auth: { mode: "session", key: pairing.key } }); + if (generation === this.generation) + await this.connectClient.connect({ url, instanceId, auth: { mode: "session", key: pairing.key } }); } // Enrollment (接入): the user ran `sctl connect`, which printed a one-time code C shown only in the // terminal (never over the wire), and typed it into the enrollment dialog. We connect in // pairing mode with C; on success the daemon ships a fresh long-term key K, which offscreen - // decrypts and relays back via `onEnrolled`. - async enroll(code: string): Promise { - if (chrome.extension.inIncognitoContext) return; + // decrypts and relays back after capabilities acceptance. + async enroll(code: string, name: string): Promise { + if (!validName(name)) return "invalid-name"; + if (chrome.extension.inIncognitoContext || this.pending) return "not-connected"; this.active = true; - this.setStatus("connecting"); - const url = await this.systemConfig.getExternalAccessUrl(); - const { instanceId } = await this.systemConfig.getExternalAccessIdentity(); - void this.connectClient.connect({ url, instanceId, auth: { mode: "pairing", code } }); + const generation = ++this.generation; + const result = this.beginOperation(); + try { + await chrome.storage.session.remove(REJECTION_KEY); + const { instanceId } = await this.systemConfig.setExternalAccessName(name); + const url = await this.systemConfig.getExternalAccessUrl(); + if (generation === this.generation) { + this.setStatus("connecting"); + await this.connectClient.connect({ url, instanceId, auth: { mode: "pairing", code } }); + } + } catch (error) { + if (generation === this.generation) await this.onDisconnected(undefined, "pairing"); + throw error; + } + return result; + } + + async rename(name: string): Promise { + if (!validName(name)) return "invalid-name"; + if (chrome.extension.inIncognitoContext || this.pending) return "not-connected"; + if (this.status !== "connected") { + await this.systemConfig.setExternalAccessName(name); + if (this.status === "rejected") await this.connect(); + else this.setStatus(this.status); + return "applies-next-connection"; + } + const result = this.beginOperation(name); + this.active = false; + void this.bridge.cancel(); + try { + await this.connect(); + } catch (error) { + await this.onDisconnected(undefined, "session"); + throw error; + } + return result; + } + + private beginOperation(renameTo?: string): Promise { + return new Promise((resolve) => { + this.pending = { resolve, renameTo }; + }); + } + + private finishOperation(result: ExternalAccessEnrollResult): void { + this.pending?.resolve(result); + this.pending = undefined; } private async onEnvelope(envelope: WSEnvelope): Promise { @@ -114,8 +193,7 @@ export class ExternalAccessController { case "$session.hello": { const { daemonVersion } = envelope.params as HelloPayload; this.daemonVersion = daemonVersion; - this.setStatus("connected"); - const { name } = await this.systemConfig.getExternalAccessIdentity(); + const name = this.pending?.renameTo ?? (await this.systemConfig.getExternalAccessIdentity()).name; const product = externalAccessProduct(); const version = getBrowserVersion(); void this.connectClient.send({ @@ -157,14 +235,45 @@ export class ExternalAccessController { } } - private onEnrolled(key: string): void { - this.systemConfig.setExternalAccessPairing({ key }); + private async onEstablished(key?: string): Promise { + if (!this.active) return; + const pending = this.pending; + if (key) this.systemConfig.setExternalAccessPairing({ key }); + if (pending?.renameTo) { + try { + await this.systemConfig.setExternalAccessName(pending.renameTo); + } catch (error) { + this.finishOperation("not-connected"); + throw error; + } + if (this.pending !== pending) return; + } + this.setStatus("connected"); + this.finishOperation(pending?.rollback ? "name-taken" : "ok"); } - private onDisconnected(): void { - if (this.status === "disabled") return; // user-initiated stop(), not a failure to recover from + private async onDisconnected(reason: ExternalAccessRejectionReason | undefined, mode: AuthMode): Promise { + if (!this.active) return; void this.bridge.cancel(); - this.setStatus("host_unreachable"); + if (!reason) { + this.setStatus("host_unreachable"); + this.finishOperation("not-connected"); + return; + } + this.active = false; + if (reason === "name-taken" && this.pending?.renameTo) { + // 新名称尚未落盘;再次声明持久化的原名,恢复成功后再答复调用者。 + this.pending.renameTo = undefined; + this.pending.rollback = true; + await this.connect(); + return; + } + const pairingConflict = mode === "pairing" && reason === "name-taken"; + const generation = this.generation; + await chrome.storage.session.set({ [REJECTION_KEY]: pairingConflict ? "pending_enrollment" : reason }); + if (generation !== this.generation) return; + this.setStatus(pairingConflict ? "pending_enrollment" : "rejected", pairingConflict ? undefined : reason); + this.finishOperation(reason === "name-taken" ? reason : "not-connected"); } private async dispatchBridgeRequest(request: ExternalAccessBridgeRequest): Promise { @@ -199,6 +308,9 @@ export class ExternalAccessController { // User disable, or "停止外部接入" kill switch (the caller additionally discards K + clears the // session-allow store, forcing a re-enrollment). stop(): void { + this.generation++; + void chrome.storage.session.remove(REJECTION_KEY); + this.finishOperation("not-connected"); this.active = false; void this.bridge.cancel(); void this.connectClient.send({ jsonrpc: "2.0", method: "$session.shutdown", params: {} }); @@ -206,19 +318,17 @@ export class ExternalAccessController { this.setStatus("disabled"); } - getStatus(): ExternalAccessBridgeStatusInfo { - return this.statusInfo(); - } - - // The daemon version is only meaningful while a connection is live; gating it on the connected - // states keeps a stale version from leaking through the status bar after a disconnect/stop. - private statusInfo(): ExternalAccessBridgeStatusInfo { - const live = this.status === "connected"; - return { status: this.status, daemonVersion: live ? this.daemonVersion : undefined }; + async getStatus(): Promise { + const status = this.status; + const reason = this.reason; + const daemonVersion = status === "connected" ? this.daemonVersion : undefined; + const { name } = await this.systemConfig.getExternalAccessIdentity(); + return { status, reason, daemonVersion, name }; } - private setStatus(status: ExternalAccessBridgeStatus): void { + private setStatus(status: ExternalAccessBridgeStatus, reason?: ExternalAccessRejectionReason): void { this.status = status; - this.mq.publish(ExternalAccessStatusChanged, this.statusInfo()); + this.reason = reason; + void this.getStatus().then((info) => this.mq.publish(ExternalAccessStatusChanged, info)); } } diff --git a/src/app/service/service_worker/external_access/service.test.ts b/src/app/service/service_worker/external_access/service.test.ts index 8066fc111..12b0c451c 100644 --- a/src/app/service/service_worker/external_access/service.test.ts +++ b/src/app/service/service_worker/external_access/service.test.ts @@ -14,6 +14,7 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { let controller: { getStatus: ReturnType; enroll: ReturnType; + rename: ReturnType; stop: ReturnType; }; let systemConfig: SystemConfig; @@ -34,8 +35,9 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { new TempStorageDAO() ); controller = { - getStatus: vi.fn().mockReturnValue({ status: "connected", daemonVersion: "1.0.0" }), - enroll: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ status: "connected", daemonVersion: "1.0.0" }), + enroll: vi.fn().mockResolvedValue("name-taken"), + rename: vi.fn().mockResolvedValue("applies-next-connection"), stop: vi.fn(), }; systemConfig = new SystemConfig(new MessageQueue()); @@ -53,19 +55,22 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { "operationDecision", "operationReopen", "pendingOperations", + "rename", "status", "stopExternalAccess", ].sort() ); }); - it("status 返回控制器状态(含 sctl 版本)", () => { - expect(handlers["status"]()).toEqual({ status: "connected", daemonVersion: "1.0.0" }); + it("status 返回控制器状态(含 sctl 版本)", async () => { + expect(await handlers["status"]()).toEqual({ status: "connected", daemonVersion: "1.0.0" }); }); - it("enroll 转发配对码给控制器", () => { - handlers["enroll"]("CODE-1234"); - expect(controller.enroll).toHaveBeenCalledWith("CODE-1234"); + it("接入与改名返回控制器的结果", async () => { + expect(await handlers["enroll"]({ code: "CODE-1234", name: "work" })).toBe("name-taken"); + expect(controller.enroll).toHaveBeenCalledWith("CODE-1234", "work"); + expect(await handlers["rename"]("work")).toBe("applies-next-connection"); + expect(controller.rename).toHaveBeenCalledWith("work"); }); it("operationDecision 透传 enable / rememberSession 给 approval.decide", () => { diff --git a/src/app/service/service_worker/external_access/service.ts b/src/app/service/service_worker/external_access/service.ts index 68eb00bdd..af8f56bee 100644 --- a/src/app/service/service_worker/external_access/service.ts +++ b/src/app/service/service_worker/external_access/service.ts @@ -2,14 +2,21 @@ import type { Group } from "@Packages/message/server"; import type { SystemConfig } from "@App/pkg/config/config"; import type { ExternalAccessOperation } from "@App/app/repo/external_access"; import type { ExternalAccessApprovalService } from "./approval"; -import type { OperationStatusResult, ExternalAccessBridgeStatusInfo, PendingOperationSummary } from "./types"; +import type { + ExternalAccessEnrollResult, + ExternalAccessRenameResult, + OperationStatusResult, + ExternalAccessBridgeStatusInfo, + PendingOperationSummary, +} from "./types"; // Narrow surface ExternalAccessUIService needs from ExternalAccessController — flat trust leaves only the status read and // the enrollment dial; the settings page reads/writes the two policies + external_access_enabled directly via // the generic SystemConfig plumbing. export interface ExternalAccessControllerFacade { - getStatus(): ExternalAccessBridgeStatusInfo; - enroll(code: string): void; + getStatus(): Promise; + enroll(code: string, name: string): Promise; + rename(name: string): Promise; stop(): void; } @@ -30,6 +37,7 @@ export class ExternalAccessUIService { init(): void { this.group.on("status", this.getStatus.bind(this)); this.group.on("enroll", this.enroll.bind(this)); + this.group.on("rename", this.rename.bind(this)); this.group.on("operation", this.getOperation.bind(this)); this.group.on("operationDecision", this.decideOperation.bind(this)); this.group.on("operationReopen", this.reopenOperation.bind(this)); @@ -40,11 +48,15 @@ export class ExternalAccessUIService { // Enrollment (接入): the user ran `sctl connect` and typed its one-time terminal code into the // dialog. Dials the daemon in pairing mode; on success the daemon ships the long-term key K and // the controller persists it — CLI + every MCP client then inherit that single enrollment. - enroll(code: string): void { - this.controller.enroll(code); + enroll({ code, name }: { code: string; name: string }): Promise { + return this.controller.enroll(code, name); } - getStatus(): ExternalAccessBridgeStatusInfo { + rename(name: string): Promise { + return this.controller.rename(name); + } + + getStatus(): Promise { return this.controller.getStatus(); } diff --git a/src/app/service/service_worker/external_access/types.ts b/src/app/service/service_worker/external_access/types.ts index af67b9d65..d52aa1c98 100644 --- a/src/app/service/service_worker/external_access/types.ts +++ b/src/app/service/service_worker/external_access/types.ts @@ -283,12 +283,22 @@ export type ExternalAccessBridgeStatus = | "pending_enrollment" | "connecting" | "connected" - | "host_unreachable"; - -// getStatus / ExternalAccessStatusChanged payload: the bare status plus the daemon version reported -// by the hello handshake. daemonVersion is only carried while connected; the status bar renders it -// as "sctl v{daemonVersion}". + | "host_unreachable" + | "rejected"; + +export type ExternalAccessRejectionReason = "refused" | "name-taken"; +// 接入时 name-taken 表示配对码已被消费,改名后需重新运行 sctl connect。 +// not-connected:连接失败/认证拒绝、操作被停止,或隐身/已有操作进行中而不能开始。 +// ok 在能力声明接受后返回;存储或内部消息错误仍通过 Promise rejection 传递。 +export type ExternalAccessEnrollResult = "ok" | "invalid-name" | "name-taken" | "not-connected"; +// 未连接时只保存名称;已连接时等 daemon 确认,冲突会用原名恢复连接。 +export type ExternalAccessRenameResult = ExternalAccessEnrollResult | "applies-next-connection"; + +// getStatus / ExternalAccessStatusChanged 共用。name 为当前保存的实例名称, +// reason 仅在 rejected 时携带;daemonVersion 仅在 connected 时展示。 export interface ExternalAccessBridgeStatusInfo { status: ExternalAccessBridgeStatus; + name: string; + reason?: ExternalAccessRejectionReason; daemonVersion?: string; } diff --git a/src/locales/de-DE/external_access.json b/src/locales/de-DE/external_access.json index d50871751..b0dee3463 100644 --- a/src/locales/de-DE/external_access.json +++ b/src/locales/de-DE/external_access.json @@ -3,6 +3,7 @@ "section_desc": "Erlaubt auf diesem Rechner laufenden Programmen – der sctl-CLI und KI-Clients über MCP – den Zugriff auf und die Steuerung deiner Skripte.", "enable_switch": "Externen Zugriff aktivieren", "status_off": "Aus", + "status_rejected": "Von sctl abgelehnt", "status_pending_enrollment": "Registrierung ausstehend", "status_connecting": "Verbinde …", "status_connected": "Verbunden", diff --git a/src/locales/en-US/external_access.json b/src/locales/en-US/external_access.json index b61350483..aca779fc6 100644 --- a/src/locales/en-US/external_access.json +++ b/src/locales/en-US/external_access.json @@ -3,6 +3,7 @@ "section_desc": "Let programs running on this machine — the sctl CLI and AI clients over MCP — connect to and control your scripts.", "enable_switch": "Enable External Access", "status_off": "Off", + "status_rejected": "Rejected by sctl", "status_pending_enrollment": "Pending enrollment", "status_connecting": "Connecting", "status_connected": "Connected", diff --git a/src/locales/ja-JP/external_access.json b/src/locales/ja-JP/external_access.json index 08af92c23..354b23ff0 100644 --- a/src/locales/ja-JP/external_access.json +++ b/src/locales/ja-JP/external_access.json @@ -3,6 +3,7 @@ "section_desc": "このマシンで動作するプログラム(sctl CLI と MCP 経由の AI クライアント)がスクリプトに接続して制御できるようにします。", "enable_switch": "外部アクセスを有効化", "status_off": "無効", + "status_rejected": "sctl により拒否されました", "status_pending_enrollment": "登録待ち", "status_connecting": "接続中", "status_connected": "接続済み", diff --git a/src/locales/ko-KR/external_access.json b/src/locales/ko-KR/external_access.json index 531adb908..e786739db 100644 --- a/src/locales/ko-KR/external_access.json +++ b/src/locales/ko-KR/external_access.json @@ -3,6 +3,7 @@ "section_desc": "이 컴퓨터에서 실행되는 프로그램(sctl CLI 및 MCP를 통한 AI 클라이언트)이 스크립트에 연결하여 제어할 수 있도록 합니다.", "enable_switch": "외부 접근 사용", "status_off": "꺼짐", + "status_rejected": "sctl에서 거부됨", "status_pending_enrollment": "등록 대기 중", "status_connecting": "연결 중", "status_connected": "연결됨", diff --git a/src/locales/pt-BR/external_access.json b/src/locales/pt-BR/external_access.json index 575d40a2c..dedc44cb7 100644 --- a/src/locales/pt-BR/external_access.json +++ b/src/locales/pt-BR/external_access.json @@ -3,6 +3,7 @@ "section_desc": "Permite que programas em execução nesta máquina — a CLI sctl e clientes de IA via MCP — conectem-se e controlem seus scripts.", "enable_switch": "Ativar acesso externo", "status_off": "Desligado", + "status_rejected": "Rejeitado pelo sctl", "status_pending_enrollment": "Aguardando vinculação", "status_connecting": "Conectando", "status_connected": "Conectado", diff --git a/src/locales/ru-RU/external_access.json b/src/locales/ru-RU/external_access.json index 972ece9ee..c7037a424 100644 --- a/src/locales/ru-RU/external_access.json +++ b/src/locales/ru-RU/external_access.json @@ -3,6 +3,7 @@ "section_desc": "Позволяет программам на этом компьютере — CLI sctl и ИИ-клиентам через MCP — подключаться к вашим скриптам и управлять ими.", "enable_switch": "Включить внешний доступ", "status_off": "Выключено", + "status_rejected": "Отклонено sctl", "status_pending_enrollment": "Ожидает подключения", "status_connecting": "Подключение…", "status_connected": "Подключено", diff --git a/src/locales/tr-TR/external_access.json b/src/locales/tr-TR/external_access.json index 9026fce72..b5a81ee5f 100644 --- a/src/locales/tr-TR/external_access.json +++ b/src/locales/tr-TR/external_access.json @@ -3,6 +3,7 @@ "section_desc": "Bu makinede çalışan programların — sctl CLI ve MCP üzerinden yapay zekâ istemcileri — betiklerinize bağlanmasına ve onları denetlemesine izin verir.", "enable_switch": "Harici Erişimi Etkinleştir", "status_off": "Kapalı", + "status_rejected": "sctl tarafından reddedildi", "status_pending_enrollment": "Eşleştirme bekleniyor", "status_connecting": "Bağlanıyor", "status_connected": "Bağlandı", diff --git a/src/locales/vi-VN/external_access.json b/src/locales/vi-VN/external_access.json index fa650861b..c59a46212 100644 --- a/src/locales/vi-VN/external_access.json +++ b/src/locales/vi-VN/external_access.json @@ -3,6 +3,7 @@ "section_desc": "Cho phép các chương trình chạy trên máy này — CLI sctl và ứng dụng AI qua MCP — kết nối và điều khiển các tập lệnh của bạn.", "enable_switch": "Bật truy cập bên ngoài", "status_off": "Tắt", + "status_rejected": "Bị sctl từ chối", "status_pending_enrollment": "Đang chờ ghép nối", "status_connecting": "Đang kết nối", "status_connected": "Đã kết nối", diff --git a/src/locales/zh-CN/external_access.json b/src/locales/zh-CN/external_access.json index b9a5a58a4..f717bffde 100644 --- a/src/locales/zh-CN/external_access.json +++ b/src/locales/zh-CN/external_access.json @@ -3,6 +3,7 @@ "section_desc": "让本机运行的程序(命令行 sctl 与经 MCP 的 AI 客户端)接入并控制你的脚本。", "enable_switch": "启用外部接入", "status_off": "未启用", + "status_rejected": "已被 sctl 拒绝", "status_pending_enrollment": "待接入", "status_connecting": "连接中", "status_connected": "已接入", diff --git a/src/locales/zh-TW/external_access.json b/src/locales/zh-TW/external_access.json index 9646ce778..038f6ffcb 100644 --- a/src/locales/zh-TW/external_access.json +++ b/src/locales/zh-TW/external_access.json @@ -3,6 +3,7 @@ "section_desc": "讓本機執行的程式(命令列 sctl 與經 MCP 的 AI 用戶端)接入並控制你的腳本。", "enable_switch": "啟用外部接入", "status_off": "未啟用", + "status_rejected": "已遭 sctl 拒絕", "status_pending_enrollment": "待接入", "status_connecting": "連線中", "status_connected": "已接入", diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx index 7b51fff61..d7562b92f 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx @@ -1,12 +1,11 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react"; +import type { ExternalAccessBridgeStatusInfo } from "@App/app/service/service_worker/external_access/types"; import { initTestLanguage } from "@Tests/initTestLanguage"; const { getBridgeStatus, enroll, stopExternalAccess } = vi.hoisted(() => ({ - getBridgeStatus: vi.fn( - (): Promise<{ status: string; daemonVersion?: string }> => Promise.resolve({ status: "connected" }) - ), - enroll: vi.fn(() => Promise.resolve()), + getBridgeStatus: vi.fn<() => Promise>(), + enroll: vi.fn((_code: string, _name: string) => Promise.resolve("ok" as const)), stopExternalAccess: vi.fn(() => Promise.resolve()), })); vi.mock("@App/pages/store/features/script", () => ({ @@ -66,7 +65,7 @@ beforeAll(() => initTestLanguage("zh-CN")); beforeEach(() => { vi.clearAllMocks(); get.mockResolvedValue(true); - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); getExternalAccessWritePolicy.mockResolvedValue("approval"); getExternalAccessSourceReadPolicy.mockResolvedValue("approval"); getExternalAccessUrl.mockResolvedValue("ws://localhost:8643"); @@ -80,7 +79,7 @@ async function renderSection() { describe("ExternalAccessSection(外部接入单卡片)", () => { it("已接入状态展示状态胶囊、策略、查看审计与停止外部接入", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); expect(await screen.findByTestId("external_access_status_pill")).toBeInTheDocument(); expect(screen.getByTestId("external_access_write_policy_approval")).toBeInTheDocument(); @@ -90,14 +89,14 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("待接入状态展示地址输入与「接入 sctl」按钮", async () => { - getBridgeStatus.mockResolvedValue({ status: "pending_enrollment" }); + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "work" }); await renderSection(); expect(await screen.findByTestId("external_access_url_input")).toBeInTheDocument(); expect(screen.getByTestId("external_access_enroll_open")).toBeInTheDocument(); }); it("提交配对码后调用 enroll 并显示进行中提示", async () => { - getBridgeStatus.mockResolvedValue({ status: "pending_enrollment" }); + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_enroll_open")); // 8 格 OTP 输入:粘贴整段配对码到第一格,一次填满 @@ -106,13 +105,13 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { await act(async () => { fireEvent.click(screen.getByTestId("external_access_enroll_submit")); }); - expect(enroll).toHaveBeenCalledWith("ABCD1234"); + expect(enroll).toHaveBeenCalledWith("ABCD1234", "home-browser"); expect(notify.info).toHaveBeenCalledWith("已提交配对码,正在接入 sctl…"); expect(notify.success).not.toHaveBeenCalled(); }); it("帮助入口与待接入文档入口深链到外部接入指南", async () => { - getBridgeStatus.mockResolvedValue({ status: "pending_enrollment" }); + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "work" }); await renderSection(); const expectedDocUrl = /^https:\/\/docs\.scriptcat\.org(?:\/en)?\/docs\/use\/external-access\/$/; @@ -127,13 +126,13 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("已接入状态在状态条显示 sctl 版本号", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected", daemonVersion: "0.3.1" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work", daemonVersion: "0.3.1" }); await renderSection(); expect(await screen.findByTestId("external_access_daemon_version")).toHaveTextContent("sctl v0.3.1"); }); it("写操作策略切到「直接允许」时写入配置并显示琥珀警示", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_write_policy_allow")); expect(setExternalAccessWritePolicy).toHaveBeenCalledWith("allow"); @@ -141,14 +140,14 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("源码读取策略切到「直接允许」时写入配置", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_source_policy_allow")); expect(setExternalAccessSourceReadPolicy).toHaveBeenCalledWith("allow"); }); it("查看审计日志深链到日志页并以 component=external-access 预过滤", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_view_audit")); expect(navigate).toHaveBeenCalledWith(expect.stringContaining("/logs?query=")); @@ -156,7 +155,7 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("关闭启用开关写入 external_access_enabled=false", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_enable_switch")); expect(set).toHaveBeenCalledWith("external_access_enabled", false); diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx index 7afe4ead9..78b455497 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx @@ -38,6 +38,7 @@ const STATUS_VARIANT: Record @@ -122,8 +124,8 @@ export function ExternalAccessSection({ register }: { register: (id: string) => const { t } = useTranslation(); const navigate = useNavigate(); const [enabled, setEnabled] = useState(false); - const [status, setStatus] = useState("disabled"); - const [daemonVersion, setDaemonVersion] = useState(); + const [statusInfo, setStatusInfo] = useState(); + const { status = "disabled", daemonVersion } = statusInfo ?? {}; const [writePolicy, setWritePolicy] = useState("approval"); const [sourcePolicy, setSourcePolicy] = useState("approval"); const [mcpUrl, setExternalAccessUrl] = useState(""); @@ -137,19 +139,13 @@ export function ExternalAccessSection({ register }: { register: (id: string) => void systemConfig.getExternalAccessUrl().then(setExternalAccessUrl); void externalAccessClient .getBridgeStatus() - .then((info) => { - setStatus(info.status); - setDaemonVersion(info.daemonVersion); - }) - .catch(() => setStatus("disabled")); + .then(setStatusInfo) + .catch(() => setStatusInfo(undefined)); }, []); // ExternalAccessController 的状态机在 SW 里推进(接入完成、hello 到达、socket 断开),页面订阅广播以实时更新。 useEffect(() => { - return subscribeMessage("mcpStatusChanged", (data) => { - setStatus(data.status); - setDaemonVersion(data.daemonVersion); - }); + return subscribeMessage("mcpStatusChanged", setStatusInfo); }, []); const handleEnableToggle = (checked: boolean) => { @@ -174,8 +170,8 @@ export function ExternalAccessSection({ register }: { register: (id: string) => const handleEnroll = async () => { const c = code.trim(); - if (!c) return; - await externalAccessClient.enroll(c); + if (!c || !statusInfo) return; + await externalAccessClient.enroll(c, statusInfo.name); setCode(""); setShowEnroll(false); // 此刻只是把配对码交给 daemon,握手成败未知(码错/sctl 未运行都会转「连接失败」),故是进行中而非成功。 @@ -323,7 +319,7 @@ export function ExternalAccessSection({ register }: { register: (id: string) => data-testid="external_access_retry" size="xs" variant="outline" - onClick={() => void externalAccessClient.getBridgeStatus().then((info) => setStatus(info.status))} + onClick={() => void externalAccessClient.getBridgeStatus().then(setStatusInfo)} > {t("external_access:retry")} diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 7d058f387..5f614d983 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -728,18 +728,32 @@ export class SystemConfig { return this._get("external_access_pairing", { key: "" }); } - async getExternalAccessIdentity(): Promise { + getExternalAccessIdentity(): Promise { + return this.externalAccessIdentity(); + } + + setExternalAccessName(name: string): Promise { + return this.externalAccessIdentity(name); + } + + private async externalAccessIdentity(name?: string): Promise { // 页面、后台及隐身上下文可能同时首次读取;跨上下文锁保证只生成一份身份。 // 身份没有历史同步数据,不能走普通配置的 sync → local 迁移路径。 return navigator.locks.request("scriptcat-external-access-identity", async () => { const key = "system_external_access_identity"; const stored = await chrome.storage.local.get(key); - if (stored[key]) return stored[key] as ExternalAccessIdentity; + if (stored[key]) { + const identity = stored[key] as ExternalAccessIdentity; + if (name === undefined || identity.name === name) return identity; + const renamed = { ...identity, name }; + await chrome.storage.local.set({ [key]: renamed }); + return renamed; + } const instanceId = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0") ).join(""); const slug = externalAccessProduct()?.toLowerCase() ?? "scriptcat"; - const identity = { instanceId, name: `${slug}-${instanceId.slice(0, 4)}` }; + const identity = { instanceId, name: name ?? `${slug}-${instanceId.slice(0, 4)}` }; await chrome.storage.local.set({ [key]: identity }); return identity; }); From 638a90b49c745029bf78bf35f78d89ac90b91574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 3 Nov 2026 14:45:11 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=A1=B5=EF=BC=9A=E6=98=BE=E7=A4=BA=E4=B8=8E=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=AE=9E=E4=BE=8B=E5=90=8D=E7=A7=B0=E3=80=81=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=97=B6=E5=91=BD=E5=90=8D=E3=80=81=E8=A2=AB=20sctl?= =?UTF-8?q?=20=E6=8B=92=E7=BB=9D=E6=97=B6=E8=AF=B4=E6=98=8E=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de-DE/external_access.json | 15 + src/locales/en-US/external_access.json | 15 + src/locales/ja-JP/external_access.json | 15 + src/locales/ko-KR/external_access.json | 15 + src/locales/pt-BR/external_access.json | 15 + src/locales/ru-RU/external_access.json | 15 + src/locales/tr-TR/external_access.json | 15 + src/locales/vi-VN/external_access.json | 15 + src/locales/zh-CN/external_access.json | 15 + src/locales/zh-TW/external_access.json | 15 + .../sections/ExternalAccessSection.test.tsx | 133 ++++++++- .../Tools/sections/ExternalAccessSection.tsx | 264 ++++++++++++++++-- 12 files changed, 527 insertions(+), 20 deletions(-) diff --git a/src/locales/de-DE/external_access.json b/src/locales/de-DE/external_access.json index b0dee3463..5186a3cca 100644 --- a/src/locales/de-DE/external_access.json +++ b/src/locales/de-DE/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "Externen Zugriff aktivieren", "status_off": "Aus", "status_rejected": "Von sctl abgelehnt", + "rejected_refused_reason": "sctl hat diese ScriptCat-Instanz abgelehnt – sie wurde möglicherweise in sctl vergessen (sctl scriptcats forget), oder sctl ist zu alt. Registriere sie erneut, um wieder eine Verbindung herzustellen.", + "rejected_name_taken_reason": "Dieser Name wird von einer anderen ScriptCat-Instanz verwendet. Ändere den Namen.", "status_pending_enrollment": "Registrierung ausstehend", "status_connecting": "Verbinde …", "status_connected": "Verbunden", "status_host_unreachable": "Verbindung fehlgeschlagen", "retry": "Erneut versuchen", "reenroll": "Neu registrieren", + "instance_name_label": "Instanzname", + "name_edit": "Bearbeiten", + "name_edit_action": "Namen ändern", + "name_save": "Speichern", + "name_invalid_hint": "Der Name muss aus 1–32 Kleinbuchstaben, Ziffern oder - bestehen.", + "name_taken": "Dieser Name wird bereits von einer anderen ScriptCat-Instanz verwendet.", + "rename_success": "Instanzname aktualisiert.", + "rename_next_connection": "Gespeichert – der neue Name gilt ab der nächsten Verbindung.", + "rename_not_connected": "Der Name kann gerade nicht geändert werden. Stelle sicher, dass sctl verbunden ist, und versuche es erneut.", + "rename_failed": "Der Name konnte nicht geändert werden. Versuche es erneut.", + "rename_saving": "Speichern …", + "enroll_name_taken": "Dieser Name wird bereits von einer anderen ScriptCat-Instanz verwendet. Dieser Kopplungscode wurde verbraucht – ändere den Namen, führe sctl connect erneut aus, um einen neuen Code zu erhalten, und sende ihn dann ab.", "address_label": "sctl-Adresse", "policy_title": "Berechtigungsrichtlinie", "policy_write": "Schreibvorgänge", @@ -43,6 +57,7 @@ "enroll_perm_write": "Installation · Aktivierung · Löschung anfragen (gemäß Schreibrichtlinie bestätigt)", "enroll_perm_source": "Skript-Quelltext lesen (gemäß Quelltextrichtlinie bestätigt)", "enroll_submit": "Registrieren", + "enroll_submitting": "Registrierung …", "cancel": "Abbrechen", "enroll_started": "Kopplungscode gesendet – Verbindung zu sctl wird hergestellt …", "confirm_via_external": "Über externen Zugriff ausgelöst", diff --git a/src/locales/en-US/external_access.json b/src/locales/en-US/external_access.json index aca779fc6..99072c60b 100644 --- a/src/locales/en-US/external_access.json +++ b/src/locales/en-US/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "Enable External Access", "status_off": "Off", "status_rejected": "Rejected by sctl", + "rejected_refused_reason": "sctl rejected this ScriptCat instance — it may have been forgotten in sctl (`sctl scriptcats forget`), or sctl may be too old. Re-enroll to connect again.", + "rejected_name_taken_reason": "This name is used by another ScriptCat instance. Change the name.", "status_pending_enrollment": "Pending enrollment", "status_connecting": "Connecting", "status_connected": "Connected", "status_host_unreachable": "Connection failed", "retry": "Retry", "reenroll": "Re-enroll", + "instance_name_label": "Instance name", + "name_edit": "Edit", + "name_edit_action": "Edit name", + "name_save": "Save", + "name_invalid_hint": "Use 1–32 lowercase letters, digits, or hyphens.", + "name_taken": "This name is already used by another ScriptCat instance.", + "rename_success": "Instance name updated.", + "rename_next_connection": "Saved — the new name takes effect on the next connection.", + "rename_not_connected": "Could not rename right now. Make sure sctl is connected and try again.", + "rename_failed": "Could not rename. Try again.", + "rename_saving": "Saving…", + "enroll_name_taken": "This name is already used by another ScriptCat instance. This pairing code has been used up — change the name, run `sctl connect` again for a new code, then submit it.", "address_label": "sctl address", "policy_title": "Permission policy", "policy_write": "Write operations", @@ -43,6 +57,7 @@ "enroll_perm_write": "Request install · enable · delete (confirmed per the write policy)", "enroll_perm_source": "Read script source (confirmed per the source policy)", "enroll_submit": "Enroll", + "enroll_submitting": "Enrolling…", "cancel": "Cancel", "enroll_started": "Pairing code submitted — connecting to sctl…", "confirm_via_external": "Triggered via External Access", diff --git a/src/locales/ja-JP/external_access.json b/src/locales/ja-JP/external_access.json index 354b23ff0..4146cb90a 100644 --- a/src/locales/ja-JP/external_access.json +++ b/src/locales/ja-JP/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "外部アクセスを有効化", "status_off": "無効", "status_rejected": "sctl により拒否されました", + "rejected_refused_reason": "sctl がこの ScriptCat インスタンスを拒否しました。sctl でこのインスタンスが忘れられている(sctl scriptcats forget)か、sctl が古い可能性があります。再登録して接続し直してください。", + "rejected_name_taken_reason": "この名前は別の ScriptCat インスタンスで使用されています。名前を変更してください。", "status_pending_enrollment": "登録待ち", "status_connecting": "接続中", "status_connected": "接続済み", "status_host_unreachable": "接続失敗", "retry": "再試行", "reenroll": "再登録", + "instance_name_label": "インスタンス名", + "name_edit": "変更", + "name_edit_action": "名前を変更", + "name_save": "保存", + "name_invalid_hint": "名前は 1〜32 文字の小文字英数字または - で指定してください。", + "name_taken": "この名前は別の ScriptCat インスタンスで使用されています。", + "rename_success": "インスタンス名を更新しました。", + "rename_next_connection": "保存しました。新しい名前は次回の接続時に有効になります。", + "rename_not_connected": "現在は名前を変更できません。sctl が接続されていることを確認して再試行してください。", + "rename_failed": "名前を変更できませんでした。もう一度お試しください。", + "rename_saving": "保存中…", + "enroll_name_taken": "この名前は別の ScriptCat インスタンスで使用されています。このペアリングコードは使用済みです。名前を変更し、sctl connect を再実行して新しいコードを取得してから送信してください。", "address_label": "sctl アドレス", "policy_title": "権限ポリシー", "policy_write": "書き込み操作", @@ -43,6 +57,7 @@ "enroll_perm_write": "インストール・有効化・削除の要求(書き込みポリシーに従って確認)", "enroll_perm_source": "スクリプトソースの読み取り(ソースポリシーに従って確認)", "enroll_submit": "登録", + "enroll_submitting": "登録中…", "cancel": "キャンセル", "enroll_started": "ペアリングコードを送信しました。sctl に接続しています…", "confirm_via_external": "外部アクセスによる要求", diff --git a/src/locales/ko-KR/external_access.json b/src/locales/ko-KR/external_access.json index e786739db..cf43dec63 100644 --- a/src/locales/ko-KR/external_access.json +++ b/src/locales/ko-KR/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "외부 접근 사용", "status_off": "꺼짐", "status_rejected": "sctl에서 거부됨", + "rejected_refused_reason": "sctl이 이 ScriptCat 인스턴스를 거부했습니다. sctl에서 이 인스턴스를 잊었거나(sctl scriptcats forget) sctl 버전이 너무 오래되었을 수 있습니다. 다시 등록하여 연결하세요.", + "rejected_name_taken_reason": "이 이름은 다른 ScriptCat 인스턴스에서 사용 중입니다. 이름을 변경하세요.", "status_pending_enrollment": "등록 대기 중", "status_connecting": "연결 중", "status_connected": "연결됨", "status_host_unreachable": "연결 실패", "retry": "다시 시도", "reenroll": "다시 등록", + "instance_name_label": "인스턴스 이름", + "name_edit": "변경", + "name_edit_action": "이름 변경", + "name_save": "저장", + "name_invalid_hint": "이름은 소문자, 숫자, -로 된 1~32자여야 합니다.", + "name_taken": "이 이름은 다른 ScriptCat 인스턴스에서 사용 중입니다.", + "rename_success": "인스턴스 이름을 업데이트했습니다.", + "rename_next_connection": "저장했습니다. 새 이름은 다음 연결부터 적용됩니다.", + "rename_not_connected": "지금은 이름을 변경할 수 없습니다. sctl이 연결되어 있는지 확인한 후 다시 시도하세요.", + "rename_failed": "이름을 변경하지 못했습니다. 다시 시도하세요.", + "rename_saving": "저장 중…", + "enroll_name_taken": "이 이름은 다른 ScriptCat 인스턴스에서 사용 중입니다. 이 페어링 코드는 이미 사용되었습니다. 이름을 변경하고 sctl connect를 다시 실행해 새 코드를 받은 후 제출하세요.", "address_label": "sctl 주소", "policy_title": "권한 정책", "policy_write": "쓰기 작업", @@ -43,6 +57,7 @@ "enroll_perm_write": "설치 · 사용 · 삭제 요청(쓰기 정책에 따라 확인)", "enroll_perm_source": "스크립트 소스 읽기(소스 정책에 따라 확인)", "enroll_submit": "등록", + "enroll_submitting": "등록 중…", "cancel": "취소", "enroll_started": "페어링 코드를 제출했습니다. sctl에 연결하는 중…", "confirm_via_external": "외부 접근으로 요청됨", diff --git a/src/locales/pt-BR/external_access.json b/src/locales/pt-BR/external_access.json index dedc44cb7..34d09ea54 100644 --- a/src/locales/pt-BR/external_access.json +++ b/src/locales/pt-BR/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "Ativar acesso externo", "status_off": "Desligado", "status_rejected": "Rejeitado pelo sctl", + "rejected_refused_reason": "O sctl rejeitou esta instância do ScriptCat — ela pode ter sido esquecida no sctl (sctl scriptcats forget), ou o sctl pode estar desatualizado. Vincule novamente para reconectar.", + "rejected_name_taken_reason": "Este nome é usado por outra instância do ScriptCat. Altere o nome.", "status_pending_enrollment": "Aguardando vinculação", "status_connecting": "Conectando", "status_connected": "Conectado", "status_host_unreachable": "Falha na conexão", "retry": "Tentar novamente", "reenroll": "Vincular novamente", + "instance_name_label": "Nome da instância", + "name_edit": "Editar", + "name_edit_action": "Editar nome", + "name_save": "Salvar", + "name_invalid_hint": "Use 1–32 letras minúsculas, dígitos ou hifens.", + "name_taken": "Este nome já é usado por outra instância do ScriptCat.", + "rename_success": "Nome da instância atualizado.", + "rename_next_connection": "Salvo — o novo nome entra em vigor na próxima conexão.", + "rename_not_connected": "Não foi possível renomear agora. Verifique se o sctl está conectado e tente novamente.", + "rename_failed": "Não foi possível renomear. Tente novamente.", + "rename_saving": "Salvando…", + "enroll_name_taken": "Este nome já é usado por outra instância do ScriptCat. Este código de emparelhamento foi usado — altere o nome, execute sctl connect novamente para obter um novo código e envie-o.", "address_label": "Endereço do sctl", "policy_title": "Política de permissões", "policy_write": "Operações de escrita", @@ -43,6 +57,7 @@ "enroll_perm_write": "Solicitar instalação · ativação · exclusão (confirmado pela política de escrita)", "enroll_perm_source": "Ler o código-fonte do script (confirmado pela política de código-fonte)", "enroll_submit": "Vincular", + "enroll_submitting": "Vinculando…", "cancel": "Cancelar", "enroll_started": "Código de emparelhamento enviado — conectando ao sctl…", "confirm_via_external": "Acionado via acesso externo", diff --git a/src/locales/ru-RU/external_access.json b/src/locales/ru-RU/external_access.json index c7037a424..1ff370945 100644 --- a/src/locales/ru-RU/external_access.json +++ b/src/locales/ru-RU/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "Включить внешний доступ", "status_off": "Выключено", "status_rejected": "Отклонено sctl", + "rejected_refused_reason": "sctl отклонил этот экземпляр ScriptCat — возможно, он был забыт в sctl (sctl scriptcats forget) или версия sctl слишком старая. Подключите заново, чтобы восстановить соединение.", + "rejected_name_taken_reason": "Это имя используется другим экземпляром ScriptCat. Измените имя.", "status_pending_enrollment": "Ожидает подключения", "status_connecting": "Подключение…", "status_connected": "Подключено", "status_host_unreachable": "Не удалось подключиться", "retry": "Повторить", "reenroll": "Подключить заново", + "instance_name_label": "Имя экземпляра", + "name_edit": "Изменить", + "name_edit_action": "Изменить имя", + "name_save": "Сохранить", + "name_invalid_hint": "Имя должно содержать от 1 до 32 строчных букв, цифр или дефисов.", + "name_taken": "Это имя уже используется другим экземпляром ScriptCat.", + "rename_success": "Имя экземпляра обновлено.", + "rename_next_connection": "Сохранено — новое имя вступит в силу при следующем подключении.", + "rename_not_connected": "Сейчас нельзя изменить имя. Убедитесь, что sctl подключён, и повторите попытку.", + "rename_failed": "Не удалось изменить имя. Повторите попытку.", + "rename_saving": "Сохранение…", + "enroll_name_taken": "Это имя уже используется другим экземпляром ScriptCat. Этот код сопряжения израсходован — измените имя, снова выполните sctl connect, чтобы получить новый код, и отправьте его.", "address_label": "Адрес sctl", "policy_title": "Политика разрешений", "policy_write": "Операции записи", @@ -43,6 +57,7 @@ "enroll_perm_write": "Запрос установки · включения · удаления (подтверждается по политике записи)", "enroll_perm_source": "Чтение исходного кода скрипта (подтверждается по политике исходного кода)", "enroll_submit": "Подключить", + "enroll_submitting": "Подключение…", "cancel": "Отмена", "enroll_started": "Код сопряжения отправлен — подключение к sctl…", "confirm_via_external": "Запрошено через внешний доступ", diff --git a/src/locales/tr-TR/external_access.json b/src/locales/tr-TR/external_access.json index b5a81ee5f..2ac57ccbd 100644 --- a/src/locales/tr-TR/external_access.json +++ b/src/locales/tr-TR/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "Harici Erişimi Etkinleştir", "status_off": "Kapalı", "status_rejected": "sctl tarafından reddedildi", + "rejected_refused_reason": "sctl bu ScriptCat örneğini reddetti — örnek sctl'de unutulmuş (sctl scriptcats forget) veya sctl çok eski olabilir. Yeniden bağlanmak için tekrar eşleştirin.", + "rejected_name_taken_reason": "Bu ad başka bir ScriptCat örneği tarafından kullanılıyor. Adı değiştirin.", "status_pending_enrollment": "Eşleştirme bekleniyor", "status_connecting": "Bağlanıyor", "status_connected": "Bağlandı", "status_host_unreachable": "Bağlantı başarısız", "retry": "Yeniden dene", "reenroll": "Yeniden eşleştir", + "instance_name_label": "Örnek adı", + "name_edit": "Düzenle", + "name_edit_action": "Adı düzenle", + "name_save": "Kaydet", + "name_invalid_hint": "Ad 1–32 küçük harf, rakam veya - içermelidir.", + "name_taken": "Bu ad zaten başka bir ScriptCat örneği tarafından kullanılıyor.", + "rename_success": "Örnek adı güncellendi.", + "rename_next_connection": "Kaydedildi — yeni ad bir sonraki bağlantıda geçerli olacak.", + "rename_not_connected": "Şu anda ad değiştirilemiyor. sctl'nin bağlı olduğundan emin olup yeniden deneyin.", + "rename_failed": "Ad değiştirilemedi. Yeniden deneyin.", + "rename_saving": "Kaydediliyor…", + "enroll_name_taken": "Bu ad zaten başka bir ScriptCat örneği tarafından kullanılıyor. Bu eşleştirme kodu kullanılmış — adı değiştirin, yeni bir kod almak için sctl connect komutunu yeniden çalıştırın ve ardından gönderin.", "address_label": "sctl adresi", "policy_title": "İzin ilkesi", "policy_write": "Yazma işlemleri", @@ -43,6 +57,7 @@ "enroll_perm_write": "Kurma · etkinleştirme · silme isteği (yazma ilkesine göre onaylanır)", "enroll_perm_source": "Betik kaynak kodunu okuma (kaynak ilkesine göre onaylanır)", "enroll_submit": "Eşleştir", + "enroll_submitting": "Eşleştiriliyor…", "cancel": "İptal", "enroll_started": "Eşleştirme kodu gönderildi — sctl'ye bağlanılıyor…", "confirm_via_external": "Harici Erişim ile tetiklendi", diff --git a/src/locales/vi-VN/external_access.json b/src/locales/vi-VN/external_access.json index c59a46212..9f90bdc74 100644 --- a/src/locales/vi-VN/external_access.json +++ b/src/locales/vi-VN/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "Bật truy cập bên ngoài", "status_off": "Tắt", "status_rejected": "Bị sctl từ chối", + "rejected_refused_reason": "sctl đã từ chối thực thể ScriptCat này — có thể thực thể đã bị quên trong sctl (sctl scriptcats forget) hoặc sctl quá cũ. Hãy ghép nối lại để kết nối lại.", + "rejected_name_taken_reason": "Tên này đang được một thực thể ScriptCat khác sử dụng. Hãy đổi tên.", "status_pending_enrollment": "Đang chờ ghép nối", "status_connecting": "Đang kết nối", "status_connected": "Đã kết nối", "status_host_unreachable": "Kết nối thất bại", "retry": "Thử lại", "reenroll": "Ghép nối lại", + "instance_name_label": "Tên thực thể", + "name_edit": "Chỉnh sửa", + "name_edit_action": "Đổi tên", + "name_save": "Lưu", + "name_invalid_hint": "Tên phải gồm 1–32 chữ cái thường, chữ số hoặc dấu gạch ngang.", + "name_taken": "Tên này đã được một thực thể ScriptCat khác sử dụng.", + "rename_success": "Đã cập nhật tên thực thể.", + "rename_next_connection": "Đã lưu — tên mới sẽ có hiệu lực ở lần kết nối tiếp theo.", + "rename_not_connected": "Hiện không thể đổi tên. Hãy đảm bảo sctl đã kết nối rồi thử lại.", + "rename_failed": "Không thể đổi tên. Hãy thử lại.", + "rename_saving": "Đang lưu…", + "enroll_name_taken": "Tên này đã được một thực thể ScriptCat khác sử dụng. Mã ghép nối này đã được dùng — hãy đổi tên, chạy lại sctl connect để lấy mã mới rồi gửi lại.", "address_label": "Địa chỉ sctl", "policy_title": "Chính sách quyền", "policy_write": "Thao tác ghi", @@ -43,6 +57,7 @@ "enroll_perm_write": "Yêu cầu cài đặt · bật · xóa (xác nhận theo chính sách ghi)", "enroll_perm_source": "Đọc mã nguồn tập lệnh (xác nhận theo chính sách mã nguồn)", "enroll_submit": "Ghép nối", + "enroll_submitting": "Đang ghép nối…", "cancel": "Hủy", "enroll_started": "Đã gửi mã ghép nối — đang kết nối tới sctl…", "confirm_via_external": "Kích hoạt qua truy cập bên ngoài", diff --git a/src/locales/zh-CN/external_access.json b/src/locales/zh-CN/external_access.json index f717bffde..15ceb5404 100644 --- a/src/locales/zh-CN/external_access.json +++ b/src/locales/zh-CN/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "启用外部接入", "status_off": "未启用", "status_rejected": "已被 sctl 拒绝", + "rejected_refused_reason": "sctl 拒绝了这个 ScriptCat 实例——它可能已在 sctl 中被忘记(sctl scriptcats forget),或 sctl 版本过旧;请重新接入以再次连接。", + "rejected_name_taken_reason": "该名称已被另一个 ScriptCat 实例使用,请修改名称。", "status_pending_enrollment": "待接入", "status_connecting": "连接中", "status_connected": "已接入", "status_host_unreachable": "连接失败", "retry": "重试", "reenroll": "重新接入", + "instance_name_label": "实例名称", + "name_edit": "修改", + "name_edit_action": "修改名称", + "name_save": "保存", + "name_invalid_hint": "名称需为 1–32 位小写字母、数字或 -。", + "name_taken": "该名称已被另一个 ScriptCat 实例使用", + "rename_success": "实例名称已更新", + "rename_next_connection": "已保存,新名称将在下次连接时生效", + "rename_not_connected": "当前无法修改名称,请确认 sctl 已连接后重试", + "rename_failed": "修改名称失败,请重试", + "rename_saving": "保存中…", + "enroll_name_taken": "该名称已被另一个 ScriptCat 实例使用;这个配对码已用掉,请改名后重新运行 sctl connect 获取新配对码再提交。", "address_label": "sctl 地址", "policy_title": "权限策略", "policy_write": "写操作", @@ -43,6 +57,7 @@ "enroll_perm_write": "请求安装 · 启用 · 删除(按写操作策略确认)", "enroll_perm_source": "读取脚本源码(按源码策略确认)", "enroll_submit": "接入", + "enroll_submitting": "接入中…", "cancel": "取消", "enroll_started": "已提交配对码,正在接入 sctl…", "confirm_via_external": "通过外部接入触发", diff --git a/src/locales/zh-TW/external_access.json b/src/locales/zh-TW/external_access.json index 038f6ffcb..f18cffb6f 100644 --- a/src/locales/zh-TW/external_access.json +++ b/src/locales/zh-TW/external_access.json @@ -4,12 +4,26 @@ "enable_switch": "啟用外部接入", "status_off": "未啟用", "status_rejected": "已遭 sctl 拒絕", + "rejected_refused_reason": "sctl 拒絕了這個 ScriptCat 執行個體——它可能已在 sctl 中被忘記(sctl scriptcats forget),或 sctl 版本過舊;請重新接入以再次連線。", + "rejected_name_taken_reason": "此名稱已被另一個 ScriptCat 執行個體使用,請修改名稱。", "status_pending_enrollment": "待接入", "status_connecting": "連線中", "status_connected": "已接入", "status_host_unreachable": "連線失敗", "retry": "重試", "reenroll": "重新接入", + "instance_name_label": "執行個體名稱", + "name_edit": "修改", + "name_edit_action": "修改名稱", + "name_save": "儲存", + "name_invalid_hint": "名稱需為 1–32 個小寫字母、數字或 -。", + "name_taken": "此名稱已被另一個 ScriptCat 執行個體使用", + "rename_success": "已更新執行個體名稱", + "rename_next_connection": "已儲存,新名稱將於下次連線時生效", + "rename_not_connected": "目前無法修改名稱,請確認 sctl 已連線後再試", + "rename_failed": "修改名稱失敗,請再試一次", + "rename_saving": "儲存中…", + "enroll_name_taken": "此名稱已被另一個 ScriptCat 執行個體使用;這個配對碼已用掉,請修改名稱後重新執行 sctl connect 取得新的配對碼再提交。", "address_label": "sctl 位址", "policy_title": "權限原則", "policy_write": "寫入操作", @@ -43,6 +57,7 @@ "enroll_perm_write": "請求安裝 · 啟用 · 刪除(依寫入操作原則確認)", "enroll_perm_source": "讀取腳本原始碼(依原始碼原則確認)", "enroll_submit": "接入", + "enroll_submitting": "接入中…", "cancel": "取消", "enroll_started": "已提交配對碼,正在接入 sctl…", "confirm_via_external": "透過外部接入觸發", diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx index d7562b92f..f7340a9e6 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx @@ -1,15 +1,20 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react"; -import type { ExternalAccessBridgeStatusInfo } from "@App/app/service/service_worker/external_access/types"; +import type { + ExternalAccessBridgeStatusInfo, + ExternalAccessEnrollResult, + ExternalAccessRenameResult, +} from "@App/app/service/service_worker/external_access/types"; import { initTestLanguage } from "@Tests/initTestLanguage"; -const { getBridgeStatus, enroll, stopExternalAccess } = vi.hoisted(() => ({ +const { getBridgeStatus, enroll, rename, stopExternalAccess } = vi.hoisted(() => ({ getBridgeStatus: vi.fn<() => Promise>(), - enroll: vi.fn((_code: string, _name: string) => Promise.resolve("ok" as const)), + enroll: vi.fn<(code: string, name: string) => Promise>(), + rename: vi.fn<(name: string) => Promise>(), stopExternalAccess: vi.fn(() => Promise.resolve()), })); vi.mock("@App/pages/store/features/script", () => ({ - externalAccessClient: { getBridgeStatus, enroll, stopExternalAccess }, + externalAccessClient: { getBridgeStatus, enroll, rename, stopExternalAccess }, })); const navigate = vi.hoisted(() => vi.fn()); @@ -69,6 +74,8 @@ beforeEach(() => { getExternalAccessWritePolicy.mockResolvedValue("approval"); getExternalAccessSourceReadPolicy.mockResolvedValue("approval"); getExternalAccessUrl.mockResolvedValue("ws://localhost:8643"); + enroll.mockResolvedValue("ok"); + rename.mockResolvedValue("ok"); }); afterEach(cleanup); @@ -160,4 +167,122 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { fireEvent.click(await screen.findByTestId("external_access_enable_switch")); expect(set).toHaveBeenCalledWith("external_access_enabled", false); }); + + it("已接入状态显示实例名称与修改按钮", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + await renderSection(); + expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("work"); + expect(screen.getByTestId("external_access_name_edit")).toBeInTheDocument(); + }); + + it("修改名称为不合规值时即时提示并禁用保存", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + const input = await screen.findByTestId("external_access_name_input"); + fireEvent.change(input, { target: { value: "Bad Name" } }); + expect(screen.getByTestId("external_access_name_error")).toHaveTextContent("名称需为 1–32 位小写字母、数字或 -。"); + expect(screen.getByTestId("external_access_name_save")).toBeDisabled(); + + fireEvent.change(input, { target: { value: "office" } }); + expect(screen.queryByTestId("external_access_name_error")).not.toBeInTheDocument(); + expect(screen.getByTestId("external_access_name_save")).toBeEnabled(); + }); + + it("保存名称成功后显示成功提示并更新当前名称", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + rename.mockResolvedValue("ok"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "office" } }); + fireEvent.click(screen.getByTestId("external_access_name_save")); + await waitFor(() => expect(rename).toHaveBeenCalledWith("office")); + await waitFor(() => expect(notify.success).toHaveBeenCalledWith("实例名称已更新")); + expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("office"); + expect(screen.queryByTestId("external_access_name_input")).not.toBeInTheDocument(); + }); + + it("未连接时保存名称提示下次连接生效", async () => { + getBridgeStatus.mockResolvedValue({ status: "host_unreachable", name: "work" }); + rename.mockResolvedValue("applies-next-connection"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "office" } }); + fireEvent.click(screen.getByTestId("external_access_name_save")); + await waitFor(() => expect(rename).toHaveBeenCalledWith("office")); + await waitFor(() => expect(notify.info).toHaveBeenCalledWith("已保存,新名称将在下次连接时生效")); + expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("office"); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it("保存名称冲突时留在编辑状态并提示名称已被占用", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + rename.mockResolvedValue("name-taken"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "taken" } }); + fireEvent.click(screen.getByTestId("external_access_name_save")); + await waitFor(() => expect(rename).toHaveBeenCalledWith("taken")); + expect(await screen.findByTestId("external_access_name_error")).toHaveTextContent( + "该名称已被另一个 ScriptCat 实例使用" + ); + expect(screen.getByTestId("external_access_name_input")).toHaveValue("taken"); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it("接入对话框预填当前实例名称,名称不合规时禁用提交", async () => { + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_enroll_open")); + const nameInput = await screen.findByTestId("external_access_enroll_name_input"); + expect(nameInput).toHaveValue("home-browser"); + + const cell0 = await screen.findByTestId("external_access_enroll_code-cell-0"); + fireEvent.paste(cell0, { clipboardData: { getData: () => "ABCD1234" } }); + fireEvent.change(nameInput, { target: { value: "Home Browser" } }); + expect(await screen.findByTestId("external_access_enroll_name_error")).toHaveTextContent( + "名称需为 1–32 位小写字母、数字或 -。" + ); + expect(screen.getByTestId("external_access_enroll_submit")).toBeDisabled(); + }); + + it("接入时名称冲突保持对话框打开,提示名称与配对码均不可用并清空配对码", async () => { + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); + enroll.mockResolvedValue("name-taken"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_enroll_open")); + const cell0 = await screen.findByTestId("external_access_enroll_code-cell-0"); + fireEvent.paste(cell0, { clipboardData: { getData: () => "ABCD1234" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_enroll_submit")); + }); + expect(enroll).toHaveBeenCalledWith("ABCD1234", "home-browser"); + const error = await screen.findByTestId("external_access_enroll_name_error"); + expect(error).toHaveTextContent("该名称已被另一个 ScriptCat 实例使用"); + expect(error).toHaveTextContent("这个配对码已用掉"); + expect(screen.getByTestId("external_access_enroll_name_input")).toBeInTheDocument(); + expect(screen.getByTestId("external_access_enroll_code-cell-0")).toHaveValue(""); + expect(screen.getByTestId("external_access_enroll_submit")).toBeDisabled(); + expect(notify.info).not.toHaveBeenCalled(); + }); + + it("被 sctl 拒绝(refused)时说明原因并可重新接入", async () => { + getBridgeStatus.mockResolvedValue({ status: "rejected", reason: "refused", name: "work" }); + await renderSection(); + expect(await screen.findByTestId("external_access_rejected_reason")).toHaveTextContent( + "sctl 拒绝了这个 ScriptCat 实例" + ); + fireEvent.click(screen.getByTestId("external_access_reenroll")); + expect(await screen.findByTestId("external_access_enroll_name_input")).toHaveValue("work"); + }); + + it("被 sctl 拒绝(name-taken)时说明冲突并可进入改名", async () => { + getBridgeStatus.mockResolvedValue({ status: "rejected", reason: "name-taken", name: "work" }); + await renderSection(); + expect(await screen.findByTestId("external_access_rejected_reason")).toHaveTextContent( + "该名称已被另一个 ScriptCat 实例使用" + ); + fireEvent.click(screen.getByTestId("external_access_edit_name")); + expect(await screen.findByTestId("external_access_name_input")).toHaveValue("work"); + }); }); diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx index 78b455497..a987276a0 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx @@ -1,7 +1,19 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { BookOpen, ShieldAlert, ScrollText, Terminal, PlugZap, Check, Download, Eye, Power } from "lucide-react"; +import { + BookOpen, + ShieldAlert, + ScrollText, + Terminal, + PlugZap, + Check, + Download, + Eye, + Loader2, + Pencil, + Power, +} from "lucide-react"; import { SettingCard } from "../../../components/SettingCard"; import { SettingRow } from "../../../components/SettingRow"; import { Switch } from "@App/pages/components/ui/switch"; @@ -47,6 +59,9 @@ const AUDIT_QUERY = encodeURIComponent(JSON.stringify([{ key: "component", value // `sctl connect` 打印一次性配对码,只在终端显示、绝不过线;用户读到后填入接入对话框。 const ENROLL_COMMAND = "sctl connect"; +// 与 sctl 的实例名称校验保持一致:不合规的输入在页面就地拦下,不发给 daemon。 +const NAME_PATTERN = /^[a-z0-9-]{1,32}$/; + // 文档站只有中文与英文两版,localePath 按界面语言在两者间切换(同 agentDocUrl)。 const docUrl = () => `${DocumentationSite}${localePath}/docs/use/external-access/`; @@ -131,6 +146,13 @@ export function ExternalAccessSection({ register }: { register: (id: string) => const [mcpUrl, setExternalAccessUrl] = useState(""); const [showEnroll, setShowEnroll] = useState(false); const [code, setCode] = useState(""); + const [enrollName, setEnrollName] = useState(""); + const [enrollNameError, setEnrollNameError] = useState(); + const [enrolling, setEnrolling] = useState(false); + const [nameEditing, setNameEditing] = useState(false); + const [nameDraft, setNameDraft] = useState(""); + const [nameError, setNameError] = useState(); + const [savingName, setSavingName] = useState(false); useEffect(() => { void Promise.resolve(systemConfig.get("external_access_enabled")).then((v) => setEnabled(Boolean(v))); @@ -168,14 +190,82 @@ export function ExternalAccessSection({ register }: { register: (id: string) => if (trimmed) systemConfig.setExternalAccessUrl(trimmed); }; + const nameInvalid = !NAME_PATTERN.test(nameDraft); + const enrollNameInvalid = !NAME_PATTERN.test(enrollName); + + // 对话框可能从待接入态或拒绝态的「重新接入」打开;两处都用当前名称预填并清掉上一轮的码与提示。 + const openEnroll = () => { + setCode(""); + setEnrollName(statusInfo?.name ?? ""); + setEnrollNameError(undefined); + setShowEnroll(true); + }; + + const startNameEdit = () => { + setNameDraft(statusInfo?.name ?? ""); + setNameError(undefined); + setNameEditing(true); + }; + + const cancelNameEdit = () => { + setNameDraft(statusInfo?.name ?? ""); + setNameError(undefined); + setNameEditing(false); + }; + const handleEnroll = async () => { const c = code.trim(); - if (!c || !statusInfo) return; - await externalAccessClient.enroll(c, statusInfo.name); - setCode(""); - setShowEnroll(false); - // 此刻只是把配对码交给 daemon,握手成败未知(码错/sctl 未运行都会转「连接失败」),故是进行中而非成功。 - notify.info(t("external_access:enroll_started")); + if (!c || !statusInfo || enrollNameInvalid || enrolling) return; + setEnrolling(true); + setEnrollNameError(undefined); + try { + const result = await externalAccessClient.enroll(c, enrollName); + if (result === "name-taken") { + // sctl 在认证通过即消费配对码;冲突后旧码不可再用,必须改名并重新运行 sctl connect。 + setCode(""); + setEnrollNameError(t("external_access:enroll_name_taken")); + return; + } + if (result === "invalid-name") { + setEnrollNameError(t("external_access:name_invalid_hint")); + return; + } + setCode(""); + setShowEnroll(false); + // 此刻只是把配对码交给 daemon,握手成败未知(码错/sctl 未运行都会转「连接失败」),故是进行中而非成功。 + notify.info(t("external_access:enroll_started")); + } finally { + setEnrolling(false); + } + }; + + const handleRename = async () => { + if (nameInvalid || savingName) return; + setSavingName(true); + setNameError(undefined); + try { + const result = await externalAccessClient.rename(nameDraft); + if (result === "ok" || result === "applies-next-connection") { + setStatusInfo((info) => (info ? { ...info, name: nameDraft } : info)); + setNameEditing(false); + if (result === "ok") notify.success(t("external_access:rename_success")); + else notify.info(t("external_access:rename_next_connection")); + return; + } + if (result === "name-taken") { + setNameError(t("external_access:name_taken")); + return; + } + if (result === "invalid-name") { + setNameError(t("external_access:name_invalid_hint")); + return; + } + setNameError(t("external_access:rename_not_connected")); + } catch { + setNameError(t("external_access:rename_failed")); + } finally { + setSavingName(false); + } }; const handleStop = async () => { @@ -282,7 +372,7 @@ export function ExternalAccessSection({ register }: { register: (id: string) =>
- @@ -324,17 +414,131 @@ export function ExternalAccessSection({ register }: { register: (id: string) => {t("external_access:retry")} )} + {status !== "rejected" && ( + + )} +
+ + + {status === "rejected" && ( +
+
+ +

+ {statusInfo?.reason === "name-taken" + ? t("external_access:rejected_name_taken_reason") + : t("external_access:rejected_refused_reason")} +

+
+ {statusInfo?.reason === "name-taken" ? ( + + ) : ( + + )} +
+ )} + + {nameEditing ? ( +
+ + {t("external_access:instance_name_label")} + +
+ { + setNameDraft(e.target.value); + setNameError(undefined); + }} + disabled={savingName} + autoFocus + className="w-full font-mono text-xs md:max-w-xs" + /> +
+ + +
+
+ {(nameInvalid || nameError) && ( +

+ {nameError ?? t("external_access:name_invalid_hint")} +

+ )} +
+ ) : ( +
+
+ + {t("external_access:instance_name_label")} + + + {statusInfo?.name ?? "—"} + +
- + )} {renderPolicies()} @@ -392,10 +596,32 @@ export function ExternalAccessSection({ register }: { register: (id: string) => aria-label={t("external_access:enroll_dialog_title")} value={code} onChange={setCode} + disabled={enrolling} autoFocus /> +
+ {t("external_access:instance_name_label")} + { + setEnrollName(e.target.value); + setEnrollNameError(undefined); + }} + disabled={enrolling} + className="font-mono text-xs" + /> + {(enrollNameInvalid || enrollNameError) && ( +

+ {enrollNameError ?? t("external_access:name_invalid_hint")} +

+ )} +
+
{t("external_access:enroll_perms_title")} {[ @@ -412,15 +638,21 @@ export function ExternalAccessSection({ register }: { register: (id: string) =>
- From 60f50401426a6ac4d0d967fe31fb667729519c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 3 Nov 2026 15:06:48 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E8=83=BD=E5=8A=9B=E5=A3=B0=E6=98=8E=E5=9C=A8?= =?UTF-8?q?=20Firefox=20=E4=B8=8E=20Safari=20=E4=B8=8A=E4=B9=9F=E8=87=AA?= =?UTF-8?q?=E6=8A=A5=E6=B5=8F=E8=A7=88=E5=99=A8=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getBrowserVersion() 只认 UA 中的 Chrome/ 标记,Firefox 与 Safari 上 productVersion 总被省略;改为按产品读取 Firefox/ 与 Version/ 标记,Chromium 系不变。 顺带修正随本功能过期的注释与测试标题(clientId、paired、接入提示),并去掉 en-US 文案中会原样显示的反引号。 --- .../offscreen/external-access-connect.test.ts | 4 ++-- .../external_access/controller.test.ts | 12 +++++++++--- .../service_worker/external_access/controller.ts | 7 +++---- .../service_worker/external_access/service.ts | 6 +++--- src/locales/en-US/external_access.json | 4 ++-- .../routes/Tools/sections/ExternalAccessSection.tsx | 2 +- src/pkg/config/config.ts | 12 +++++++++++- src/pkg/config/consts.ts | 2 +- 8 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 3260ee28d..871a3e0ce 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -415,7 +415,7 @@ describe("ExternalAccessConnect", () => { // ──────────────────────────────────────────────── describe("配对握手(首次配对)", () => { - it("校验 daemon、解密新长期密钥 K 并上抛 paired,随后切换为会话模式", async () => { + it("校验 daemon、解密新长期密钥 K,能力声明接受后上抛 established 并切换为会话模式", async () => { const code = "MNBV-3456"; const { mac, enc } = nodePairingKeys(code); const K = randomBytes(32); @@ -446,7 +446,7 @@ describe("ExternalAccessConnect", () => { expect((externalAccessConnect as any).currentParams.auth).toEqual({ mode: "session", key: K.toString("hex") }); }); - it("配对 $session.authenticated 缺少 key 时断开、不上抛 paired", async () => { + it("配对 $session.authenticated 缺少 key 时断开、不上抛 established", async () => { const code = "MNBV-3456"; const { mac } = nodePairingKeys(code); const ws = triggerConnect({ url: "ws://127.0.0.1:8643", auth: { mode: "pairing", code } }); diff --git a/src/app/service/service_worker/external_access/controller.test.ts b/src/app/service/service_worker/external_access/controller.test.ts index bbd776c98..58a3d4bb9 100644 --- a/src/app/service/service_worker/external_access/controller.test.ts +++ b/src/app/service/service_worker/external_access/controller.test.ts @@ -171,7 +171,13 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "newkey" }); }); - it.each(["Chrome", "Edge", "Firefox", "Safari", undefined])("能力声明包含名称与 %s 产品信息", async (product) => { + it.each([ + ["Chrome", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36", "129"], + ["Edge", "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", "129"], + ["Firefox", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0", "130.0"], + ["Safari", "Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", "17.4"], + [undefined, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36", undefined], + ])("能力声明包含名称与 %s 产品信息及浏览器版本", async (product, userAgent, productVersion) => { vi.spyOn(browserUtils, "getBrowserType").mockReturnValue({ firefox: Number(product === "Firefox"), webkit: Number(product === "Safari"), @@ -180,7 +186,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { chromeVersion: 0, device: 0, }); - vi.spyOn(browserUtils, "getBrowserVersion").mockReturnValue(product ? 129 : 0); + vi.spyOn(navigator, "userAgent", "get").mockReturnValue(userAgent); await initEnrolled(); await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); const identity = await systemConfig.getExternalAccessIdentity(); @@ -192,7 +198,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { methods: expect.any(Array), peer: { name: identity.name, - ...(product ? { product, productVersion: "129" } : {}), + ...(product ? { product, productVersion } : {}), extensionVersion: chrome.runtime.getManifest().version, }, }), diff --git a/src/app/service/service_worker/external_access/controller.ts b/src/app/service/service_worker/external_access/controller.ts index 274439e1a..5edff6e6b 100644 --- a/src/app/service/service_worker/external_access/controller.ts +++ b/src/app/service/service_worker/external_access/controller.ts @@ -1,6 +1,5 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; -import { getBrowserVersion } from "@App/pkg/utils/utils"; -import { externalAccessProduct, type SystemConfig } from "@App/pkg/config/config"; +import { externalAccessProduct, externalAccessProductVersion, type SystemConfig } from "@App/pkg/config/config"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { ExternalAccessConnectClient } from "../../offscreen/client"; import type { ExternalAccessBridge } from "./bridge"; @@ -195,7 +194,7 @@ export class ExternalAccessController { this.daemonVersion = daemonVersion; const name = this.pending?.renameTo ?? (await this.systemConfig.getExternalAccessIdentity()).name; const product = externalAccessProduct(); - const version = getBrowserVersion(); + const productVersion = externalAccessProductVersion(); void this.connectClient.send({ jsonrpc: "2.0", id: uuidv4(), @@ -206,7 +205,7 @@ export class ExternalAccessController { peer: { name: truncatePeerText(name), ...(product ? { product } : {}), - ...(Number.isFinite(version) && version > 0 ? { productVersion: String(version) } : {}), + ...(productVersion ? { productVersion: truncatePeerText(productVersion) } : {}), extensionVersion: truncatePeerText(chrome.runtime.getManifest().version), }, }, diff --git a/src/app/service/service_worker/external_access/service.ts b/src/app/service/service_worker/external_access/service.ts index af8f56bee..bc98dbf47 100644 --- a/src/app/service/service_worker/external_access/service.ts +++ b/src/app/service/service_worker/external_access/service.ts @@ -10,9 +10,9 @@ import type { PendingOperationSummary, } from "./types"; -// Narrow surface ExternalAccessUIService needs from ExternalAccessController — flat trust leaves only the status read and -// the enrollment dial; the settings page reads/writes the two policies + external_access_enabled directly via -// the generic SystemConfig plumbing. +// Narrow surface ExternalAccessUIService needs from ExternalAccessController — flat trust leaves only the status read, +// the enrollment dial and the instance rename; the settings page reads/writes the two policies + +// external_access_enabled directly via the generic SystemConfig plumbing. export interface ExternalAccessControllerFacade { getStatus(): Promise; enroll(code: string, name: string): Promise; diff --git a/src/locales/en-US/external_access.json b/src/locales/en-US/external_access.json index 99072c60b..2415bd593 100644 --- a/src/locales/en-US/external_access.json +++ b/src/locales/en-US/external_access.json @@ -4,7 +4,7 @@ "enable_switch": "Enable External Access", "status_off": "Off", "status_rejected": "Rejected by sctl", - "rejected_refused_reason": "sctl rejected this ScriptCat instance — it may have been forgotten in sctl (`sctl scriptcats forget`), or sctl may be too old. Re-enroll to connect again.", + "rejected_refused_reason": "sctl rejected this ScriptCat instance — it may have been forgotten in sctl (sctl scriptcats forget), or sctl may be too old. Re-enroll to connect again.", "rejected_name_taken_reason": "This name is used by another ScriptCat instance. Change the name.", "status_pending_enrollment": "Pending enrollment", "status_connecting": "Connecting", @@ -23,7 +23,7 @@ "rename_not_connected": "Could not rename right now. Make sure sctl is connected and try again.", "rename_failed": "Could not rename. Try again.", "rename_saving": "Saving…", - "enroll_name_taken": "This name is already used by another ScriptCat instance. This pairing code has been used up — change the name, run `sctl connect` again for a new code, then submit it.", + "enroll_name_taken": "This name is already used by another ScriptCat instance. This pairing code has been used up — change the name, run sctl connect again for a new code, then submit it.", "address_label": "sctl address", "policy_title": "Permission policy", "policy_write": "Write operations", diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx index a987276a0..be71b4eeb 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx @@ -232,7 +232,7 @@ export function ExternalAccessSection({ register }: { register: (id: string) => } setCode(""); setShowEnroll(false); - // 此刻只是把配对码交给 daemon,握手成败未知(码错/sctl 未运行都会转「连接失败」),故是进行中而非成功。 + // 名称问题之外的结果沿用原有的进行中提示:sctl 未运行时 offscreen 仍在重连,最终成败以状态胶囊为准。 notify.info(t("external_access:enroll_started")); } finally { setEnrolling(false); diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 5f614d983..ca4827b8c 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -7,7 +7,7 @@ import { matchLanguage } from "@App/locales/locales"; import { ExtVersion } from "@App/app/const"; import defaultTypeDefinition from "@App/template/scriptcat.d.tpl"; import type { ScriptTemplateOverrides } from "@App/pkg/utils/script_template"; -import { BrowserType, getBrowserType, toCamelCase } from "../utils/utils"; +import { BrowserType, getBrowserType, getBrowserVersion, toCamelCase } from "../utils/utils"; import EventEmitter from "eventemitter3"; import { STORAGE_LOCAL_KEYS } from "./consts"; @@ -65,6 +65,16 @@ export function externalAccessProduct() { return undefined; } +// getBrowserVersion() 只认 Chrome/ 标记;Firefox 与 Safari 的 UA 不带它,各自从品牌版本标记读取。 +export function externalAccessProductVersion(): string | undefined { + const browser = getBrowserType(); + if (browser.firefox) return navigator.userAgent.match(/Firefox\/(\d[\d.]*)/)?.[1]; + if (browser.webkit) return navigator.userAgent.match(/Version\/(\d[\d.]*)/)?.[1]; + if (!browser.chrome) return undefined; + const version = getBrowserVersion(); + return Number.isFinite(version) && version > 0 ? String(version) : undefined; +} + export type CATFileStorage = { filesystem: FileSystemType; params: { [key: string]: any }; diff --git a/src/pkg/config/consts.ts b/src/pkg/config/consts.ts index d6ab4277b..27ada5037 100644 --- a/src/pkg/config/consts.ts +++ b/src/pkg/config/consts.ts @@ -14,7 +14,7 @@ export const STORAGE_LOCAL_KEYS: Set = new Set([ "external_access_url", // 外部接入 sctl 地址(设备相关) "external_access_write_policy", // 外部接入 · 写操作策略(设备相关) "external_access_source_read_policy", // 外部接入 · 源码读取策略(设备相关) - "external_access_pairing", // 外部接入长期共享密钥 K + 客户端身份(含密钥,绝不跨设备同步) + "external_access_pairing", // 外部接入长期共享密钥 K(绝不跨设备同步) "keep_ext_background_alive", // Chrome service worker 和 offscreen 保活开关 // 新建脚本模板:用户手写的代码,三份合计很容易超过 chrome.storage.sync 单项 8KB 配额, // 而 ChromeStorage.set 不检查 lastError,超额会静默丢失 From f7ba8febf492a3baf03471a32ffe3295a1db73cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 3 Nov 2026 15:57:00 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E9=85=8D=E5=AF=B9=E4=B8=AD=20SW=20=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E4=B8=8D=E5=86=8D=E4=B8=A2=E5=BC=83=E6=96=B0=E5=AF=86?= =?UTF-8?q?=E9=92=A5=EF=BC=8C=E6=9C=AC=E5=9C=B0=E6=8B=A6=E4=B8=8B=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=90=8D=20scriptcat=EF=BC=8C=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onEstablished 改以 disabled 状态而非内存里的 active 判断是否接收:SW 在配对握手期间重启后,daemon 已登记的新密钥仍会落盘并转为已连接;stop() 之后迟到的通知仍被忽略 - 实例名称规则收敛为 config 中唯一的 isValidExternalAccessName,页面与控制器共用,并拒绝 sctl 保留给旧版 ScriptCat 的 scriptcat(否则接入会在认证后被拒、白白消耗一次性配对码),各语言提示同步说明 - 接入请求本身失败(doThrow 拒绝)时页面提示失败,不再产生未处理的 Promise 拒绝 - 能力声明只对来自 UA、可被改写的浏览器版本截断到 64 字节;名称与扩展版本均为受控输入,删去不可达的 UTF-8 截断 - Web Locks 测试替身从生产目录移到 tests/mockWebLocks.ts;连接测试改经 Group 下发能力声明、以 hello 转发观察握手完成、以重连后的会话模式握手验证密钥切换,不再读私有字段 --- .../offscreen/external-access-connect.test.ts | 59 +++++++++----- .../external_access/controller.test.ts | 79 +++++++++++++------ .../external_access/controller.ts | 31 ++++---- .../external_access/service.test.ts | 4 +- src/locales/de-DE/external_access.json | 3 +- src/locales/en-US/external_access.json | 3 +- src/locales/ja-JP/external_access.json | 3 +- src/locales/ko-KR/external_access.json | 3 +- src/locales/pt-BR/external_access.json | 3 +- src/locales/ru-RU/external_access.json | 3 +- src/locales/tr-TR/external_access.json | 3 +- src/locales/vi-VN/external_access.json | 3 +- src/locales/zh-CN/external_access.json | 3 +- src/locales/zh-TW/external_access.json | 3 +- .../sections/ExternalAccessSection.test.tsx | 44 ++++++++--- .../Tools/sections/ExternalAccessSection.tsx | 15 ++-- src/pkg/config/config.test.ts | 4 +- src/pkg/config/config.ts | 13 ++- .../test-locks.ts => tests/mockWebLocks.ts | 4 +- 19 files changed, 191 insertions(+), 92 deletions(-) rename src/app/service/service_worker/external_access/test-locks.ts => tests/mockWebLocks.ts (63%) diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 871a3e0ce..de956ff5f 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -1,4 +1,3 @@ -import type { WSEnvelope } from "../service_worker/external_access/types"; import { initTestEnv } from "@Tests/utils"; import { ExternalAccessConnect, @@ -19,6 +18,7 @@ import { Server } from "@Packages/message/server"; import EventEmitter from "eventemitter3"; import { createCipheriv, createHmac, hkdfSync, randomBytes } from "node:crypto"; import { CRYPTO, LIMITS } from "../service_worker/external_access/generated/protocol.generated"; +import { ExternalAccessConnectClient } from "./client"; initTestEnv(); @@ -151,6 +151,7 @@ describe("ExternalAccessConnect", () => { beforeAll(() => vi.setConfig({ testTimeout: 5000 })); let externalAccessConnect: ExternalAccessConnect; + let client: ExternalAccessConnectClient; let relay: { envelope: ReturnType; established: ReturnType; @@ -167,6 +168,7 @@ describe("ExternalAccessConnect", () => { const group = server.group("externalAccessConnect"); externalAccessConnect = new ExternalAccessConnect(group, mockMessage); + client = new ExternalAccessConnectClient(mockMessage); relay = { envelope: vi.fn().mockResolvedValue(undefined), established: vi.fn().mockResolvedValue(undefined), @@ -189,12 +191,17 @@ describe("ExternalAccessConnect", () => { return wsInstances[wsInstances.length - 1]; } + // SW 经 Group 下发能力声明,与生产路径一致。 function declareCapabilities() { - ( - externalAccessConnect as unknown as { - sendEnvelope(env: WSEnvelope): void; - } - ).sendEnvelope({ jsonrpc: "2.0", id: "cap", method: "$session.capabilities", params: {} }); + return client.send({ jsonrpc: "2.0", id: "cap", method: "$session.capabilities", params: {} }); + } + + // hello 只在认证完成后才转发给 SW(认证前收到会断开),以此作为握手完成的可观察信号。 + async function expectHelloRelayed(ws: MockWebSocket) { + ws.simulateMessage({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + await vi.waitFor(() => + expect(relay.envelope).toHaveBeenCalledWith(expect.objectContaining({ method: "$session.hello" })) + ); } const sessionParam = (key: string): ExternalAccessConnectParam => ({ @@ -245,11 +252,9 @@ describe("ExternalAccessConnect", () => { method: "$session.authenticated", params: { hmac: mode === "session" ? vector.sessionDaemonHmac : vector.pairingDaemonHmac, key }, }); - await vi.waitFor(() => - expect((externalAccessConnect as unknown as { handshakeComplete: boolean }).handshakeComplete).toBe(true) - ); + await expectHelloRelayed(ws); expect(relay.established).not.toHaveBeenCalled(); - declareCapabilities(); + await declareCapabilities(); vi.useFakeTimers(); ws.simulateMessage({ jsonrpc: "2.0", @@ -435,15 +440,31 @@ describe("ExternalAccessConnect", () => { const key = nodeEncryptKey(enc, K); ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac: okHmac, key } }); - await vi.waitFor(() => - expect((externalAccessConnect as unknown as { handshakeComplete: boolean }).handshakeComplete).toBe(true) - ); - declareCapabilities(); + await expectHelloRelayed(ws); + await declareCapabilities(); ws.simulateMessage({ jsonrpc: "2.0", id: "cap", result: {} }); await vi.waitFor(() => expect(relay.established).toHaveBeenCalledWith(K.toString("hex"))); - expect((externalAccessConnect as any).handshakeComplete).toBe(true); - // 握手后内部凭据切到会话模式,供后续自动重连 - expect((externalAccessConnect as any).currentParams.auth).toEqual({ mode: "session", key: K.toString("hex") }); + + // 之后的自动重连用会话模式与新密钥 K,而不是已被消费的配对码。 + vi.useFakeTimers(); + ws.simulateClose(); + await vi.advanceTimersByTimeAsync(2000); + vi.useRealTimers(); + const next = wsInstances[1]; + next.simulateOpen(); + const nextNonceD = randomBytes(32).toString("hex"); + next.simulateMessage({ + jsonrpc: "2.0", + id: "c2", + method: "$session.authenticate", + params: { nonceD: nextNonceD }, + }); + await vi.waitFor(() => expect(next.sentMessages.length).toBe(1)); + const nextResp = next.sent(0); + expect(nextResp.result.mode).toBe("session"); + expect(nextResp.result.hmac).toBe( + nodeHmacHex(new Uint8Array(K), CTX.sessionExt, nextNonceD, nextResp.result.nonceE) + ); }); it("配对 $session.authenticated 缺少 key 时断开、不上抛 established", async () => { @@ -492,7 +513,7 @@ describe("ExternalAccessConnect", () => { expect(wsInstances).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); const ws = await completeSessionHandshake(key, wsInstances[2]); - declareCapabilities(); + await declareCapabilities(); ws.simulateMessage({ jsonrpc: "2.0", id: "cap", result: {} }); await vi.advanceTimersByTimeAsync(0); expect(relay.established).toHaveBeenCalledWith(undefined); @@ -507,7 +528,7 @@ describe("ExternalAccessConnect", () => { it("认证后能力声明迟迟不答复也会超时关闭", async () => { vi.useFakeTimers(); const ws = await completeSessionHandshake(); - declareCapabilities(); + await declareCapabilities(); await vi.advanceTimersByTimeAsync(LIMITS.authTimeoutMs + 1); expect(ws.readyState).toBe(MockWebSocket.CLOSED); expect(relay.established).not.toHaveBeenCalled(); diff --git a/src/app/service/service_worker/external_access/controller.test.ts b/src/app/service/service_worker/external_access/controller.test.ts index 58a3d4bb9..02c2677ae 100644 --- a/src/app/service/service_worker/external_access/controller.test.ts +++ b/src/app/service/service_worker/external_access/controller.test.ts @@ -3,7 +3,7 @@ import { ExternalAccessController } from "./controller"; import { SystemConfig } from "@App/pkg/config/config"; import { MessageQueue } from "@Packages/message/message_queue"; import type { WSEnvelope } from "./types"; -import { mockIdentityLock } from "./test-locks"; +import { mockWebLocks } from "@Tests/mockWebLocks"; import * as browserUtils from "@App/pkg/utils/utils"; // Captures the relay handlers ExternalAccessController registers on its Group so tests can feed decoded @@ -38,7 +38,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { let fake: ReturnType; beforeEach(() => { - mockIdentityLock(); + mockWebLocks(); vi.spyOn(chrome.runtime, "getManifest").mockReturnValue({ ...chrome.runtime.getManifest(), version: "1.5.0" }); chrome.storage.local.clear(); chrome.storage.sync.clear(); @@ -129,16 +129,20 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { }); }); - it.each(["", "Bad", " ", "a".repeat(33), "a_b", "工作"])("无效名称 %j 不拨号、不改名", async (invalid) => { - const controller = makeController(); - await controller.initialize(); - const { name } = await systemConfig.getExternalAccessIdentity(); - expect(await controller.enroll("code", invalid)).toBe("invalid-name"); - expect(await controller.rename(invalid)).toBe("invalid-name"); - expect(connectClient.connect).not.toHaveBeenCalled(); - expect((await controller.getStatus()).name).toBe(name); - expect((await systemConfig.getExternalAccessIdentity()).name).toBe(name); - }); + // scriptcat 是 sctl 为旧版 ScriptCat 保留的名称,daemon 必然拒绝;接入时发出去会白白消耗一次性配对码。 + it.each(["", "Bad", " ", "a".repeat(33), "a_b", "工作", "scriptcat"])( + "无效名称 %j 不拨号、不改名", + async (invalid) => { + const controller = makeController(); + await controller.initialize(); + const { name } = await systemConfig.getExternalAccessIdentity(); + expect(await controller.enroll("code", invalid)).toBe("invalid-name"); + expect(await controller.rename(invalid)).toBe("invalid-name"); + expect(connectClient.connect).not.toHaveBeenCalled(); + expect((await controller.getStatus()).name).toBe(name); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(name); + } + ); it("未连接时改名保存到下一次连接", async () => { const controller = makeController(); @@ -206,18 +210,22 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { ); }); - it.each([63, 64, 65])("能力声明自报文本在 %i UTF-8 字节边界不拆字符", async (bytes) => { - const text = "a".repeat(bytes - 4) + "😀"; - vi.spyOn(chrome.runtime, "getManifest").mockReturnValue({ ...chrome.runtime.getManifest(), version: text }); - vi.spyOn(browserUtils, "getBrowserVersion").mockReturnValue(Number.NaN); - await chrome.storage.local.set({ system_external_access_identity: { instanceId: "ab".repeat(16), name: text } }); + it.each([64, 65])("能力声明自报的浏览器版本截断到 64 字节(UA 中版本为 %i 字节)", async (length) => { + vi.spyOn(browserUtils, "getBrowserType").mockReturnValue({ + firefox: 1, + webkit: 0, + chrome: 0, + unknown: 0, + chromeVersion: 0, + device: 0, + }); + const version = "1." + "0".repeat(length - 2); + vi.spyOn(navigator, "userAgent", "get").mockReturnValue( + `Mozilla/5.0 (X11; rv:130.0) Gecko/20100101 Firefox/${version}` + ); await initEnrolled(); await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); - const peer = connectClient.send.mock.calls[0][0].params.peer; - const expected = bytes <= 64 ? text : "a".repeat(bytes - 4); - expect(peer.name).toBe(expected); - expect(peer.extensionVersion).toBe(expected); - expect(peer).not.toHaveProperty("productVersion"); + expect(connectClient.send.mock.calls[0][0].params.peer.productVersion).toBe(version.slice(0, 64)); }); it("被拒后重新接入时名称冲突要求新配对码,并保留原密钥", async () => { @@ -241,6 +249,33 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { expect(connectClient.connect).toHaveBeenCalledTimes(2); }); + it("SW 在配对握手期间重启,配对完成时仍保存新密钥并转为已连接", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = makeController(); + await controller.initialize(); + void controller.enroll("PAIR-CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); + // 重启丢掉 SW 内存中的控制器,offscreen 里的配对连接仍在继续。 + const restarted = makeController(); + await restarted.initialize(); + expect((await restarted.getStatus()).status).toBe("pending_enrollment"); + await fake.established("newkey"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "newkey" }); + expect(await restarted.getStatus()).toMatchObject({ status: "connected", name: "work" }); + }); + + it("停止接入后迟到的 established 不保存密钥", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = makeController(); + await controller.initialize(); + void controller.enroll("PAIR-CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); + controller.stop(); + await fake.established("newkey"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "" }); + expect((await controller.getStatus()).status).toBe("disabled"); + }); + it("停止接入优先于尚未落盘的拒绝通知", async () => { systemConfig.setExternalAccessEnabled(true); const controller = await initEnrolled(); diff --git a/src/app/service/service_worker/external_access/controller.ts b/src/app/service/service_worker/external_access/controller.ts index 5edff6e6b..af1eddb2e 100644 --- a/src/app/service/service_worker/external_access/controller.ts +++ b/src/app/service/service_worker/external_access/controller.ts @@ -1,5 +1,10 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; -import { externalAccessProduct, externalAccessProductVersion, type SystemConfig } from "@App/pkg/config/config"; +import { + externalAccessProduct, + externalAccessProductVersion, + isValidExternalAccessName, + type SystemConfig, +} from "@App/pkg/config/config"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { ExternalAccessConnectClient } from "../../offscreen/client"; import type { ExternalAccessBridge } from "./bridge"; @@ -18,15 +23,6 @@ import { import type { Group } from "@Packages/message/server"; import { RPC_METHODS, SCHEMA_VERSION } from "./generated/protocol.generated"; -function truncatePeerText(text: string): string { - const { read } = new TextEncoder().encodeInto(text, new Uint8Array(64)); - return text.slice(0, read); -} - -function validName(name: string): boolean { - return typeof name === "string" && /^[a-z0-9-]{1,32}$/.test(name); -} - // Broadcast on every status transition so the Tools settings page updates live. export const ExternalAccessStatusChanged = "mcpStatusChanged"; const REJECTION_KEY = "externalAccessRejection"; @@ -135,7 +131,7 @@ export class ExternalAccessController { // pairing mode with C; on success the daemon ships a fresh long-term key K, which offscreen // decrypts and relays back after capabilities acceptance. async enroll(code: string, name: string): Promise { - if (!validName(name)) return "invalid-name"; + if (!isValidExternalAccessName(name)) return "invalid-name"; if (chrome.extension.inIncognitoContext || this.pending) return "not-connected"; this.active = true; const generation = ++this.generation; @@ -156,7 +152,7 @@ export class ExternalAccessController { } async rename(name: string): Promise { - if (!validName(name)) return "invalid-name"; + if (!isValidExternalAccessName(name)) return "invalid-name"; if (chrome.extension.inIncognitoContext || this.pending) return "not-connected"; if (this.status !== "connected") { await this.systemConfig.setExternalAccessName(name); @@ -203,10 +199,10 @@ export class ExternalAccessController { schemaVersion: SCHEMA_VERSION, methods: Object.keys(RPC_METHODS), peer: { - name: truncatePeerText(name), + name, ...(product ? { product } : {}), - ...(productVersion ? { productVersion: truncatePeerText(productVersion) } : {}), - extensionVersion: truncatePeerText(chrome.runtime.getManifest().version), + ...(productVersion ? { productVersion } : {}), + extensionVersion: chrome.runtime.getManifest().version, }, }, }); @@ -235,7 +231,10 @@ export class ExternalAccessController { } private async onEstablished(key?: string): Promise { - if (!this.active) return; + // stop() 之后迟到的通知不能让已废弃的接入复活;但 SW 在配对握手期间重启会丢掉内存里的 active, + // 而 daemon 已登记这把新密钥,此时必须落盘并接管这条连接。 + if (this.status === "disabled") return; + this.active = true; const pending = this.pending; if (key) this.systemConfig.setExternalAccessPairing({ key }); if (pending?.renameTo) { diff --git a/src/app/service/service_worker/external_access/service.test.ts b/src/app/service/service_worker/external_access/service.test.ts index 12b0c451c..8a86fbfc1 100644 --- a/src/app/service/service_worker/external_access/service.test.ts +++ b/src/app/service/service_worker/external_access/service.test.ts @@ -1,4 +1,4 @@ -import { mockIdentityLock } from "./test-locks"; +import { mockWebLocks } from "@Tests/mockWebLocks"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ExternalAccessUIService } from "./service"; import { ExternalAccessOperationDAO } from "@App/app/repo/external_access"; @@ -82,7 +82,7 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { }); it("stopExternalAccess 废弃密钥但保留实例身份、清本会话授权、停止并关闭开关", async () => { - mockIdentityLock(); + mockWebLocks(); const identity = await systemConfig.getExternalAccessIdentity(); systemConfig.setExternalAccessPairing({ key: "test-key" }); systemConfig.setExternalAccessEnabled(true); diff --git a/src/locales/de-DE/external_access.json b/src/locales/de-DE/external_access.json index 5186a3cca..d4bc85272 100644 --- a/src/locales/de-DE/external_access.json +++ b/src/locales/de-DE/external_access.json @@ -16,7 +16,7 @@ "name_edit": "Bearbeiten", "name_edit_action": "Namen ändern", "name_save": "Speichern", - "name_invalid_hint": "Der Name muss aus 1–32 Kleinbuchstaben, Ziffern oder - bestehen.", + "name_invalid_hint": "Der Name muss aus 1–32 Kleinbuchstaben, Ziffern oder - bestehen; scriptcat ist reserviert.", "name_taken": "Dieser Name wird bereits von einer anderen ScriptCat-Instanz verwendet.", "rename_success": "Instanzname aktualisiert.", "rename_next_connection": "Gespeichert – der neue Name gilt ab der nächsten Verbindung.", @@ -58,6 +58,7 @@ "enroll_perm_source": "Skript-Quelltext lesen (gemäß Quelltextrichtlinie bestätigt)", "enroll_submit": "Registrieren", "enroll_submitting": "Registrierung …", + "enroll_failed": "Die Registrierung konnte nicht gestartet werden. Versuche es erneut.", "cancel": "Abbrechen", "enroll_started": "Kopplungscode gesendet – Verbindung zu sctl wird hergestellt …", "confirm_via_external": "Über externen Zugriff ausgelöst", diff --git a/src/locales/en-US/external_access.json b/src/locales/en-US/external_access.json index 2415bd593..3d0ecd248 100644 --- a/src/locales/en-US/external_access.json +++ b/src/locales/en-US/external_access.json @@ -16,7 +16,7 @@ "name_edit": "Edit", "name_edit_action": "Edit name", "name_save": "Save", - "name_invalid_hint": "Use 1–32 lowercase letters, digits, or hyphens.", + "name_invalid_hint": "Use 1–32 lowercase letters, digits, or hyphens; scriptcat is reserved.", "name_taken": "This name is already used by another ScriptCat instance.", "rename_success": "Instance name updated.", "rename_next_connection": "Saved — the new name takes effect on the next connection.", @@ -58,6 +58,7 @@ "enroll_perm_source": "Read script source (confirmed per the source policy)", "enroll_submit": "Enroll", "enroll_submitting": "Enrolling…", + "enroll_failed": "Could not start enrollment. Try again.", "cancel": "Cancel", "enroll_started": "Pairing code submitted — connecting to sctl…", "confirm_via_external": "Triggered via External Access", diff --git a/src/locales/ja-JP/external_access.json b/src/locales/ja-JP/external_access.json index 4146cb90a..ea2447ca8 100644 --- a/src/locales/ja-JP/external_access.json +++ b/src/locales/ja-JP/external_access.json @@ -16,7 +16,7 @@ "name_edit": "変更", "name_edit_action": "名前を変更", "name_save": "保存", - "name_invalid_hint": "名前は 1〜32 文字の小文字英数字または - で指定してください。", + "name_invalid_hint": "名前は 1〜32 文字の小文字英数字または - で指定してください(scriptcat は予約済みです)。", "name_taken": "この名前は別の ScriptCat インスタンスで使用されています。", "rename_success": "インスタンス名を更新しました。", "rename_next_connection": "保存しました。新しい名前は次回の接続時に有効になります。", @@ -58,6 +58,7 @@ "enroll_perm_source": "スクリプトソースの読み取り(ソースポリシーに従って確認)", "enroll_submit": "登録", "enroll_submitting": "登録中…", + "enroll_failed": "登録を開始できませんでした。もう一度お試しください。", "cancel": "キャンセル", "enroll_started": "ペアリングコードを送信しました。sctl に接続しています…", "confirm_via_external": "外部アクセスによる要求", diff --git a/src/locales/ko-KR/external_access.json b/src/locales/ko-KR/external_access.json index cf43dec63..a7f0208af 100644 --- a/src/locales/ko-KR/external_access.json +++ b/src/locales/ko-KR/external_access.json @@ -16,7 +16,7 @@ "name_edit": "변경", "name_edit_action": "이름 변경", "name_save": "저장", - "name_invalid_hint": "이름은 소문자, 숫자, -로 된 1~32자여야 합니다.", + "name_invalid_hint": "이름은 소문자, 숫자, -로 된 1~32자여야 하며 scriptcat은 사용할 수 없습니다.", "name_taken": "이 이름은 다른 ScriptCat 인스턴스에서 사용 중입니다.", "rename_success": "인스턴스 이름을 업데이트했습니다.", "rename_next_connection": "저장했습니다. 새 이름은 다음 연결부터 적용됩니다.", @@ -58,6 +58,7 @@ "enroll_perm_source": "스크립트 소스 읽기(소스 정책에 따라 확인)", "enroll_submit": "등록", "enroll_submitting": "등록 중…", + "enroll_failed": "등록을 시작하지 못했습니다. 다시 시도하세요.", "cancel": "취소", "enroll_started": "페어링 코드를 제출했습니다. sctl에 연결하는 중…", "confirm_via_external": "외부 접근으로 요청됨", diff --git a/src/locales/pt-BR/external_access.json b/src/locales/pt-BR/external_access.json index 34d09ea54..b092ce4a9 100644 --- a/src/locales/pt-BR/external_access.json +++ b/src/locales/pt-BR/external_access.json @@ -16,7 +16,7 @@ "name_edit": "Editar", "name_edit_action": "Editar nome", "name_save": "Salvar", - "name_invalid_hint": "Use 1–32 letras minúsculas, dígitos ou hifens.", + "name_invalid_hint": "Use 1–32 letras minúsculas, dígitos ou hifens; scriptcat é reservado.", "name_taken": "Este nome já é usado por outra instância do ScriptCat.", "rename_success": "Nome da instância atualizado.", "rename_next_connection": "Salvo — o novo nome entra em vigor na próxima conexão.", @@ -58,6 +58,7 @@ "enroll_perm_source": "Ler o código-fonte do script (confirmado pela política de código-fonte)", "enroll_submit": "Vincular", "enroll_submitting": "Vinculando…", + "enroll_failed": "Não foi possível iniciar a vinculação. Tente novamente.", "cancel": "Cancelar", "enroll_started": "Código de emparelhamento enviado — conectando ao sctl…", "confirm_via_external": "Acionado via acesso externo", diff --git a/src/locales/ru-RU/external_access.json b/src/locales/ru-RU/external_access.json index 1ff370945..082818ee1 100644 --- a/src/locales/ru-RU/external_access.json +++ b/src/locales/ru-RU/external_access.json @@ -16,7 +16,7 @@ "name_edit": "Изменить", "name_edit_action": "Изменить имя", "name_save": "Сохранить", - "name_invalid_hint": "Имя должно содержать от 1 до 32 строчных букв, цифр или дефисов.", + "name_invalid_hint": "Имя должно содержать от 1 до 32 строчных букв, цифр или дефисов; имя scriptcat зарезервировано.", "name_taken": "Это имя уже используется другим экземпляром ScriptCat.", "rename_success": "Имя экземпляра обновлено.", "rename_next_connection": "Сохранено — новое имя вступит в силу при следующем подключении.", @@ -58,6 +58,7 @@ "enroll_perm_source": "Чтение исходного кода скрипта (подтверждается по политике исходного кода)", "enroll_submit": "Подключить", "enroll_submitting": "Подключение…", + "enroll_failed": "Не удалось начать подключение. Повторите попытку.", "cancel": "Отмена", "enroll_started": "Код сопряжения отправлен — подключение к sctl…", "confirm_via_external": "Запрошено через внешний доступ", diff --git a/src/locales/tr-TR/external_access.json b/src/locales/tr-TR/external_access.json index 2ac57ccbd..06c30b431 100644 --- a/src/locales/tr-TR/external_access.json +++ b/src/locales/tr-TR/external_access.json @@ -16,7 +16,7 @@ "name_edit": "Düzenle", "name_edit_action": "Adı düzenle", "name_save": "Kaydet", - "name_invalid_hint": "Ad 1–32 küçük harf, rakam veya - içermelidir.", + "name_invalid_hint": "Ad 1–32 küçük harf, rakam veya - içermelidir; scriptcat ayrılmıştır.", "name_taken": "Bu ad zaten başka bir ScriptCat örneği tarafından kullanılıyor.", "rename_success": "Örnek adı güncellendi.", "rename_next_connection": "Kaydedildi — yeni ad bir sonraki bağlantıda geçerli olacak.", @@ -58,6 +58,7 @@ "enroll_perm_source": "Betik kaynak kodunu okuma (kaynak ilkesine göre onaylanır)", "enroll_submit": "Eşleştir", "enroll_submitting": "Eşleştiriliyor…", + "enroll_failed": "Eşleştirme başlatılamadı. Yeniden deneyin.", "cancel": "İptal", "enroll_started": "Eşleştirme kodu gönderildi — sctl'ye bağlanılıyor…", "confirm_via_external": "Harici Erişim ile tetiklendi", diff --git a/src/locales/vi-VN/external_access.json b/src/locales/vi-VN/external_access.json index 9f90bdc74..05436ab0b 100644 --- a/src/locales/vi-VN/external_access.json +++ b/src/locales/vi-VN/external_access.json @@ -16,7 +16,7 @@ "name_edit": "Chỉnh sửa", "name_edit_action": "Đổi tên", "name_save": "Lưu", - "name_invalid_hint": "Tên phải gồm 1–32 chữ cái thường, chữ số hoặc dấu gạch ngang.", + "name_invalid_hint": "Tên phải gồm 1–32 chữ cái thường, chữ số hoặc dấu gạch ngang; không dùng được scriptcat.", "name_taken": "Tên này đã được một thực thể ScriptCat khác sử dụng.", "rename_success": "Đã cập nhật tên thực thể.", "rename_next_connection": "Đã lưu — tên mới sẽ có hiệu lực ở lần kết nối tiếp theo.", @@ -58,6 +58,7 @@ "enroll_perm_source": "Đọc mã nguồn tập lệnh (xác nhận theo chính sách mã nguồn)", "enroll_submit": "Ghép nối", "enroll_submitting": "Đang ghép nối…", + "enroll_failed": "Không thể bắt đầu ghép nối. Hãy thử lại.", "cancel": "Hủy", "enroll_started": "Đã gửi mã ghép nối — đang kết nối tới sctl…", "confirm_via_external": "Kích hoạt qua truy cập bên ngoài", diff --git a/src/locales/zh-CN/external_access.json b/src/locales/zh-CN/external_access.json index 15ceb5404..5d535b048 100644 --- a/src/locales/zh-CN/external_access.json +++ b/src/locales/zh-CN/external_access.json @@ -16,7 +16,7 @@ "name_edit": "修改", "name_edit_action": "修改名称", "name_save": "保存", - "name_invalid_hint": "名称需为 1–32 位小写字母、数字或 -。", + "name_invalid_hint": "名称需为 1–32 位小写字母、数字或 -,且不能是 scriptcat。", "name_taken": "该名称已被另一个 ScriptCat 实例使用", "rename_success": "实例名称已更新", "rename_next_connection": "已保存,新名称将在下次连接时生效", @@ -58,6 +58,7 @@ "enroll_perm_source": "读取脚本源码(按源码策略确认)", "enroll_submit": "接入", "enroll_submitting": "接入中…", + "enroll_failed": "接入失败,请重试", "cancel": "取消", "enroll_started": "已提交配对码,正在接入 sctl…", "confirm_via_external": "通过外部接入触发", diff --git a/src/locales/zh-TW/external_access.json b/src/locales/zh-TW/external_access.json index f18cffb6f..15d024493 100644 --- a/src/locales/zh-TW/external_access.json +++ b/src/locales/zh-TW/external_access.json @@ -16,7 +16,7 @@ "name_edit": "修改", "name_edit_action": "修改名稱", "name_save": "儲存", - "name_invalid_hint": "名稱需為 1–32 個小寫字母、數字或 -。", + "name_invalid_hint": "名稱需為 1–32 個小寫字母、數字或 -,且不能是 scriptcat。", "name_taken": "此名稱已被另一個 ScriptCat 執行個體使用", "rename_success": "已更新執行個體名稱", "rename_next_connection": "已儲存,新名稱將於下次連線時生效", @@ -58,6 +58,7 @@ "enroll_perm_source": "讀取腳本原始碼(依原始碼原則確認)", "enroll_submit": "接入", "enroll_submitting": "接入中…", + "enroll_failed": "接入失敗,請再試一次", "cancel": "取消", "enroll_started": "已提交配對碼,正在接入 sctl…", "confirm_via_external": "透過外部接入觸發", diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx index f7340a9e6..3d19b1f1b 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx @@ -23,6 +23,7 @@ vi.mock("react-router-dom", () => ({ useNavigate: () => navigate })); const notify = vi.hoisted(() => ({ success: vi.fn(), info: vi.fn(), + error: vi.fn(), })); vi.mock("@App/pages/components/ui/toast", () => ({ notify })); @@ -181,7 +182,9 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { fireEvent.click(await screen.findByTestId("external_access_name_edit")); const input = await screen.findByTestId("external_access_name_input"); fireEvent.change(input, { target: { value: "Bad Name" } }); - expect(screen.getByTestId("external_access_name_error")).toHaveTextContent("名称需为 1–32 位小写字母、数字或 -。"); + expect(screen.getByTestId("external_access_name_error")).toHaveTextContent( + "名称需为 1–32 位小写字母、数字或 -,且不能是 scriptcat。" + ); expect(screen.getByTestId("external_access_name_save")).toBeDisabled(); fireEvent.change(input, { target: { value: "office" } }); @@ -195,9 +198,11 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { await renderSection(); fireEvent.click(await screen.findByTestId("external_access_name_edit")); fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "office" } }); - fireEvent.click(screen.getByTestId("external_access_name_save")); - await waitFor(() => expect(rename).toHaveBeenCalledWith("office")); - await waitFor(() => expect(notify.success).toHaveBeenCalledWith("实例名称已更新")); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_name_save")); + }); + expect(rename).toHaveBeenCalledWith("office"); + expect(notify.success).toHaveBeenCalledWith("实例名称已更新"); expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("office"); expect(screen.queryByTestId("external_access_name_input")).not.toBeInTheDocument(); }); @@ -208,9 +213,11 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { await renderSection(); fireEvent.click(await screen.findByTestId("external_access_name_edit")); fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "office" } }); - fireEvent.click(screen.getByTestId("external_access_name_save")); - await waitFor(() => expect(rename).toHaveBeenCalledWith("office")); - await waitFor(() => expect(notify.info).toHaveBeenCalledWith("已保存,新名称将在下次连接时生效")); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_name_save")); + }); + expect(rename).toHaveBeenCalledWith("office"); + expect(notify.info).toHaveBeenCalledWith("已保存,新名称将在下次连接时生效"); expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("office"); expect(notify.success).not.toHaveBeenCalled(); }); @@ -221,8 +228,10 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { await renderSection(); fireEvent.click(await screen.findByTestId("external_access_name_edit")); fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "taken" } }); - fireEvent.click(screen.getByTestId("external_access_name_save")); - await waitFor(() => expect(rename).toHaveBeenCalledWith("taken")); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_name_save")); + }); + expect(rename).toHaveBeenCalledWith("taken"); expect(await screen.findByTestId("external_access_name_error")).toHaveTextContent( "该名称已被另一个 ScriptCat 实例使用" ); @@ -241,7 +250,7 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { fireEvent.paste(cell0, { clipboardData: { getData: () => "ABCD1234" } }); fireEvent.change(nameInput, { target: { value: "Home Browser" } }); expect(await screen.findByTestId("external_access_enroll_name_error")).toHaveTextContent( - "名称需为 1–32 位小写字母、数字或 -。" + "名称需为 1–32 位小写字母、数字或 -,且不能是 scriptcat。" ); expect(screen.getByTestId("external_access_enroll_submit")).toBeDisabled(); }); @@ -266,6 +275,21 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { expect(notify.info).not.toHaveBeenCalled(); }); + it("接入请求本身失败时提示失败并保留对话框以便重试", async () => { + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); + enroll.mockRejectedValue(new Error("offscreen unavailable")); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_enroll_open")); + const cell0 = await screen.findByTestId("external_access_enroll_code-cell-0"); + fireEvent.paste(cell0, { clipboardData: { getData: () => "ABCD1234" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_enroll_submit")); + }); + expect(notify.error).toHaveBeenCalledWith("接入失败,请重试"); + expect(notify.info).not.toHaveBeenCalled(); + expect(screen.getByTestId("external_access_enroll_submit")).toBeEnabled(); + }); + it("被 sctl 拒绝(refused)时说明原因并可重新接入", async () => { getBridgeStatus.mockResolvedValue({ status: "rejected", reason: "refused", name: "work" }); await renderSection(); diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx index be71b4eeb..f15561422 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx @@ -37,7 +37,11 @@ import type { ExternalAccessBridgeStatus, ExternalAccessBridgeStatusInfo, } from "@App/app/service/service_worker/external_access/types"; -import type { ExternalAccessWritePolicy, ExternalAccessSourceReadPolicy } from "@App/pkg/config/config"; +import { + isValidExternalAccessName, + type ExternalAccessWritePolicy, + type ExternalAccessSourceReadPolicy, +} from "@App/pkg/config/config"; import { notify } from "@App/pages/components/ui/toast"; import { DocumentationSite } from "@App/app/const"; import { localePath } from "@App/locales/locales"; @@ -59,9 +63,6 @@ const AUDIT_QUERY = encodeURIComponent(JSON.stringify([{ key: "component", value // `sctl connect` 打印一次性配对码,只在终端显示、绝不过线;用户读到后填入接入对话框。 const ENROLL_COMMAND = "sctl connect"; -// 与 sctl 的实例名称校验保持一致:不合规的输入在页面就地拦下,不发给 daemon。 -const NAME_PATTERN = /^[a-z0-9-]{1,32}$/; - // 文档站只有中文与英文两版,localePath 按界面语言在两者间切换(同 agentDocUrl)。 const docUrl = () => `${DocumentationSite}${localePath}/docs/use/external-access/`; @@ -190,8 +191,8 @@ export function ExternalAccessSection({ register }: { register: (id: string) => if (trimmed) systemConfig.setExternalAccessUrl(trimmed); }; - const nameInvalid = !NAME_PATTERN.test(nameDraft); - const enrollNameInvalid = !NAME_PATTERN.test(enrollName); + const nameInvalid = !isValidExternalAccessName(nameDraft); + const enrollNameInvalid = !isValidExternalAccessName(enrollName); // 对话框可能从待接入态或拒绝态的「重新接入」打开;两处都用当前名称预填并清掉上一轮的码与提示。 const openEnroll = () => { @@ -234,6 +235,8 @@ export function ExternalAccessSection({ register }: { register: (id: string) => setShowEnroll(false); // 名称问题之外的结果沿用原有的进行中提示:sctl 未运行时 offscreen 仍在重连,最终成败以状态胶囊为准。 notify.info(t("external_access:enroll_started")); + } catch { + notify.error(t("external_access:enroll_failed")); } finally { setEnrolling(false); } diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index 33cd7dcaf..6b25afe51 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { SystemConfig } from "./config"; import * as browserUtils from "../utils/utils"; -import { mockIdentityLock } from "@App/app/service/service_worker/external_access/test-locks"; +import { mockWebLocks } from "@Tests/mockWebLocks"; import { MessageQueue } from "@Packages/message/message_queue"; describe("SystemConfig 双 storage 与懒迁移", () => { @@ -9,7 +9,7 @@ describe("SystemConfig 双 storage 与懒迁移", () => { let config: SystemConfig; beforeEach(() => { - mockIdentityLock(); + mockWebLocks(); // 清空 storage 数据 chrome.storage.sync.clear(); chrome.storage.local.clear(); diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index ca4827b8c..89d8b0e5c 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -56,6 +56,12 @@ export type ExternalAccessIdentity = { name: string; }; +// 与 sctl 的实例名称规则一致。scriptcat 是 sctl 为旧版 ScriptCat 保留的名称,daemon 必然拒绝; +// 接入时它会在认证后才被拒,白白消耗一次性配对码,所以在本地就拦下。 +export function isValidExternalAccessName(name: unknown): name is string { + return typeof name === "string" && /^[a-z0-9-]{1,32}$/.test(name) && name !== "scriptcat"; +} + export function externalAccessProduct() { const browser = getBrowserType(); if (browser.firefox) return "Firefox"; @@ -66,10 +72,11 @@ export function externalAccessProduct() { } // getBrowserVersion() 只认 Chrome/ 标记;Firefox 与 Safari 的 UA 不带它,各自从品牌版本标记读取。 +// UA 可被用户改写;sctl 限定自报文本不超过 64 字节,超长会被 daemon 断开,而这里的匹配结果只含 ASCII 数字与点。 export function externalAccessProductVersion(): string | undefined { const browser = getBrowserType(); - if (browser.firefox) return navigator.userAgent.match(/Firefox\/(\d[\d.]*)/)?.[1]; - if (browser.webkit) return navigator.userAgent.match(/Version\/(\d[\d.]*)/)?.[1]; + if (browser.firefox) return navigator.userAgent.match(/Firefox\/(\d[\d.]*)/)?.[1]?.slice(0, 64); + if (browser.webkit) return navigator.userAgent.match(/Version\/(\d[\d.]*)/)?.[1]?.slice(0, 64); if (!browser.chrome) return undefined; const version = getBrowserVersion(); return Number.isFinite(version) && version > 0 ? String(version) : undefined; @@ -747,7 +754,7 @@ export class SystemConfig { } private async externalAccessIdentity(name?: string): Promise { - // 页面、后台及隐身上下文可能同时首次读取;跨上下文锁保证只生成一份身份。 + // 扩展页面与后台可能同时首次读取;用 Web Locks 串行化读-生成-写,保证只生成一份身份。 // 身份没有历史同步数据,不能走普通配置的 sync → local 迁移路径。 return navigator.locks.request("scriptcat-external-access-identity", async () => { const key = "system_external_access_identity"; diff --git a/src/app/service/service_worker/external_access/test-locks.ts b/tests/mockWebLocks.ts similarity index 63% rename from src/app/service/service_worker/external_access/test-locks.ts rename to tests/mockWebLocks.ts index 6004b3dc0..e3aee8e63 100644 --- a/src/app/service/service_worker/external_access/test-locks.ts +++ b/tests/mockWebLocks.ts @@ -1,7 +1,7 @@ import { vi } from "vitest"; -// happy-dom 没有 Web Locks;模拟同名锁的串行调度,使并发读取走真实的本地存储。 -export function mockIdentityLock() { +// happy-dom 没有 Web Locks。所有锁名共用一条队列串行执行(比真实实现更严格),被锁保护的读写仍走真实的 chrome.storage mock。 +export function mockWebLocks() { let tail = Promise.resolve(); vi.spyOn(navigator, "locks", "get").mockReturnValue({ request: (_name: string, run: () => Promise) => { From 4f9fb6c2964254fe4d21e4654064d347d175e92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 3 Nov 2026 16:50:38 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E6=8E=A5=E5=85=A5=E6=97=B6=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E5=90=8E=E6=81=A2=E5=A4=8D=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=89=8D=E7=9A=84=E5=AE=9E=E4=BE=8B=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external_access/controller.test.ts | 13 +++++++++ .../external_access/controller.ts | 27 ++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/external_access/controller.test.ts b/src/app/service/service_worker/external_access/controller.test.ts index 02c2677ae..39979bbde 100644 --- a/src/app/service/service_worker/external_access/controller.test.ts +++ b/src/app/service/service_worker/external_access/controller.test.ts @@ -249,6 +249,19 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { expect(connectClient.connect).toHaveBeenCalledTimes(2); }); + it("接入时名称冲突恢复接入前的实例名称", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + await fake.relayDisconnected("refused"); + const before = (await systemConfig.getExternalAccessIdentity()).name; + const result = controller.enroll("CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + await fake.relayDisconnected("name-taken", "pairing"); + expect(await result).toBe("name-taken"); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(before); + expect((await controller.getStatus()).name).toBe(before); + }); + it("SW 在配对握手期间重启,配对完成时仍保存新密钥并转为已连接", async () => { systemConfig.setExternalAccessEnabled(true); const controller = makeController(); diff --git a/src/app/service/service_worker/external_access/controller.ts b/src/app/service/service_worker/external_access/controller.ts index af1eddb2e..a10ab1f54 100644 --- a/src/app/service/service_worker/external_access/controller.ts +++ b/src/app/service/service_worker/external_access/controller.ts @@ -32,6 +32,14 @@ const REJECTION_KEY = "externalAccessRejection"; // backoff, live in offscreen (src/app/service/offscreen/external-access-connect.ts). type ConnectDriver = Pick; +type PendingOperation = { + resolve: (result: ExternalAccessEnrollResult) => void; + renameTo?: string; + rollback?: boolean; + // 接入前的名称:配对时名称冲突要恢复它,否则下次接入会预填一个已知被占用的名称。 + restoreName?: string; +}; + /** * SW-side coordinator for 外部接入 (External Access). Owns the status machine and drives the * offscreen WS client for transport. Trust is flat (design §2.3): a single enrollment establishes @@ -51,7 +59,7 @@ export class ExternalAccessController { private connecting?: Promise; private generation = 0; private reason?: ExternalAccessRejectionReason; - private pending?: { resolve: (result: ExternalAccessEnrollResult) => void; renameTo?: string; rollback?: boolean }; + private pending?: PendingOperation; constructor( private readonly systemConfig: SystemConfig, @@ -135,9 +143,10 @@ export class ExternalAccessController { if (chrome.extension.inIncognitoContext || this.pending) return "not-connected"; this.active = true; const generation = ++this.generation; - const result = this.beginOperation(); + const { result, op } = this.beginOperation(); try { await chrome.storage.session.remove(REJECTION_KEY); + op.restoreName = (await this.systemConfig.getExternalAccessIdentity()).name; const { instanceId } = await this.systemConfig.setExternalAccessName(name); const url = await this.systemConfig.getExternalAccessUrl(); if (generation === this.generation) { @@ -160,7 +169,7 @@ export class ExternalAccessController { else this.setStatus(this.status); return "applies-next-connection"; } - const result = this.beginOperation(name); + const { result } = this.beginOperation(name); this.active = false; void this.bridge.cancel(); try { @@ -172,10 +181,13 @@ export class ExternalAccessController { return result; } - private beginOperation(renameTo?: string): Promise { - return new Promise((resolve) => { - this.pending = { resolve, renameTo }; + private beginOperation(renameTo?: string): { result: Promise; op: PendingOperation } { + const op: PendingOperation = { resolve: () => undefined, renameTo }; + const result = new Promise((resolve) => { + op.resolve = resolve; }); + this.pending = op; + return { result, op }; } private finishOperation(result: ExternalAccessEnrollResult): void { @@ -268,6 +280,9 @@ export class ExternalAccessController { } const pairingConflict = mode === "pairing" && reason === "name-taken"; const generation = this.generation; + if (pairingConflict && this.pending?.restoreName) { + await this.systemConfig.setExternalAccessName(this.pending.restoreName); + } await chrome.storage.session.set({ [REJECTION_KEY]: pairingConflict ? "pending_enrollment" : reason }); if (generation !== this.generation) return; this.setStatus(pairingConflict ? "pending_enrollment" : "rejected", pairingConflict ? undefined : reason); From bc7620dbe37eb2108860a33597da6679d309c597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 3 Nov 2026 16:50:43 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=92=84=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=A1=B5=EF=BC=9A=E6=89=8B=E6=9C=BA=E5=AE=BD=E5=BA=A6?= =?UTF-8?q?=E4=B8=8B=E9=85=8D=E5=AF=B9=E7=A0=81=E4=B8=8D=E5=86=8D=E6=92=91?= =?UTF-8?q?=E5=87=BA=E5=AF=B9=E8=AF=9D=E6=A1=86=EF=BC=8C=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=BE=BD=E6=A0=87=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=8C=A4=E6=88=90?= =?UTF-8?q?=E7=AB=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/components/ui/pairing-code-input.tsx | 7 ++++--- .../routes/Tools/sections/ExternalAccessSection.tsx | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/components/ui/pairing-code-input.tsx b/src/pages/components/ui/pairing-code-input.tsx index 0b8e71457..963abd0ee 100644 --- a/src/pages/components/ui/pairing-code-input.tsx +++ b/src/pages/components/ui/pairing-code-input.tsx @@ -117,10 +117,10 @@ export function PairingCodeInput({ data-testid={testId} role="group" aria-label={ariaLabel} - className={cn("flex items-center justify-center gap-2", className)} + className={cn("flex items-center justify-center gap-1.5 md:gap-2", className)} > {chars.map((char, index) => ( -
+
{groupSize > 0 && index > 0 && index % groupSize === 0 && ( )} @@ -144,7 +144,8 @@ export function PairingCodeInput({ onPaste={(e) => handlePaste(index, e)} onFocus={(e) => e.target.select()} className={cn( - "size-10 rounded-md border border-input bg-background text-center font-mono text-lg font-semibold uppercase text-foreground shadow-sm outline-none transition-colors", + // 8 格 + 分隔符在 40px 下约 396px 宽,超过手机宽度对话框的内容区;窄屏缩到 32px。 + "size-8 rounded-md border border-input bg-background text-center font-mono text-base font-semibold uppercase text-foreground shadow-sm outline-none transition-colors md:size-10 md:text-lg", "focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary", "disabled:cursor-not-allowed disabled:opacity-50" )} diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx index f15561422..021b71485 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx @@ -76,7 +76,11 @@ function StatusPill({ status, t }: { status: ExternalAccessBridgeStatus; t: (key rejected: "external_access:status_rejected", }; return ( - + {t(labelKey[status])} );