From 4f65b7e8fedc0e2c1b2f010a43c8a3b722095618 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:20:45 +0800 Subject: [PATCH 01/15] fix(google): bound live transcript accumulation --- extensions/google/realtime-voice-provider.ts | 49 +++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/extensions/google/realtime-voice-provider.ts b/extensions/google/realtime-voice-provider.ts index 69f5a9d01687..39b46dbba444 100644 --- a/extensions/google/realtime-voice-provider.ts +++ b/extensions/google/realtime-voice-provider.ts @@ -68,6 +68,9 @@ const GOOGLE_REALTIME_BROWSER_NEW_SESSION_TTL_MS = 60 * 1000; const GOOGLE_REALTIME_RECONNECT_MAX_ATTEMPTS = 3; const GOOGLE_REALTIME_RECONNECT_BASE_DELAY_MS = 250; const GOOGLE_REALTIME_RECONNECT_MAX_DELAY_MS = 2_000; +const GOOGLE_REALTIME_MAX_PENDING_TRANSCRIPT_BYTES = 256 * 1024; +const GOOGLE_REALTIME_TRANSCRIPT_OVERFLOW_MESSAGE = + "Google Live transcript exceeded the 256 KiB UTF-8 pending buffer limit"; // Google Live requires a leading letter/underscore and caps function names at 128 characters. const GOOGLE_REALTIME_TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/; const MULAW_LINEAR_SAMPLES = new Int16Array(256); @@ -143,6 +146,10 @@ type GoogleRealtimeLiveConfig = { type GoogleRealtimeVoiceBridgeConfig = RealtimeVoiceBridgeCreateRequest & GoogleRealtimeLiveConfig; type GoogleLiveTranscription = NonNullable; +type GoogleLiveTranscriptAccumulator = { + text: string; + byteCount: number; +}; function trimToUndefined(value: unknown): string | undefined { return normalizeOptionalString(value); @@ -478,10 +485,13 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge { private closeNotified = false; private connectionOwner: GoogleLiveConnectionAttempt | undefined; private connectAttempt: GoogleLiveConnectionAttempt | undefined; - private readonly pendingTranscripts: Record = { - user: "", - assistant: "", - }; + // Google can interleave independent input/output transcripts, so each role + // owns its own in-progress byte budget until `finished` or terminal cleanup. + private readonly pendingTranscripts: Record = + { + user: { text: "", byteCount: 0 }, + assistant: { text: "", byteCount: 0 }, + }; constructor(private readonly config: GoogleRealtimeVoiceBridgeConfig) { this.audioFormat = config.audioFormat ?? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ; @@ -858,13 +868,17 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge { } if (content.inputTranscription) { - this.appendTranscript("user", content.inputTranscription); + if (!this.appendTranscript("user", content.inputTranscription)) { + return; + } } if (content.outputTranscription) { // outputAudioTranscription is requested in the session config. Keep that // official stream canonical; modelTurn text has no transcript turn identity. - this.appendTranscript("assistant", content.outputTranscription); + if (!this.appendTranscript("assistant", content.outputTranscription)) { + return; + } } for (const part of content.modelTurn?.parts ?? []) { @@ -886,10 +900,18 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge { } } - private appendTranscript(role: RealtimeVoiceRole, transcript: GoogleLiveTranscription): void { + private appendTranscript(role: RealtimeVoiceRole, transcript: GoogleLiveTranscription): boolean { const text = transcript.text; if (text) { - this.pendingTranscripts[role] += text; + const pending = this.pendingTranscripts[role]; + const textBytes = Buffer.byteLength(text, "utf8"); + if (pending.byteCount + textBytes > GOOGLE_REALTIME_MAX_PENDING_TRANSCRIPT_BYTES) { + this.resetPendingTranscripts(); + this.failConnection(new Error(GOOGLE_REALTIME_TRANSCRIPT_OVERFLOW_MESSAGE)); + return false; + } + pending.text += text; + pending.byteCount += textBytes; this.emitTranscript(role, text, false); } // turnComplete belongs to model generation and is unordered with transcription. @@ -897,11 +919,14 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge { if (transcript.finished) { this.flushPendingTranscript(role); } + return true; } private flushPendingTranscript(role: RealtimeVoiceRole): void { - const completeText = this.pendingTranscripts[role].trim(); - this.pendingTranscripts[role] = ""; + const pending = this.pendingTranscripts[role]; + const completeText = pending.text.trim(); + pending.text = ""; + pending.byteCount = 0; if (completeText) { this.emitTranscript(role, completeText, true); } @@ -927,8 +952,8 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge { } private resetPendingTranscripts(): void { - this.pendingTranscripts.user = ""; - this.pendingTranscripts.assistant = ""; + this.pendingTranscripts.user = { text: "", byteCount: 0 }; + this.pendingTranscripts.assistant = { text: "", byteCount: 0 }; } private failConnection(error: Error): void { From 74918607d10427a088bfae2befe6ebeb1570932d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:20:45 +0800 Subject: [PATCH 02/15] test(google): cover live transcript overflow --- .../google/realtime-voice-provider.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/extensions/google/realtime-voice-provider.test.ts b/extensions/google/realtime-voice-provider.test.ts index 2019fbc7f260..ba7d613470e1 100644 --- a/extensions/google/realtime-voice-provider.test.ts +++ b/extensions/google/realtime-voice-provider.test.ts @@ -1344,6 +1344,85 @@ describe("buildGoogleRealtimeVoiceProvider", () => { ]); }); + it("allows each role's UTF-8 transcript limit and releases it on finished", async () => { + const provider = buildGoogleRealtimeVoiceProvider(); + const onTranscript = vi.fn(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "gemini-key" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + onTranscript, + }); + + await bridge.connect(); + const onmessage = lastConnectParams().callbacks.onmessage; + const halfLimit = "é".repeat(64 * 1024); + onmessage({ + serverContent: { + inputTranscription: { text: halfLimit }, + outputTranscription: { text: halfLimit }, + }, + }); + onmessage({ + serverContent: { + inputTranscription: { text: halfLimit }, + outputTranscription: { text: halfLimit }, + }, + }); + onmessage({ serverContent: { outputTranscription: { finished: true } } }); + onmessage({ serverContent: { inputTranscription: { finished: true } } }); + + expect(onTranscript.mock.calls.filter((call) => call[2] === true)).toEqual([ + ["assistant", `${halfLimit}${halfLimit}`, true], + ["user", `${halfLimit}${halfLimit}`, true], + ]); + expect(session.close).not.toHaveBeenCalled(); + }); + + it("terminates and clears a runaway transcript stream at the UTF-8 byte limit", async () => { + const provider = buildGoogleRealtimeVoiceProvider(); + const onError = vi.fn(); + const onClose = vi.fn(); + const onTranscript = vi.fn(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "gemini-key" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + onError, + onClose, + onTranscript, + }); + + await bridge.connect(); + const callbacks = lastConnectParams().callbacks; + const transcriptChunk = "€".repeat(16); + const acceptedChunks = Math.floor((256 * 1024) / Buffer.byteLength(transcriptChunk, "utf8")); + for (let index = 0; index < 10_000; index += 1) { + callbacks.onmessage({ + serverContent: { + inputTranscription: { text: transcriptChunk }, + }, + }); + } + callbacks.onclose({ code: 1000, reason: "late clean close", wasClean: true }); + + expect(onTranscript).toHaveBeenCalledTimes(acceptedChunks); + expect(onTranscript.mock.calls.at(-1)).toEqual(["user", transcriptChunk, false]); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Google Live transcript exceeded the 256 KiB UTF-8 pending buffer limit", + }), + ); + expect(onTranscript.mock.calls.filter((call) => call[2] === true)).toEqual([]); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("error"); + expect(session.close).toHaveBeenCalledTimes(1); + await expect(bridge.connect()).rejects.toThrow( + "Google Live transcript exceeded the 256 KiB UTF-8 pending buffer limit", + ); + }); + it("retains unordered transcript chunks until a protocol terminal or close", async () => { const provider = buildGoogleRealtimeVoiceProvider(); const onTranscript = vi.fn(); From 65f3e42f24336308cb955063ab7fcddfb604787e Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:56:03 -0600 Subject: [PATCH 03/15] fix(gateway): yield before post-ready background work (#117083) * fix(gateway): yield before post-ready background work * fix(gateway): gate restart recovery after ready --- .../main-session-restart-recovery-runtime.ts | 44 +++-- .../main-session-restart-recovery.test.ts | 81 +++++++++ src/gateway/server-start.ts | 10 ++ src/gateway/server-startup-finish.ts | 8 +- .../server-startup-handler-prewarm.test.ts | 47 ++++++ src/gateway/server-startup-handler-prewarm.ts | 34 ++-- .../server-startup-post-attach.test.ts | 45 +++++ src/gateway/server-startup-post-attach.ts | 159 +++++++++++------- 8 files changed, 337 insertions(+), 91 deletions(-) diff --git a/src/agents/main-session-restart-recovery-runtime.ts b/src/agents/main-session-restart-recovery-runtime.ts index 58fb25f2dff2..da4e74f13b5e 100644 --- a/src/agents/main-session-restart-recovery-runtime.ts +++ b/src/agents/main-session-restart-recovery-runtime.ts @@ -333,6 +333,7 @@ export function scheduleRestartAbortedMainSessionRecovery(params: { maxRetries?: number; shouldContinue?: () => boolean; stateDir?: string; + waitForStart?: () => Promise; gatewayRuntime: GatewayRecoveryRuntime; }): { stop: () => Promise } { const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; @@ -343,12 +344,16 @@ export function scheduleRestartAbortedMainSessionRecovery(params: { let timer: ReturnType | undefined; let queuedAttempt: Promise | undefined; let activeAttempt: Promise | undefined; + let cancelStartWait: (() => void) | undefined; + const startWaitCancelled = new Promise((resolve) => { + cancelStartWait = resolve; + }); const shouldContinue = () => !stopped && params.shouldContinue?.() !== false && isAgentEventLifecycleGenerationCurrent(lifecycleGeneration); - // Only reconcile rows that existed before this startup recovery was scheduled. - // Fresh runs started by this gateway are protected again by the active-run check. + // Capture the cutoff at registration, before any startup gate can release new + // work. Sessions created by this gateway must never become recovery candidates. const startupRecoveryCutoffMs = Date.now(); const runRecoveryAttempt = (attempt: number, delay: number) => { @@ -436,28 +441,36 @@ export function scheduleRestartAbortedMainSessionRecovery(params: { activeAttempt = trackedAttempt; }; + const queueRecoveryAttempt = (attempt: number, delay: number) => { + const pendingStart = Promise.resolve().then(async () => { + if (attempt === 1 && params.waitForStart) { + // Shutdown must cancel an unresolved startup gate so failed startup and + // same-port replacement cannot hang while joining this lifetime owner. + await Promise.race([params.waitForStart(), startWaitCancelled]); + } + if (shouldContinue()) { + runRecoveryAttempt(attempt, delay); + } + }); + const trackedStart = pendingStart.finally(() => { + if (queuedAttempt === trackedStart) { + queuedAttempt = undefined; + } + }); + queuedAttempt = trackedStart; + }; + const scheduleAttempt = (attempt: number, delay: number) => { if (!shouldContinue()) { return; } if (delay <= 0) { - // Publish the cancellable handle before immediate startup can claim a session. - const pendingStart = Promise.resolve().then(() => { - if (shouldContinue()) { - runRecoveryAttempt(attempt, delay); - } - }); - const trackedStart = pendingStart.finally(() => { - if (queuedAttempt === trackedStart) { - queuedAttempt = undefined; - } - }); - queuedAttempt = trackedStart; + queueRecoveryAttempt(attempt, delay); return; } timer = setTimeout(() => { timer = undefined; - runRecoveryAttempt(attempt, delay); + queueRecoveryAttempt(attempt, delay); }, delay); timer.unref?.(); }; @@ -468,6 +481,7 @@ export function scheduleRestartAbortedMainSessionRecovery(params: { // Restart recovery belongs to its startup generation; stale timers must // never claim a session after that gateway begins draining. stopped = true; + cancelStartWait?.(); if (timer) { clearTimeout(timer); timer = undefined; diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index c2074dad1ddc..e3a63d3b289e 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -2387,6 +2387,87 @@ describe("main-session-restart-recovery", () => { }); }); + it("waits for startup release while preserving the registration cutoff", async () => { + const sessionsDir = await makeSessionsDir(); + const storePath = path.join(sessionsDir, "sessions.json"); + const releaseStartup = createDeferred(); + await writeStore(sessionsDir, { + "agent:main:main": { + ...runningSessionEntry("pre-start-session"), + updatedAt: 1, + }, + }); + await writeTranscript(sessionsDir, "pre-start-session", [ + { role: "user", content: "resume the interrupted work" }, + { role: "toolResult", content: "done" }, + ]); + + const recovery = scheduleRestartAbortedMainSessionRecovery({ + cfg: {}, + delayMs: 0, + stateDir: tmpDir, + waitForStart: () => releaseStartup.promise, + }); + await Promise.resolve(); + expect(callGateway).not.toHaveBeenCalled(); + + const postRegistrationUpdatedAt = Date.now() + 60_000; + await writeStore(sessionsDir, { + ...readStore(storePath), + "agent:main:fresh": { + ...runningSessionEntry("post-start-session"), + updatedAt: postRegistrationUpdatedAt, + }, + }); + await writeTranscript(sessionsDir, "post-start-session", [ + { role: "user", content: "new work from this gateway" }, + { role: "toolResult", content: "done" }, + ]); + + releaseStartup.resolve(); + await waitForFast(() => expect(callGateway).toHaveBeenCalledOnce()); + await recovery.stop(); + + const store = readStore(storePath); + expect(store["agent:main:main"]?.abortedLastRun).toBe(false); + expect(store["agent:main:fresh"]).toMatchObject({ + sessionId: "post-start-session", + status: "running", + }); + expect(store["agent:main:fresh"]?.abortedLastRun).toBeUndefined(); + }); + + it("stops without waiting for an unresolved startup release", async () => { + const sessionsDir = await makeSessionsDir(); + const storePath = path.join(sessionsDir, "sessions.json"); + const releaseStartup = createDeferred(); + await writeMainSession({ + sessionsDir, + pendingFinalDelivery: { + kind: "replayable", + text: "interrupted response", + createdAt: Date.now(), + }, + }); + + const recovery = scheduleRestartAbortedMainSessionRecovery({ + cfg: {}, + delayMs: 0, + stateDir: tmpDir, + waitForStart: () => releaseStartup.promise, + }); + await recovery.stop(); + releaseStartup.resolve(); + await Promise.resolve(); + + expect(callGateway).not.toHaveBeenCalled(); + expect(getActiveGatewayRootWorkCount()).toBe(0); + expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({ + status: "running", + abortedLastRun: true, + }); + }); + it("fences an in-flight startup recovery before its durable session claim", async () => { const sessionsDir = await makeSessionsDir(); const storePath = path.join(sessionsDir, "sessions.json"); diff --git a/src/gateway/server-start.ts b/src/gateway/server-start.ts index 0111dbda694e..30070eed9281 100644 --- a/src/gateway/server-start.ts +++ b/src/gateway/server-start.ts @@ -78,6 +78,7 @@ const logPlugins = log.child("plugins"); const logWsControl = log.child("ws"); const logSecrets = log.child("secrets"); const gatewayRuntime = runtimeForLogger(log); +const POST_READY_WORK_START_DELAY_MS = 500; function formatRuntimeGatewayAuthTokenWarning(): string { const base = @@ -101,6 +102,10 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { + let releasePostReadyWork: () => void = () => {}; + const postReadyWorkBarrier = new Promise((resolve) => { + releasePostReadyWork = resolve; + }); const bootstrap = await prepareGatewayServerBootstrap({ port, opts, @@ -168,11 +173,16 @@ export async function startGatewayServer( logReload, logTailscale, loadGatewayStartupPostAttachModule, + waitForPostReadyWork: () => postReadyWorkBarrier, }); } catch (err) { await closeOnStartupFailure(); throw err; } + // The public server is fully initialized now. Leave a short I/O window before + // background prewarms and cleanup imports compete for the startup CPU. + const postReadyWorkTimer = setTimeout(releasePostReadyWork, POST_READY_WORK_START_DELAY_MS); + postReadyWorkTimer.unref?.(); const close = createCloseHandler(); diff --git a/src/gateway/server-startup-finish.ts b/src/gateway/server-startup-finish.ts index 54dd8d38d352..833be30f0324 100644 --- a/src/gateway/server-startup-finish.ts +++ b/src/gateway/server-startup-finish.ts @@ -30,8 +30,7 @@ import { type GatewayCoreRuntime = Awaited>; type GatewayLogger = ReturnType; -const POST_READY_MAINTENANCE_DELAY_MS = 250; -const RETAINED_PLUGIN_CLEANUP_DELAY_MS = 30_000; +const [POST_READY_MAINTENANCE_DELAY_MS, RETAINED_PLUGIN_CLEANUP_DELAY_MS] = [250, 30_000]; export async function finishGatewayStartup(params: { coreRuntime: GatewayCoreRuntime; @@ -48,6 +47,7 @@ export async function finishGatewayStartup(params: { loadGatewayStartupPostAttachModule: () => Promise< typeof import("./server-startup-post-attach.js") >; + waitForPostReadyWork: () => Promise; }) { const { coreRuntime: runtime, @@ -342,7 +342,6 @@ export async function finishGatewayStartup(params: { import("./server/plugins-http/route-capability.js"), ]), ); - const pluginSurfaceScheme = gatewayTls.enabled ? "https" : "http"; await startupTrace.measure("gateway.ws-attach", () => attachGatewayWsHandlers({ wss, @@ -350,7 +349,7 @@ export async function finishGatewayStartup(params: { preauthConnectionBudget, port, gatewayHost: bindHost ?? undefined, - pluginSurfaceScheme, + pluginSurfaceScheme: gatewayTls.enabled ? "https" : "http", getPluginNodeCapabilities: () => withCoreCanvasNodeCapability( listPluginNodeCapabilities(pluginRuntime.registry), @@ -530,6 +529,7 @@ export async function finishGatewayStartup(params: { isClosing: () => lifecycle.closePreludeStarted, startupTrace, sidecarStartup, + waitForPostReadyWork: params.waitForPostReadyWork, providerAuthPrewarm: { getConfig: getRuntimeConfig, }, diff --git a/src/gateway/server-startup-handler-prewarm.test.ts b/src/gateway/server-startup-handler-prewarm.test.ts index 3bb59cb3ff42..b955dce9f62f 100644 --- a/src/gateway/server-startup-handler-prewarm.test.ts +++ b/src/gateway/server-startup-handler-prewarm.test.ts @@ -123,6 +123,53 @@ describe("scheduleGatewayHandlerPrewarm", () => { sidecar.stop(); }); + it("waits for gateway readiness before warming handler data", async () => { + vi.useFakeTimers(); + let releaseGatewayReady!: () => void; + const gatewayReady = new Promise((resolve) => { + releaseGatewayReady = resolve; + }); + const load = vi.fn(async () => {}); + + const sidecar = scheduleGatewayHandlerPrewarm({ + cfgAtStart: {} as never, + log: { warn: vi.fn() }, + items: [{ name: "sessions", load }], + waitForPostReadyWork: () => gatewayReady, + }); + + await vi.advanceTimersToNextTimerAsync(); + expect(load).not.toHaveBeenCalled(); + + releaseGatewayReady(); + await vi.runAllTimersAsync(); + expect(load).toHaveBeenCalledOnce(); + sidecar.stop(); + }); + + it("stays stopped when readiness arrives after shutdown", async () => { + vi.useFakeTimers(); + let releaseGatewayReady!: () => void; + const gatewayReady = new Promise((resolve) => { + releaseGatewayReady = resolve; + }); + const load = vi.fn(async () => {}); + + const sidecar = scheduleGatewayHandlerPrewarm({ + cfgAtStart: {} as never, + log: { warn: vi.fn() }, + items: [{ name: "sessions", load }], + waitForPostReadyWork: () => gatewayReady, + }); + + await vi.advanceTimersToNextTimerAsync(); + sidecar.stop(); + releaseGatewayReady(); + await vi.runAllTimersAsync(); + + expect(load).not.toHaveBeenCalled(); + }); + it("logs failures and continues without changing later request behavior", async () => { vi.useFakeTimers(); const warn = vi.fn(); diff --git a/src/gateway/server-startup-handler-prewarm.ts b/src/gateway/server-startup-handler-prewarm.ts index aca124644275..3092ed95aefc 100644 --- a/src/gateway/server-startup-handler-prewarm.ts +++ b/src/gateway/server-startup-handler-prewarm.ts @@ -95,12 +95,14 @@ export function scheduleGatewayHandlerPrewarm(params: { startupTrace?: StartupTrace; log: { warn: (msg: string) => void }; items?: readonly GatewayHandlerPrewarmItem[]; + waitForPostReadyWork?: () => Promise; }): GatewayHandlerPrewarmHandle { // Frequent updater restarts make cold dashboard data the remaining slow tier. // Keep cheap session reads first, process-stable plugin data second, and provider catalogs last. const items = params.items ?? dashboardDataPrewarmItems(params.cfgAtStart); let stopped = false; let nextIndex = 0; + let currentItemName = "unknown"; let timer: ReturnType | undefined; const scheduleNext = () => { @@ -109,23 +111,27 @@ export function scheduleGatewayHandlerPrewarm(params: { } timer = setTimeout(() => { timer = undefined; - if (stopped) { - return; - } - const item = items[nextIndex++]; - if (!item) { - return; - } - const load = () => item.load(); - void runWithGatewayIndependentRootWorkAdmission(() => - params.startupTrace - ? params.startupTrace.measure(`post-ready.gateway-data.${item.name}`, load) - : load(), - ) + void (async () => { + await params.waitForPostReadyWork?.(); + if (stopped) { + return; + } + const item = items[nextIndex++]; + if (!item) { + return; + } + currentItemName = item.name; + const load = () => item.load(); + await runWithGatewayIndependentRootWorkAdmission(() => + params.startupTrace + ? params.startupTrace.measure(`post-ready.gateway-data.${item.name}`, load) + : load(), + ); + })() .catch((err: unknown) => { // Prewarm only improves latency; readiness and request-time loaders remain authoritative. params.log.warn( - `post-ready gateway data prewarm failed for ${item.name}: ${String(err)}`, + `post-ready gateway data prewarm failed for ${currentItemName}: ${String(err)}`, ); }) .finally(scheduleNext); diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 05c0d0c39e04..30bfea703298 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -426,6 +426,7 @@ describe("startGatewayPostAttachRuntime", () => { cfg: { hooks: { internal: { enabled: false } } }, delayMs: 0, shouldContinue: expect.any(Function), + waitForStart: undefined, gatewayRuntime: expect.any(Object), }); expect(hoisted.scheduleSubagentOrphanRecovery).toHaveBeenCalledWith(); @@ -461,6 +462,39 @@ describe("startGatewayPostAttachRuntime", () => { ); }); + it("gates main-session recovery behind post-ready work", async () => { + let releasePostReadyWork!: () => void; + const postReadyWork = new Promise((resolve) => { + releasePostReadyWork = resolve; + }); + let waitForStart: (() => Promise) | undefined; + hoisted.scheduleRestartAbortedMainSessionRecovery.mockImplementationOnce( + (params: { waitForStart?: () => Promise }) => { + waitForStart = params.waitForStart; + return { stop: vi.fn(async () => {}) }; + }, + ); + + await startGatewayPostAttachRuntime({ + ...createPostAttachParams(), + waitForPostReadyWork: () => postReadyWork, + }); + + await waitForGatewayTestState(() => { + expect(waitForStart).toEqual(expect.any(Function)); + }); + let released = false; + const waiting = waitForStart?.().then(() => { + released = true; + }); + await Promise.resolve(); + expect(released).toBe(false); + + releasePostReadyWork(); + await waiting; + expect(released).toBe(true); + }); + it("stops restart recovery with gateway-lifetime sidecars", async () => { const recoverySidecar = { stop: vi.fn() }; hoisted.scheduleRestartAbortedMainSessionRecovery.mockReturnValueOnce(recoverySidecar); @@ -1192,11 +1226,16 @@ describe("startGatewayPostAttachRuntime", () => { it("uses current config when agent runtime plugin prewarm runs", async () => { const startupConfig = { marker: "startup" } as never; const currentConfig = { marker: "current" } as never; + let releaseGatewayReady!: () => void; + const gatewayReady = new Promise((resolve) => { + releaseGatewayReady = resolve; + }); await startGatewayPostAttachRuntime({ ...createPostAttachParams({ gatewayPluginConfigAtStart: startupConfig, }), + waitForPostReadyWork: () => gatewayReady, providerAuthPrewarm: { enabled: false }, agentRuntimePluginPrewarm: { enabled: true, @@ -1205,6 +1244,12 @@ describe("startGatewayPostAttachRuntime", () => { }, }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(hoisted.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + + releaseGatewayReady(); await waitForGatewayTestState(() => { expect(hoisted.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: currentConfig, diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index cf27e691e339..4609dacdff8f 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -304,6 +304,7 @@ function scheduleAgentRuntimePluginPrewarm(params: { warn: (msg: string) => void; }; delayMs?: number; + waitForPostReadyWork?: () => Promise; }): GatewayPostReadySidecarHandle { let stopped = false; let timer: ReturnType | undefined; @@ -311,29 +312,39 @@ function scheduleAgentRuntimePluginPrewarm(params: { timer = setTimeout( () => { timer = undefined; - void runWithGatewayIndependentRootWorkAdmission(async () => { - await measureStartup(params.startupTrace, "post-ready.agent-runtime-plugins", async () => { - if (isStopped()) { - return; - } - const started = performance.now(); - const { ensureRuntimePluginsLoaded } = await import("../agents/runtime-plugins.js"); - const cfg = params.getConfig(); - if (isStopped()) { - return; - } - ensureRuntimePluginsLoaded({ - config: cfg, - workspaceDir: params.workspaceDir, - allowGatewaySubagentBinding: true, - }); - if (!isStopped()) { - params.log.info( - `agent runtime plugins pre-warmed in ${(performance.now() - started).toFixed(0)}ms`, - ); - } + void (async () => { + await params.waitForPostReadyWork?.(); + if (isStopped()) { + return; + } + await runWithGatewayIndependentRootWorkAdmission(async () => { + await measureStartup( + params.startupTrace, + "post-ready.agent-runtime-plugins", + async () => { + if (isStopped()) { + return; + } + const started = performance.now(); + const { ensureRuntimePluginsLoaded } = await import("../agents/runtime-plugins.js"); + const cfg = params.getConfig(); + if (isStopped()) { + return; + } + ensureRuntimePluginsLoaded({ + config: cfg, + workspaceDir: params.workspaceDir, + allowGatewaySubagentBinding: true, + }); + if (!isStopped()) { + params.log.info( + `agent runtime plugins pre-warmed in ${(performance.now() - started).toFixed(0)}ms`, + ); + } + }, + ); }); - }).catch((err: unknown) => { + })().catch((err: unknown) => { params.log.warn(`agent runtime plugin pre-warm failed: ${String(err)}`); }); }, @@ -357,19 +368,23 @@ function schedulePostReadySidecarTask(params: { log: { warn: (msg: string) => void }; run: (isStopped: () => boolean, signal: AbortSignal) => Awaitable; stop?: () => Awaitable; + waitForPostReadyWork?: () => Promise; }): GatewayPostReadySidecarHandle { let stopped = false; const abortController = new AbortController(); const isStopped = () => stopped; const handle = setImmediate(() => { - if (isStopped()) { - return; - } - void runWithGatewayIndependentRootWorkAdmission(async () => { - await measureStartup(params.startupTrace, params.name, () => - params.run(isStopped, abortController.signal), - ); - }).catch((err: unknown) => { + void (async () => { + await params.waitForPostReadyWork?.(); + if (isStopped()) { + return; + } + await runWithGatewayIndependentRootWorkAdmission(async () => { + await measureStartup(params.startupTrace, params.name, () => + params.run(isStopped, abortController.signal), + ); + }); + })().catch((err: unknown) => { params.log.warn(`${params.name} failed after gateway ready: ${String(err)}`); }); }); @@ -456,12 +471,14 @@ function scheduleTranscriptsAutoStartSidecar(params: { cfg: OpenClawConfig; startupTrace?: GatewayStartupTrace; log: { warn: (msg: string) => void }; + waitForPostReadyWork?: () => Promise; }): GatewayPostReadySidecarHandle { let stopTranscriptsAutoStart: (() => Promise) | undefined; return schedulePostReadySidecarTask({ startupTrace: params.startupTrace, name: "sidecars.transcripts-auto-start", log: params.log, + waitForPostReadyWork: params.waitForPostReadyWork, run: async (isStopped) => { const { createTranscriptsAutoStartService } = await import("../agents/tools/transcripts-tool.js"); @@ -619,6 +636,7 @@ export async function startGatewaySidecars(params: { logChannels: { info: (msg: string) => void; error: (msg: string) => void }; startupTrace?: GatewayStartupTrace; startupOutcomes?: GatewayStartupOutcomeRecorder; + waitForPostReadyWork?: () => Promise; }) { const postReadySidecars: GatewayPostReadySidecarHandle[] = []; @@ -793,6 +811,7 @@ export async function startGatewaySidecars(params: { startupTrace: params.startupTrace, name: "sidecars.session-locks", log: params.log, + waitForPostReadyWork: params.waitForPostReadyWork, run: async (isStopped) => { try { const [{ resolveAgentSessionDirs }, { cleanStaleLockFiles }] = await Promise.all([ @@ -818,6 +837,7 @@ export async function startGatewaySidecars(params: { startupTrace: params.startupTrace, name: "sidecars.restart-sentinel", log: params.log, + waitForPostReadyWork: params.waitForPostReadyWork, run: async () => { if (!shouldCheckRestartSentinel()) { return; @@ -835,6 +855,7 @@ export async function startGatewaySidecars(params: { startupTrace: params.startupTrace, name: "sidecars.gmail-watch", log: params.log, + waitForPostReadyWork: params.waitForPostReadyWork, run: async (isStopped, signal) => { const { startGmailWatcherWithLogs } = await import("../hooks/gmail-watcher-lifecycle.js"); if (isStopped()) { @@ -857,6 +878,7 @@ export async function startGatewaySidecars(params: { startupTrace: params.startupTrace, name: "sidecars.gmail-model", log: params.log, + waitForPostReadyWork: params.waitForPostReadyWork, run: async (isStopped) => { const [ { DEFAULT_MODEL, DEFAULT_PROVIDER }, @@ -948,6 +970,7 @@ function createDeferredGatewayUpdateCheck(params: { }; isNixMode: boolean; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; + waitForPostReadyWork?: () => Promise; }): { start: () => void; stop: () => void } { let started = false; let stopped = false; @@ -966,37 +989,47 @@ function createDeferredGatewayUpdateCheck(params: { started = true; // Update checks are intentionally post-attach so startup logging, sidecars, // and Tailscale exposure are not serialized behind network I/O. - setImmediate(() => { + void (async () => { + await params.waitForPostReadyWork?.(); if (stopped) { return; } - void runWithGatewayIndependentRootWorkAdmission( - async () => - await measureStartup(params.startupTrace, "post-attach.update-check", () => - params.runtimeDeps.scheduleGatewayUpdateCheck({ - cfg: params.cfg, - log: params.log, - isNixMode: params.isNixMode, - onUpdateAvailableChange: (updateAvailable) => { - const payload: GatewayUpdateAvailableEventPayload = { updateAvailable }; - params.broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true }); - }, - }), - ), - ) - .then((nextStop) => { - if (stopped) { - nextStop(); - return; - } - stopUpdateCheck = nextStop; - }) - .catch((err: unknown) => { - if (stopped) { - return; - } - params.log.warn(`gateway update check failed to start: ${String(err)}`); - }); + setImmediate(() => { + if (stopped) { + return; + } + void runWithGatewayIndependentRootWorkAdmission( + async () => + await measureStartup(params.startupTrace, "post-attach.update-check", () => + params.runtimeDeps.scheduleGatewayUpdateCheck({ + cfg: params.cfg, + log: params.log, + isNixMode: params.isNixMode, + onUpdateAvailableChange: (updateAvailable) => { + const payload: GatewayUpdateAvailableEventPayload = { updateAvailable }; + params.broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true }); + }, + }), + ), + ) + .then((nextStop) => { + if (stopped) { + nextStop(); + return; + } + stopUpdateCheck = nextStop; + }) + .catch((err: unknown) => { + if (stopped) { + return; + } + params.log.warn(`gateway update check failed to start: ${String(err)}`); + }); + }); + })().catch((err: unknown) => { + if (!stopped) { + params.log.warn(`gateway update check readiness wait failed: ${String(err)}`); + } }); }; @@ -1076,6 +1109,7 @@ export async function startGatewayPostAttachRuntime( delayMs?: number; getConfig?: () => OpenClawConfig; }; + waitForPostReadyWork?: () => Promise; }, runtimeDeps: GatewayPostAttachRuntimeDeps = defaultGatewayPostAttachRuntimeDeps, ) { @@ -1157,6 +1191,7 @@ export async function startGatewayPostAttachRuntime( log: params.log, isNixMode: params.isNixMode, broadcast: params.broadcast, + waitForPostReadyWork: params.waitForPostReadyWork, }); const tailscaleCleanupPromise = params.minimalTestGateway @@ -1221,6 +1256,7 @@ export async function startGatewayPostAttachRuntime( shouldStartPluginServices: () => params.isClosing?.() !== true, broadcastPluginEvent: params.broadcastPluginEvent, startupOutcomes, + waitForPostReadyWork: params.waitForPostReadyWork, }), ); } catch (error) { @@ -1254,6 +1290,7 @@ export async function startGatewayPostAttachRuntime( cfg: params.cfgAtStart, delayMs: 0, shouldContinue: () => params.isClosing?.() !== true, + waitForStart: params.waitForPostReadyWork, gatewayRuntime: params.recoveryRuntime, }); } @@ -1295,6 +1332,7 @@ export async function startGatewayPostAttachRuntime( startupTrace: params.startupTrace, log: params.log, delayMs: params.agentRuntimePluginPrewarm?.delayMs, + waitForPostReadyWork: params.waitForPostReadyWork, }), ); } @@ -1314,6 +1352,7 @@ export async function startGatewayPostAttachRuntime( cfg: params.gatewayPluginConfigAtStart, startupTrace: params.startupTrace, log: params.log, + waitForPostReadyWork: params.waitForPostReadyWork, }), ); } @@ -1340,6 +1379,10 @@ export async function startGatewayPostAttachRuntime( if (params.minimalTestGateway) { return; } + await params.waitForPostReadyWork?.(); + if (params.isClosing?.()) { + return; + } schedulePostAttachUpdateSentinelRefresh({ startupTrace: params.startupTrace, log: params.log, From 02457657f012d33e141c710d92671d1bc4a519e9 Mon Sep 17 00:00:00 2001 From: ml12580 Date: Sat, 1 Aug 2026 11:05:16 +0800 Subject: [PATCH 04/15] fix(tlon): wire autoDiscoverChannels through the settings-store round-trip (#114949) parseSettingsResponse and applySettingsUpdate read/wrote a dead `autoDiscover` key, while every consumer (monitor/settings-helpers.ts) and the settings migration use `autoDiscoverChannels`. As a result the settings-store hot-reload override for auto-discover-channels never took effect and the migration re-fired on every restart. Align the field name and add a regression test covering both the load and subscription paths. Co-authored-by: Claude --- extensions/tlon/src/settings.test.ts | 71 ++++++++++++++++++++++++++++ extensions/tlon/src/settings.ts | 10 ++-- 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 extensions/tlon/src/settings.test.ts diff --git a/extensions/tlon/src/settings.test.ts b/extensions/tlon/src/settings.test.ts new file mode 100644 index 000000000000..1073b159c50c --- /dev/null +++ b/extensions/tlon/src/settings.test.ts @@ -0,0 +1,71 @@ +// Tlon tests cover settings store behavior. +import { describe, expect, it } from "vitest"; +import { createSettingsManager } from "./settings.js"; +import type { UrbitSSEClient } from "./urbit/sse-client.js"; + +type SubscriptionHandlers = { + event?: (data: unknown) => Promise | void; +}; + +function createMockSettingsApi(scryResult: unknown): { + api: UrbitSSEClient; + emitSettingsEvent: (event: unknown) => Promise; +} { + const handlers: SubscriptionHandlers = {}; + const api = { + async scry() { + return scryResult; + }, + async subscribe(params: { + app: string; + path: string; + event?: (data: unknown) => Promise | void; + }) { + handlers.event = params.event; + return 1; + }, + } as unknown as UrbitSSEClient; + return { + api, + emitSettingsEvent: async (event: unknown) => { + await handlers.event?.(event); + }, + }; +} + +describe("tlon settings store", () => { + it("loads autoDiscoverChannels from the settings-store scry response", async () => { + const { api } = createMockSettingsApi({ + all: { moltbot: { tlon: { autoDiscoverChannels: true } } }, + }); + + const manager = createSettingsManager(api); + await manager.load(); + + // Regression: parseSettingsResponse previously read the dead `autoDiscover` + // key, so the live `autoDiscoverChannels` override never reached the monitor. + expect(manager.current.autoDiscoverChannels).toBe(true); + }); + + it("applies live autoDiscoverChannels updates delivered over the subscription", async () => { + const { api, emitSettingsEvent } = createMockSettingsApi({ + all: { moltbot: { tlon: {} } }, + }); + + const manager = createSettingsManager(api); + await manager.load(); + expect(manager.current.autoDiscoverChannels).toBeUndefined(); + + await manager.startSubscription(); + await emitSettingsEvent({ + "put-entry": { + desk: "moltbot", + "bucket-key": "tlon", + "entry-key": "autoDiscoverChannels", + value: false, + }, + }); + + expect(manager.current.autoDiscoverChannels).toBe(false); + }); +}); diff --git a/extensions/tlon/src/settings.ts b/extensions/tlon/src/settings.ts index d42447d1138e..4814aaa5ceb9 100644 --- a/extensions/tlon/src/settings.ts +++ b/extensions/tlon/src/settings.ts @@ -34,7 +34,6 @@ export type PendingApproval = { export type TlonSettingsStore = { groupChannels?: string[]; dmAllowlist?: string[]; - autoDiscover?: boolean; showModelSig?: boolean; autoAcceptDmInvites?: boolean; autoDiscoverChannels?: boolean; @@ -118,7 +117,10 @@ function parseSettingsResponse(raw: unknown): TlonSettingsStore { dmAllowlist: Array.isArray(settings.dmAllowlist) ? settings.dmAllowlist.filter((x): x is string => typeof x === "string") : undefined, - autoDiscover: typeof settings.autoDiscover === "boolean" ? settings.autoDiscover : undefined, + autoDiscoverChannels: + typeof settings.autoDiscoverChannels === "boolean" + ? settings.autoDiscoverChannels + : undefined, showModelSig: typeof settings.showModelSig === "boolean" ? settings.showModelSig : undefined, autoAcceptDmInvites: typeof settings.autoAcceptDmInvites === "boolean" ? settings.autoAcceptDmInvites : undefined, @@ -249,8 +251,8 @@ function applySettingsUpdate( ? value.filter((x): x is string => typeof x === "string") : undefined; break; - case "autoDiscover": - next.autoDiscover = typeof value === "boolean" ? value : undefined; + case "autoDiscoverChannels": + next.autoDiscoverChannels = typeof value === "boolean" ? value : undefined; break; case "showModelSig": next.showModelSig = typeof value === "boolean" ? value : undefined; From a0fe02a6dcf387375b298d4a0bbd25a75d5c3eb0 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:11:00 -0600 Subject: [PATCH 05/15] fix(telegram): confirm polling before long poll (#116970) --- .../telegram-ingress-worker.runtime.test.ts | 51 +++++++++++++++++-- .../src/telegram-ingress-worker.runtime.ts | 6 ++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/extensions/telegram/src/telegram-ingress-worker.runtime.test.ts b/extensions/telegram/src/telegram-ingress-worker.runtime.test.ts index 99fa9a0c743d..2747ac8ea089 100644 --- a/extensions/telegram/src/telegram-ingress-worker.runtime.test.ts +++ b/extensions/telegram/src/telegram-ingress-worker.runtime.test.ts @@ -25,13 +25,15 @@ function htmlResponse(status: number, body: string): Response { function createRuntime( responses: Response[], - options: { stopAfterPollSuccesses?: number } = {}, + options: { stopAfterPollSuccesses?: number; timeoutSeconds?: number } = {}, ): { calls: number[]; + pollBodies: Array>; messages: TelegramIngressWorkerMessage[]; done: Promise; } { const calls: number[] = []; + const pollBodies: Array> = []; const messages: TelegramIngressWorkerMessage[] = []; const listeners = new Set<(message: TelegramIngressWorkerCommand) => void>(); let pollSuccesses = 0; @@ -62,8 +64,11 @@ function createRuntime( }, close() {}, }; - const fetchImpl: typeof fetch = async () => { + const fetchImpl: typeof fetch = async (_url, init) => { calls.push(Date.now()); + pollBodies.push( + JSON.parse((init?.body as string | undefined) ?? "{}") as Record, + ); const responseIndex = Math.min(calls.length - 1, responses.length - 1); return expectDefined(responses[responseIndex], `Telegram response ${responseIndex}`); }; @@ -74,7 +79,7 @@ function createRuntime( initialUpdateId: null, spoolDir: "/tmp/openclaw-telegram-ingress-worker-test", apiRoot: "https://api.telegram.test", - timeoutSeconds: 1, + timeoutSeconds: options.timeoutSeconds ?? 1, }, port, deps: { @@ -82,7 +87,7 @@ function createRuntime( closeTransport: async () => {}, }, }); - return { calls, messages, done }; + return { calls, pollBodies, messages, done }; } async function flushRuntime(): Promise { @@ -94,6 +99,43 @@ afterEach(() => { }); describe("telegram ingress worker poll cadence", () => { + it("confirms polling connectivity before entering the first long poll", async () => { + vi.useFakeTimers(); + const runtime = createRuntime( + [jsonResponse(200, { ok: true, result: [] }), jsonResponse(200, { ok: true, result: [] })], + { stopAfterPollSuccesses: 2, timeoutSeconds: 30 }, + ); + + await flushRuntime(); + await runtime.done; + + expect(runtime.pollBodies.map((body) => body.timeout)).toEqual([0, 30]); + expect(runtime.messages.filter((message) => message.type === "poll-success")).toHaveLength(2); + }); + + it("keeps short polling until a getUpdates request succeeds", async () => { + vi.useFakeTimers(); + const runtime = createRuntime( + [ + jsonResponse(502, { ok: false, error_code: 502, description: "Bad Gateway" }), + jsonResponse(200, { ok: true, result: [] }), + jsonResponse(200, { ok: true, result: [] }), + ], + { stopAfterPollSuccesses: 2, timeoutSeconds: 30 }, + ); + + await flushRuntime(); + expect(runtime.messages).toContainEqual( + expect.objectContaining({ type: "poll-error", errorCode: 502 }), + ); + await vi.advanceTimersByTimeAsync(1_000); + await flushRuntime(); + await runtime.done; + + expect(runtime.pollBodies.map((body) => body.timeout)).toEqual([0, 0, 30]); + expect(runtime.messages.filter((message) => message.type === "poll-success")).toHaveLength(2); + }); + it("backs off consecutive empty polls without hot spinning", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z")); @@ -225,6 +267,7 @@ describe("telegram ingress worker durable-before-offset", () => { expect(messages).toContainEqual(expect.objectContaining({ type: "spooled", updateId: 42 })); // Second getUpdates must use offset = lastUpdateId + 1 only after spool-ack. expect(pollBodies[1]?.offset).toBe(43); + expect(pollBodies.map((body) => body.timeout)).toEqual([0, 1]); }); }); diff --git a/extensions/telegram/src/telegram-ingress-worker.runtime.ts b/extensions/telegram/src/telegram-ingress-worker.runtime.ts index b397f6815e8d..b8381defc191 100644 --- a/extensions/telegram/src/telegram-ingress-worker.runtime.ts +++ b/extensions/telegram/src/telegram-ingress-worker.runtime.ts @@ -196,6 +196,7 @@ export async function runTelegramIngressWorkerRuntime(params: { let lastUpdateId = options.initialUpdateId; let failures = 0; let consecutiveEmptyPolls = 0; + let pollingConfirmed = false; port.onMessage((message) => { if (message?.type === "stop") { @@ -251,7 +252,9 @@ export async function runTelegramIngressWorkerRuntime(params: { fetch: fetchImpl, url: getUpdatesUrl, body: { - timeout: pollTimeoutSeconds, + // Confirm getUpdates ownership with a completed short poll before + // entering the long poll; request start alone cannot prove connectivity. + timeout: pollingConfirmed ? pollTimeoutSeconds : 0, limit: pollLimit, allowed_updates: resolveTelegramAllowedUpdates(), ...(offset === null ? {} : { offset }), @@ -273,6 +276,7 @@ export async function runTelegramIngressWorkerRuntime(params: { } port.postMessage({ type: "spooled", updateId, queued: result.length }); } + pollingConfirmed = true; failures = 0; port.postMessage({ type: "poll-success", From 5d97721564b182d5e0c906eb1c0b8a51edb5b481 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 11:16:58 +0800 Subject: [PATCH 06/15] fix(agents): prevent fallback after stale lifecycle abort (#117168) Closes #116418 --- src/agents/failover-error.test.ts | 19 +++++++++++++++++ src/agents/failover-error.ts | 27 +++++++++++++++++++++++- src/agents/model-fallback.test.ts | 33 ++++++++++++++++++++++++++++++ src/infra/agent-events.ts | 4 ++-- src/infra/agent-lifecycle-error.ts | 21 +++++++++++++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 src/infra/agent-lifecycle-error.ts diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index bd02f5ee5851..73ecb3d5d01e 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -3,6 +3,7 @@ * Exercises raw error coercion, remediation hints, timeout/auth/billing/rate-limit cases. */ import { describe, expect, it } from "vitest"; +import { createAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js"; import { classifyFailoverSignal } from "./embedded-agent-helpers/errors.js"; import { buildFailoverRemediationHint, @@ -1416,6 +1417,17 @@ describe("failover-error", () => { ).toBe(true); }); + it("returns true for stale gateway lifecycle ownership loss", () => { + const staleLifecycle = createAgentRunStaleLifecycleError(); + expect(isNonProviderRuntimeCoordinationError(staleLifecycle)).toBe(true); + expect( + isNonProviderRuntimeCoordinationError(new Error("wrapper", { cause: staleLifecycle })), + ).toBe(true); + const abortWrapper = new Error("request was aborted", { cause: staleLifecycle }); + abortWrapper.name = "AbortError"; + expect(isNonProviderRuntimeCoordinationError(abortWrapper)).toBe(true); + }); + it("returns true when the coordination error is nested via cause", () => { const wrapped = new Error("wrapper", { cause: makeSessionLockError() }); expect(isNonProviderRuntimeCoordinationError(wrapped)).toBe(true); @@ -1459,6 +1471,13 @@ describe("failover-error", () => { cause: { result: { reason: "missing_tool_result" } }, }), ).toBe(false); + expect( + isNonProviderRuntimeCoordinationError({ + status: 503, + message: "upstream overloaded", + cause: createAgentRunStaleLifecycleError(), + }), + ).toBe(false); expect(isNonProviderRuntimeCoordinationError(null)).toBe(false); expect(isNonProviderRuntimeCoordinationError(undefined)).toBe(false); }); diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index f0ba9f35f2cb..fb65b34f37fe 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -5,6 +5,7 @@ */ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion"; import { formatCliCommand } from "../cli/command-format.js"; +import { isAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js"; import { readErrorName } from "../infra/errors.js"; import { classifyFailoverSignal, @@ -496,6 +497,22 @@ function hasMissingToolResultFailure(err: unknown): boolean { return findErrorProperty(err, readMissingToolResultMarker) === true; } +function hasStaleAgentRunLifecycleFailure(err: unknown): boolean { + return ( + findErrorProperty(err, (candidate) => + isAgentRunStaleLifecycleError(candidate) ? true : undefined, + ) === true + ); +} + +function hasDirectProviderFailureIdentity(err: unknown): boolean { + if (isFailoverError(err)) { + return true; + } + const signal = normalizeDirectErrorSignal(err); + return Boolean(signal.status || signal.code || signal.errorType || signal.provider); +} + /** * True when the error is a local runtime coordination/tool-execution error * rather than a provider/model failure. The model fallback chain must abort on @@ -899,6 +916,13 @@ export function resolveModelFallbackError( if (err instanceof AgentHarnessSessionSupersededError) { return { kind: "coordination", error: err }; } + const staleLifecycleFailure = hasStaleAgentRunLifecycleFailure(err); + if ( + staleLifecycleFailure && + (isAgentRunStaleLifecycleError(err) || !hasDirectProviderFailureIdentity(err)) + ) { + return { kind: "coordination", error: err }; + } // A direct takeover remains a coordination failure unless the dedicated // cleanup wrapper owns a preserved prompt error. Its message alone must not // reclassify session-state loss as a provider failure. @@ -912,7 +936,8 @@ export function resolveModelFallbackError( if ( hasSessionWriteLockContention(err) || hasEmbeddedAttemptSessionTakeover(err) || - hasMissingToolResultFailure(err) + hasMissingToolResultFailure(err) || + staleLifecycleFailure ) { return { kind: "coordination", error: err }; } diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 5f847ca44c6c..7fb79e4f9759 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -5,6 +5,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { TranscriptNotContinuableError } from "../../packages/agent-core/src/errors.js"; import type { OpenClawConfig } from "../config/config.js"; +import { createAgentRunStaleLifecycleError } from "../infra/agent-lifecycle-error.js"; import { onTrustedInternalDiagnosticEvent, resetDiagnosticEventsForTest, @@ -1927,6 +1928,38 @@ describe("runWithModelFallback", () => { expect(run).toHaveBeenCalledTimes(1); }); + it("aborts the fallback chain on stale gateway lifecycle errors (#116418)", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "ollama/qwen3:0.6b", + fallbacks: ["minimax/MiniMax-M3"], + }, + }, + }, + }); + const lifecycleError = createAgentRunStaleLifecycleError(); + const wrappedLifecycleError = new Error("request was aborted", { cause: lifecycleError }); + wrappedLifecycleError.name = "AbortError"; + const run = vi.fn().mockRejectedValue(wrappedLifecycleError); + const onFallbackStep = vi.fn(); + + await expect( + runWithModelFallback({ + cfg, + provider: "ollama", + model: "qwen3:0.6b", + run, + onFallbackStep, + }), + ).rejects.toBe(wrappedLifecycleError); + expect(run).toHaveBeenCalledTimes(1); + expect(onFallbackStep).not.toHaveBeenCalledWith( + expect.objectContaining({ decision: "candidate_failed" }), + ); + }); + it("aborts the fallback chain on transcript continuation failures without candidate_failed attribution", async () => { const cfg = makeCfg({ agents: { diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index fb8d8d2af213..84a78446ff31 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -4,8 +4,8 @@ import { randomUUID } from "node:crypto"; import type { VerboseLevel } from "../auto-reply/thinking.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { notifyListeners, registerListener } from "../shared/listeners.js"; -import { createAbortError } from "./abort-signal.js"; import { hasInvalidLifecycleStartTimestamp } from "./agent-event-lifecycle.js"; +import { createAgentRunStaleLifecycleError } from "./agent-lifecycle-error.js"; import { clearAgentRunUsage, resetAgentRunUsageForTest } from "./agent-run-usage.js"; /** Approval event phase for request/resolution transitions. */ @@ -194,7 +194,7 @@ export function assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration: st if (isAgentEventLifecycleGenerationCurrent(lifecycleGeneration)) { return; } - throw createAbortError("Agent run belongs to a stale gateway lifecycle"); + throw createAgentRunStaleLifecycleError(); } /** Captures immutable lifecycle ownership for one admitted execution. */ diff --git a/src/infra/agent-lifecycle-error.ts b/src/infra/agent-lifecycle-error.ts new file mode 100644 index 000000000000..7c2f304b4e76 --- /dev/null +++ b/src/infra/agent-lifecycle-error.ts @@ -0,0 +1,21 @@ +const AGENT_RUN_STALE_LIFECYCLE_ERROR = "Agent run belongs to a stale gateway lifecycle"; +const AGENT_RUN_STALE_LIFECYCLE_ERROR_CODE = "ERR_STALE_GATEWAY_LIFECYCLE"; + +export function createAgentRunStaleLifecycleError(): Error { + const error = new Error(AGENT_RUN_STALE_LIFECYCLE_ERROR) as Error & { code: string }; + error.name = "AbortError"; + error.code = AGENT_RUN_STALE_LIFECYCLE_ERROR_CODE; + return error; +} + +export function isAgentRunStaleLifecycleError(value: unknown): boolean { + try { + return ( + value instanceof Error && + "code" in value && + value.code === AGENT_RUN_STALE_LIFECYCLE_ERROR_CODE + ); + } catch { + return false; + } +} From 865a7286c8a5073c25822e1859c6431ef42471b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 20:19:57 -0700 Subject: [PATCH 07/15] fix(ollama): honor model requests and pull completion contracts (#117171) Co-authored-by: Peter Steinberger --- extensions/ollama/provider-discovery.test.ts | 6 +- .../node-inference.paired-node.e2e.test.ts | 8 +- extensions/ollama/src/node-inference.test.ts | 10 +-- extensions/ollama/src/provider-models.test.ts | 28 +++++-- extensions/ollama/src/provider-models.ts | 2 +- extensions/ollama/src/setup-pull.test.ts | 77 ++++++++++++++++++- extensions/ollama/src/setup-pull.ts | 40 +++++----- .../src/setup.non-interactive-auth.test.ts | 27 +++++-- extensions/ollama/src/setup.test.ts | 18 ++--- 9 files changed, 155 insertions(+), 61 deletions(-) diff --git a/extensions/ollama/provider-discovery.test.ts b/extensions/ollama/provider-discovery.test.ts index c8ba8eb0134c..5e66ddb39617 100644 --- a/extensions/ollama/provider-discovery.test.ts +++ b/extensions/ollama/provider-discovery.test.ts @@ -215,11 +215,11 @@ describe("Ollama provider", () => { if (url.endsWith("/api/show")) { const rawBody = init?.body; const bodyText = typeof rawBody === "string" ? rawBody : "{}"; - const parsed = JSON.parse(bodyText) as { name?: string }; - if (parsed.name === "qwen3:32b") { + const parsed = JSON.parse(bodyText) as { model?: string }; + if (parsed.model === "qwen3:32b") { return jsonResponse({ model_info: { "qwen3.context_length": 131072 } }); } - if (parsed.name === "llama3.3:70b") { + if (parsed.model === "llama3.3:70b") { return jsonResponse({ model_info: { "llama.context_length": 65536 } }); } } diff --git a/extensions/ollama/src/node-inference.paired-node.e2e.test.ts b/extensions/ollama/src/node-inference.paired-node.e2e.test.ts index e412a9885d81..acebd2d8873c 100644 --- a/extensions/ollama/src/node-inference.paired-node.e2e.test.ts +++ b/extensions/ollama/src/node-inference.paired-node.e2e.test.ts @@ -607,13 +607,7 @@ async function handleFakeOllamaRequest( } if (requestPath === "/api/show") { const body = await readRequestJson(request); - // Ollama documents `model`; the current provider sends its supported `name` alias. - const modelName = - typeof body.model === "string" - ? body.model - : typeof body.name === "string" - ? body.name - : undefined; + const modelName = typeof body.model === "string" ? body.model : undefined; if (!modelName) { response.statusCode = 400; response.end(JSON.stringify({ error: "model is required" })); diff --git a/extensions/ollama/src/node-inference.test.ts b/extensions/ollama/src/node-inference.test.ts index e40b81d26747..ba5d5668951a 100644 --- a/extensions/ollama/src/node-inference.test.ts +++ b/extensions/ollama/src/node-inference.test.ts @@ -79,16 +79,16 @@ async function withOllamaServer( return; } if (request.url === "/api/show") { - const body = (await readBody(request)) as { name?: string }; - if (body.name) { - showRequests.push(body.name); + const body = (await readBody(request)) as { model?: string }; + if (body.model) { + showRequests.push(body.model); } - if (body.name === "unknown:latest") { + if (body.model === "unknown:latest") { response.statusCode = 500; response.end(JSON.stringify({ error: "show failed" })); return; } - const embedding = body.name?.startsWith("embedding") === true; + const embedding = body.model?.startsWith("embedding") === true; response.end( JSON.stringify({ capabilities: embedding ? ["embedding"] : ["completion", "tools"], diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index 3be6e9e3d436..9e3d77c60bd6 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -50,6 +50,18 @@ describe("ollama provider models", () => { expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434"); }); + it("inspects local models using Ollama's canonical model request field", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse({ model_info: {} }), + ); + vi.stubGlobal("fetch", fetchMock); + + await readOllamaModelShowInfo("http://127.0.0.1:11434", "gemma4:e2b"); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(JSON.parse(requestBodyText(request?.body))).toEqual({ model: "gemma4:e2b" }); + }); + it("caps local discovered runtime context while preserving native metadata", () => { const provider = capLocalOllamaProviderContext({ api: "ollama", @@ -93,8 +105,8 @@ describe("ollama provider models", () => { if (!url.endsWith("/api/show")) { throw new Error(`Unexpected fetch: ${url}`); } - const body = JSON.parse(requestBodyText(init?.body)) as { name?: string }; - if (body.name === "llama3:8b") { + const body = JSON.parse(requestBodyText(init?.body)) as { model?: string }; + if (body.model === "llama3:8b") { return jsonResponse({ model_info: { "llama.context_length": 65536 } }); } return jsonResponse({}); @@ -161,8 +173,8 @@ describe("ollama provider models", () => { }); } if (url.endsWith("/api/show")) { - const body = JSON.parse(requestBodyText(init?.body)) as { name?: string }; - const completion = body.name === "qwen-chat:latest"; + const body = JSON.parse(requestBodyText(init?.body)) as { model?: string }; + const completion = body.model === "qwen-chat:latest"; return jsonResponse({ capabilities: completion ? ["completion", "tools"] : ["embedding"], model_info: completion ? { "qwen.context_length": 32_768 } : {}, @@ -275,14 +287,14 @@ describe("ollama provider models", () => { if (!url.endsWith("/api/show")) { throw new Error(`Unexpected fetch: ${url}`); } - const body = JSON.parse(requestBodyText(init?.body)) as { name?: string }; - if (body.name === "kimi-k2.5:cloud") { + const body = JSON.parse(requestBodyText(init?.body)) as { model?: string }; + if (body.model === "kimi-k2.5:cloud") { return jsonResponse({ model_info: { "kimi-k2.context_length": 262144 }, capabilities: ["vision", "thinking", "completion", "tools"], }); } - if (body.name === "glm-5.1:cloud") { + if (body.model === "glm-5.1:cloud") { return jsonResponse({ model_info: { "glm5.context_length": 202752 }, capabilities: ["thinking", "completion", "tools"], @@ -409,7 +421,7 @@ describe("ollama provider models", () => { const model: OllamaTagModel = { name: "qwen3:32b", digest: "sha256:normalized-base" }; const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { expect(requestUrl(input)).toBe("http://127.0.0.1:11434/api/show"); - expect(JSON.parse(requestBodyText(init?.body))).toEqual({ name: "qwen3:32b" }); + expect(JSON.parse(requestBodyText(init?.body))).toEqual({ model: "qwen3:32b" }); return jsonResponse({ model_info: { "qwen3.context_length": 131072 }, capabilities: ["thinking", "tools"], diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index b282c56ac214..c1a392e527a1 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -172,7 +172,7 @@ export async function readOllamaModelShowInfo( init: { method: "POST", headers, - body: JSON.stringify({ name: modelName }), + body: JSON.stringify({ model: modelName }), }, // Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not. timeoutMs: Math.min(opts?.timeoutMs ?? OLLAMA_SHOW_TIMEOUT_MS, OLLAMA_SHOW_TIMEOUT_MS), diff --git a/extensions/ollama/src/setup-pull.test.ts b/extensions/ollama/src/setup-pull.test.ts index 62547b12492b..025fc04476b5 100644 --- a/extensions/ollama/src/setup-pull.test.ts +++ b/extensions/ollama/src/setup-pull.test.ts @@ -1,6 +1,6 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { pullOllamaModel } from "./setup-pull.js"; +import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); @@ -17,6 +17,81 @@ describe("Ollama onboarding model pulls", () => { fetchWithSsrFGuardMock.mockReset(); }); + it("uses the canonical Ollama model request field and requires its success terminal", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response('{"status":"pulling manifest"}\n{"status":"success"}\n'), + release, + }); + const progress = { update: vi.fn(), stop: vi.fn() }; + const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter; + + await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe( + true, + ); + + const request = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as { init?: { body?: string } }; + expect(JSON.parse(request.init?.body ?? "null")).toEqual({ model: "gemma4:e2b" }); + expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e2b"); + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + { label: "empty response", body: "" }, + { label: "interrupted manifest download", body: '{"status":"pulling manifest"}\n' }, + { + label: "interrupted model layer", + body: '{"status":"pulling abcdef123456","total":100,"completed":40}\n', + }, + { label: "malformed stream", body: "not valid json\n" }, + { label: "incomplete trailing record", body: '{"status":"success"' }, + ])("does not report a completed model pull for an $label", async ({ body }) => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response(body), release }); + const progress = { update: vi.fn(), stop: vi.fn() }; + const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter; + + await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe( + false, + ); + + expect(progress.stop).toHaveBeenCalledWith( + "Failed to download gemma4:e2b: pull stream ended before success", + ); + expect(release).toHaveBeenCalledOnce(); + }); + + it("accepts a final success record without a trailing newline", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response('{"status":"success"}'), + release: vi.fn(async () => {}), + }); + const progress = { update: vi.fn(), stop: vi.fn() }; + const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter; + + await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe( + true, + ); + expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e2b"); + }); + + it("reports interrupted pulls as failures during non-interactive setup", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response('{"status":"pulling manifest"}\n'), + release: vi.fn(async () => {}), + }); + const runtime = { log: vi.fn(), error: vi.fn() }; + + await expect( + pullOllamaModelNonInteractive("http://127.0.0.1:11434", "gemma4:e2b", runtime as never), + ).resolves.toBe(false); + + expect(runtime.error).toHaveBeenCalledWith( + "Failed to download gemma4:e2b: pull stream ended before success", + ); + expect(runtime.log).not.toHaveBeenCalledWith("Downloaded gemma4:e2b"); + }); + it("coerces non-Error stream failures through the shared error contract", async () => { const release = vi.fn(async () => {}); const response = new Response( diff --git a/extensions/ollama/src/setup-pull.ts b/extensions/ollama/src/setup-pull.ts index 81e8389b3768..b5c8b017187f 100644 --- a/extensions/ollama/src/setup-pull.ts +++ b/extensions/ollama/src/setup-pull.ts @@ -68,7 +68,7 @@ async function pullOllamaModelCore(params: { init: { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: modelName }), + body: JSON.stringify({ model: modelName }), }, signal: params.signal ? AbortSignal.any([responseController.signal, params.signal]) @@ -92,28 +92,25 @@ async function pullOllamaModelCore(params: { let pendingRecordBytes = 0; const layers = new Map(); - const parseLine = (line: string): OllamaPullResult => { - const trimmed = line.trim(); - if (!trimmed) { - return { ok: true }; + const parseLine = (line: string): OllamaPullResult | undefined => { + if (!line.trim()) { + return undefined; } try { - const chunk = JSON.parse(trimmed) as OllamaPullChunk; + const chunk = JSON.parse(line) as OllamaPullChunk; if (chunk.error) { return { ok: false, message: `Download failed: ${chunk.error}` }; } - if (!chunk.status) { - return { ok: true }; + if (!chunk.status || chunk.status === "success") { + return chunk.status ? { ok: true } : undefined; } if (chunk.total && chunk.completed !== undefined) { layers.set(chunk.status, { total: chunk.total, completed: chunk.completed }); - const totals = [...layers.values()].reduce( - (sum, layer) => ({ - total: sum.total + layer.total, - completed: sum.completed + layer.completed, - }), - { total: 0, completed: 0 }, - ); + const totals = { total: 0, completed: 0 }; + for (const layer of layers.values()) { + totals.total += layer.total; + totals.completed += layer.completed; + } params.onStatus?.( chunk.status, totals.total > 0 ? Math.round((totals.completed / totals.total) * 100) : null, @@ -124,14 +121,18 @@ async function pullOllamaModelCore(params: { } catch { // Ignore malformed streaming lines from Ollama. } - return { ok: true }; + return undefined; }; try { for (;;) { const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader); if (done) { - return parseLine(buffer); + const terminal = parseLine(buffer); + if (terminal) { + return terminal; + } + throw new Error("pull stream ended before success"); } pendingRecordBytes = checkNdjsonRecordCap(value, pendingRecordBytes); buffer += decoder.decode(value, { stream: true }); @@ -139,7 +140,7 @@ async function pullOllamaModelCore(params: { buffer = lines.pop() ?? ""; for (const line of lines) { const parsed = parseLine(line); - if (!parsed.ok) { + if (parsed) { return parsed; } } @@ -154,8 +155,7 @@ async function pullOllamaModelCore(params: { await release(); } } catch (err) { - const reason = formatErrorMessage(err); - return { ok: false, message: `Failed to download ${modelName}: ${reason}` }; + return { ok: false, message: `Failed to download ${modelName}: ${formatErrorMessage(err)}` }; } finally { clearTimeout(responseTimeout); } diff --git a/extensions/ollama/src/setup.non-interactive-auth.test.ts b/extensions/ollama/src/setup.non-interactive-auth.test.ts index c732b9d8f012..5569cfcd5858 100644 --- a/extensions/ollama/src/setup.non-interactive-auth.test.ts +++ b/extensions/ollama/src/setup.non-interactive-auth.test.ts @@ -44,9 +44,11 @@ function createOllamaFetchMock(params: { return jsonResponse({ models: params.tags.map((name) => ({ name })) }); } if (url.endsWith("/api/show")) { - const body = JSON.parse(requestBodyText(init?.body)) as { name?: string }; - const contextWindow = body.name ? params.show?.[body.name] : undefined; - const capabilities = body.name ? (params.capabilities?.[body.name] ?? ["tools"]) : ["tools"]; + const body = JSON.parse(requestBodyText(init?.body)) as { model?: string }; + const contextWindow = body.model ? params.show?.[body.model] : undefined; + const capabilities = body.model + ? (params.capabilities?.[body.model] ?? ["tools"]) + : ["tools"]; return jsonResponse({ ...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}), capabilities, @@ -73,10 +75,21 @@ describe("Ollama non-interactive onboarding", () => { upsertAuthProfileWithLock.mockClear(); }); - it("does not persist local auth when non-interactive setup cannot select a model", async () => { + it.each([ + { + label: "Ollama reports a pull failure", + body: '{"error":"disk full"}\n', + error: "Download failed: disk full", + }, + { + label: "the model pull ends before success", + body: '{"status":"pulling manifest"}\n', + error: "Failed to download missing-model: pull stream ended before success", + }, + ])("does not persist unavailable local models when $label", async ({ body, error }) => { const fetchMock = createOllamaFetchMock({ tags: [], - pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }), + pullResponse: new Response(body, { status: 200 }), }); vi.stubGlobal("fetch", fetchMock); const runtime = createRuntime(); @@ -91,7 +104,7 @@ describe("Ollama non-interactive onboarding", () => { runtime, }); - expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full"); + expect(runtime.error).toHaveBeenCalledWith(error); expect(runtime.error).toHaveBeenCalledWith( [ "No Ollama models are available at http://127.0.0.1:11434.", @@ -186,7 +199,7 @@ describe("Ollama non-interactive onboarding", () => { return false; } const init = call[1] as RequestInit | undefined; - return JSON.parse(requestBodyText(init?.body)).name === modelId; + return JSON.parse(requestBodyText(init?.body)).model === modelId; }), ).toHaveLength(1); }); diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 103cd3e95e94..96e0a210db55 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -56,12 +56,12 @@ function createOllamaFetchMock(params: { return jsonResponse({ models: (params.tags ?? []).map((name) => ({ name })) }); } if (url.endsWith("/api/show")) { - const body = JSON.parse(requestBodyText(init?.body)) as { name?: string }; - const contextWindow = body.name ? params.show?.[body.name] : undefined; - const capabilities = body.name + const body = JSON.parse(requestBodyText(init?.body)) as { model?: string }; + const contextWindow = body.model ? params.show?.[body.model] : undefined; + const capabilities = body.model ? params.capabilities === undefined ? ["tools"] - : params.capabilities[body.name] + : params.capabilities[body.model] : undefined; return jsonResponse({ ...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}), @@ -569,7 +569,7 @@ describe("ollama setup", () => { }); const pullCall = fetchMock.mock.calls.find((call) => requestUrl(call[0]).endsWith("/api/pull")); expect(pullCall).toBeDefined(); - expect(JSON.parse(requestBodyText(pullCall?.[1]?.body))).toEqual({ name: "gemma4:e4b" }); + expect(JSON.parse(requestBodyText(pullCall?.[1]?.body))).toEqual({ model: "gemma4:e4b" }); expect(progress.update).toHaveBeenCalledWith("Downloading gemma4:e4b - pulling part - 50%"); expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e4b"); expect(result.config.models?.providers?.ollama?.models?.map((model) => model.id)).toContain( @@ -657,7 +657,7 @@ describe("ollama setup", () => { const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { if (requestUrl(input).endsWith("/api/show")) { const body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; - if (body.name === "broken:20b") { + if (body.model === "broken:20b") { return new Response("boom", { status: 500 }); } } @@ -714,8 +714,8 @@ describe("ollama setup", () => { markScanStarted = resolve; }); const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const body = init?.body ? (JSON.parse(requestBodyText(init.body)) as { name?: string }) : {}; - if (!requestUrl(input).endsWith("/api/show") || body.name !== "model-200") { + const body = init?.body ? (JSON.parse(requestBodyText(init.body)) as { model?: string }) : {}; + if (!requestUrl(input).endsWith("/api/show") || body.model !== "model-200") { return await baseFetch(input, init); } markScanStarted(); @@ -999,7 +999,7 @@ describe("ollama setup", () => { }); const pullRequest = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined; - expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" }); + expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ model: "llama3.2:latest" }); expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/llama3.2:latest" }); expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1); }); From 4376387791c41b97d4e8e1972243930fe6a3b193 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 20:26:44 -0700 Subject: [PATCH 08/15] fix(plugins): invalidate bundled artifact locations after metadata refresh (#117041) Co-authored-by: Peter Steinberger --- src/plugins/public-surface-loader.test.ts | 51 +++++++++++++++++++++++ src/plugins/public-surface-loader.ts | 5 +++ 2 files changed, 56 insertions(+) diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index 3fb064553d59..2c59fa8957ce 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -246,6 +246,57 @@ describe("bundled plugin public surface loader", () => { expect(createJiti).not.toHaveBeenCalled(); }); + it.each([ + { firstLocation: "root", nextLocation: "dist" }, + { firstLocation: "dist", nextLocation: "root" }, + ] as const)( + "refreshes native ESM artifact locations from $firstLocation to $nextLocation with plugin metadata", + async ({ firstLocation, nextLocation }) => { + const publicSurfaceLoader = await importFreshModule< + typeof import("./public-surface-loader.js") + >( + import.meta.url, + `./public-surface-loader.js?scope=esm-artifact-relocation-${firstLocation}-${nextLocation}`, + ); + const { clearPluginMetadataLifecycleCaches } = await import("./plugin-metadata-lifecycle.js"); + const tempRoot = fs.realpathSync(createTempDir()); + const bundledPluginsDir = path.join(tempRoot, "extensions"); + const pluginDir = path.join(bundledPluginsDir, "demo"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "package.json"), '{"type":"module"}\n', "utf8"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; + process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = "1"; + + const artifactPath = (location: "root" | "dist") => + path.join(pluginDir, ...(location === "dist" ? ["dist"] : []), "api.js"); + const writeArtifact = (location: "root" | "dist") => { + const modulePath = artifactPath(location); + fs.mkdirSync(path.dirname(modulePath), { recursive: true }); + fs.writeFileSync( + modulePath, + `export const marker = ${JSON.stringify(location)};\n`, + "utf8", + ); + }; + const loadArtifact = () => + publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ marker: string }>({ + dirName: "demo", + artifactBasename: "api.js", + }).marker; + + writeArtifact(firstLocation); + expect(loadArtifact()).toBe(firstLocation); + + fs.unlinkSync(artifactPath(firstLocation)); + writeArtifact(nextLocation); + expect(loadArtifact()).toBe(firstLocation); + + clearPluginMetadataLifecycleCaches(); + + expect(loadArtifact()).toBe(nextLocation); + }, + ); + it.runIf(process.platform !== "win32")( "allows hardlinked bundled public artifacts under the trusted bundled root", async () => { diff --git a/src/plugins/public-surface-loader.ts b/src/plugins/public-surface-loader.ts index 28f39d14966d..b7c026088476 100644 --- a/src/plugins/public-surface-loader.ts +++ b/src/plugins/public-surface-loader.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { openRootFileSync } from "../infra/boundary-file-read.js"; import { sameFileIdentity } from "../infra/fs-safe-advanced.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; +import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; import { createPluginModuleLoaderCache, getCachedPluginModuleLoader, @@ -33,6 +34,10 @@ const publicSurfaceLocationCache = new Map< >(); const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache(); +registerPluginMetadataProcessMemoLifecycleClear(() => { + publicSurfaceLocationCache.clear(); +}); + function isSourceArtifactPath(modulePath: string): boolean { switch (path.extname(modulePath).toLowerCase()) { case ".ts": From 6938f7dddb63bad59862c014c427d63f0a41a6f4 Mon Sep 17 00:00:00 2001 From: Sasan Date: Fri, 31 Jul 2026 23:28:39 -0400 Subject: [PATCH 09/15] fix: allow gateway service commands for named profiles (#116314) * fix: gateway service commands refuse a named profile or relocated OPENCLAW_HOME - Resolve the default install identity against the canonical state directory for the active OpenClaw home and profile instead of the unprofiled OS account default. - `--profile ` / `--dev` project `.openclaw-` state and config paths, so every named profile was classified as isolated state and refused `install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling. - `OPENCLAW_HOME` relocates all OpenClaw path defaults and is documented for running as a dedicated service user; a relocated home is now an install identity. `HOME` alone still is not. - An `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` pointing outside those canonical paths is still treated as isolated state. - Recovery guidance in the refusal message now names the paths that must match. Verified: focused vitest shards for the changed suites plus the daemon, CLI, and doctor suites that consume the identity check; tsgo core and core-test lanes; oxlint; docs format, MDX, link, and map checks. * fix(gateway): keep relocated homes isolated * fix(config): validate service profile identity * fix(daemon): enforce named-profile service ownership * fix(update): reject drifted service selectors before probes * test(windows): prove scheduled task lifecycle * test(windows): harden scheduled task proof cleanup * test(windows): bind lifecycle proof to checkout * test(windows): normalize cleanup exit status * test(windows): verify effective task privilege * test(windows): protect scheduled task proof roots * test(windows): prove listener-owned task lifecycle * test(windows): fix scheduled task proof contracts * test(windows): remove redundant mock coercions * test(windows): measure fallback before task probes * test(windows): prove scheduled task process origin * fix(gateway): preserve unmanaged restart fallback * test(gateway): cover denied restart ownership * test(gateway): keep restart helper types private * test(gateway): classify lifecycle helpers as test code --------- Co-authored-by: Vincent Koc --- .github/workflows/windows-testbox-probe.yml | 161 ++- docs/cli/gateway.md | 10 + docs/docs_map.md | 1 + docs/help/environment.md | 2 + package.json | 1 + src/cli/daemon-cli/install.test.ts | 1 + src/cli/daemon-cli/lifecycle-core.test.ts | 49 + src/cli/daemon-cli/lifecycle-core.ts | 7 + src/cli/daemon-cli/lifecycle.test-helpers.ts | 41 + src/cli/daemon-cli/lifecycle.test.ts | 117 +- src/cli/daemon-cli/lifecycle.ts | 21 +- src/cli/profile.test.ts | 35 + src/cli/profile.ts | 23 + src/cli/update-cli.test.ts | 117 +- .../update-cli/update-command-post-update.ts | 38 +- src/cli/update-cli/update-command-service.ts | 108 +- src/cli/update-cli/update-command.test.ts | 27 + src/config/paths.test.ts | 219 +++- src/config/paths.ts | 82 +- src/daemon/constants.test.ts | 43 + src/daemon/constants.ts | 36 + src/daemon/launchd.integration.e2e.test.ts | 81 +- src/daemon/schtasks.integration.e2e.test.ts | 1052 +++++++++++++++++ .../schtasks.integration.test-helpers.ts | 72 ++ src/daemon/schtasks.stop.test.ts | 99 +- src/daemon/service.test.ts | 25 + src/daemon/service.ts | 9 +- src/infra/gateway-supervision.test.ts | 95 +- src/infra/gateway-supervision.ts | 37 +- test/package-scripts.test.ts | 12 + test/scripts/check-workflows.test.ts | 16 +- 31 files changed, 2520 insertions(+), 117 deletions(-) create mode 100644 src/cli/daemon-cli/lifecycle.test-helpers.ts create mode 100644 src/daemon/schtasks.integration.e2e.test.ts create mode 100644 src/daemon/schtasks.integration.test-helpers.ts diff --git a/.github/workflows/windows-testbox-probe.yml b/.github/workflows/windows-testbox-probe.yml index 9729a9ba67b0..de0f45829d1f 100644 --- a/.github/workflows/windows-testbox-probe.yml +++ b/.github/workflows/windows-testbox-probe.yml @@ -38,7 +38,7 @@ on: default: false type: boolean run_windows_ci: - description: "Run the focused Windows-native CI test shard after probing" + description: "Run the focused Windows CI shard and native Scheduled Task proof" required: false default: false type: boolean @@ -281,6 +281,165 @@ jobs: export PATH="$NODE_BIN:$PATH" pnpm test:windows:ci + - name: Preflight native Scheduled Task session + if: ${{ inputs.run_windows_ci }} + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + $isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $sessionId = (Get-Process -Id $PID).SessionId + Write-Host "identity=$($identity.Name)" + Write-Host "session_id=$sessionId" + Write-Host "user_interactive=$([Environment]::UserInteractive)" + Write-Host "administrator=$isAdmin" + query user 2>&1 | Write-Host + if (-not [Environment]::UserInteractive) { + throw "Native Scheduled Task proof requires an interactive Windows runner session." + } + + - name: Run native Scheduled Task lifecycle proof + id: native_schtasks + if: ${{ inputs.run_windows_ci }} + timeout-minutes: 5 + shell: bash + env: + CI_WINDOWS_SCHTASKS_PROOF_PATH: ${{ github.workspace }}\.artifacts\windows-schtasks\proof.json + CI_WINDOWS_SCHTASKS_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }} + CI_WINDOWS_SCHTASKS_TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }} + EXPECTED_HEAD: ${{ inputs.target_ref }} + run: | + set -euo pipefail + export PATH="$NODE_BIN:$PATH" + if [[ ! "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then + echo "Native Scheduled Task proof requires target_ref to be an exact 40-character commit SHA." >&2 + exit 1 + fi + CI_WINDOWS_SCHTASKS_HEAD="$(git rev-parse HEAD)" + if [[ "$CI_WINDOWS_SCHTASKS_HEAD" != "$EXPECTED_HEAD" ]]; then + echo "Checked out $CI_WINDOWS_SCHTASKS_HEAD, expected frozen target $EXPECTED_HEAD." >&2 + exit 1 + fi + export CI_WINDOWS_SCHTASKS_HEAD + mkdir -p .artifacts/windows-schtasks + pnpm test:windows:schtasks:integration + + - name: Clean native Scheduled Task residue + id: native_cleanup + if: ${{ always() && inputs.run_windows_ci }} + shell: pwsh + env: + TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }} + TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }} + run: | + $ErrorActionPreference = "Continue" + $cleanupErrors = @() + $profile = "schtasks-int-$env:TEST_ID" + $taskName = "OpenClaw Gateway ($profile)" + $stateDir = Join-Path $env:USERPROFILE ".openclaw-$profile" + New-Item -ItemType Directory -Force -Path $env:TEST_ROOT | Out-Null + schtasks.exe /End /TN $taskName 2>$null + Start-Sleep -Milliseconds 200 + $activePidPath = Join-Path $env:TEST_ROOT "active-pid.txt" + if (Test-Path -LiteralPath $activePidPath) { + try { + $probePid = 0 + $activePid = (Get-Content -LiteralPath $activePidPath -Raw).Trim() + if (-not [int]::TryParse($activePid, [ref]$probePid) -or $probePid -le 1) { + throw "Invalid Scheduled Task active process id: $activePid" + } + $processQueryError = @() + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError + if ($processQueryError.Count -gt 0) { + throw "Could not inspect Scheduled Task probe process $probePid." + } + if ($process) { + $probePath = Join-Path $env:TEST_ROOT "probe.cjs" + $eventsPath = Join-Path $env:TEST_ROOT "runs.txt" + if ( + $process.CommandLine -like "*$probePath*" -and + $process.CommandLine -like "*$eventsPath*" + ) { + taskkill.exe /F /T /PID $probePid 2>$null + $deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + Start-Sleep -Milliseconds 200 + $processQueryError = @() + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError + if ($processQueryError.Count -gt 0) { + throw "Could not verify Scheduled Task probe process $probePid exited." + } + } while ($process -and [DateTime]::UtcNow -lt $deadline) + if ($process) { + throw "Scheduled Task probe process $probePid survived cleanup." + } + } else { + throw "Refusing to kill reused or unverifiable process id $probePid." + } + } + } catch { + $cleanupErrors += $_.Exception.Message + } + } + schtasks.exe /Delete /F /TN $taskName 2>$null + $deleteExit = $LASTEXITCODE + try { + $service = New-Object -ComObject "Schedule.Service" + $service.Connect() + $null = $service.GetFolder("\").GetTask($taskName) + $taskExists = $true + } catch { + $exception = $_.Exception + while ($null -ne $exception.InnerException) { + $exception = $exception.InnerException + } + if ($exception.HResult -eq -2147024894 -or $exception.HResult -eq -2147024893) { + $taskExists = $false + } else { + $cleanupErrors += "Could not verify Scheduled Task cleanup for $taskName (HRESULT $($exception.HResult))." + $taskExists = $null + } + } + if ($taskExists -eq $true) { + $cleanupErrors += "Scheduled Task cleanup left $taskName registered (delete exit $deleteExit)." + } + @( + "task_name=$taskName" + "delete_exit=$deleteExit" + "task_exists=$taskExists" + "proof_outcome=${{ steps.native_schtasks.outcome }}" + "cleanup_errors=$($cleanupErrors -join ' ')" + ) | Set-Content -LiteralPath (Join-Path $env:TEST_ROOT "cleanup-summary.txt") + if ($cleanupErrors.Count -gt 0) { + throw ($cleanupErrors -join " ") + } + exit 0 + + - name: Upload native Scheduled Task proof + id: native_proof_upload + if: ${{ always() && inputs.run_windows_ci }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-schtasks-proof-${{ github.run_id }}-${{ github.run_attempt }} + path: | + .artifacts/windows-schtasks/proof.json + ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\failure-diagnostics.json + ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\cleanup-summary.txt + if-no-files-found: warn + retention-days: 7 + + - name: Remove retained native Scheduled Task evidence + if: ${{ always() && inputs.run_windows_ci && steps.native_cleanup.outcome == 'success' && steps.native_proof_upload.outcome == 'success' }} + shell: pwsh + env: + TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }} + TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }} + run: | + $profile = "schtasks-int-$env:TEST_ID" + Remove-Item -LiteralPath (Join-Path $env:USERPROFILE ".openclaw-$profile") -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $env:TEST_ROOT -Recurse -Force -ErrorAction SilentlyContinue + - name: Keep runner alive for SSH inspection if: ${{ always() && !cancelled() }} env: diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index aca004248e4d..5d126b57dc7a 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -129,6 +129,16 @@ openclaw gateway restart --wait 30s Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`. +### Install identity + +Service management (`install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling) belongs to the install that owns the host service. That is the canonical `.openclaw` directory under the OS account home, or the `.openclaw-` directory a named profile projects there. Named profiles use distinct native service identities. + +`OPENCLAW_HOME`, or an `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` that points elsewhere, is treated as isolated state and skipped. A relocated or copied state tree cannot adopt and rewrite the account's host service. + +On macOS and Windows, native service-managed profile names must be lowercase. Runtime-only profiles may still use uppercase, but case-distinct names such as `Main` and `main` share paths on normal case-insensitive filesystems and cannot safely own separate native services. On macOS, the lowercase names `gateway` and `node` are also unavailable for native service management because their historical LaunchAgent labels collide with the default Gateway and node-host services. + +Named profiles must also use the native service identity derived from `OPENCLAW_PROFILE`. Unset `OPENCLAW_LAUNCHD_LABEL`, `OPENCLAW_SYSTEMD_UNIT`, or `OPENCLAW_WINDOWS_TASK_NAME` before service management; custom identities remain available for the default profile or runtime-only/external-supervisor setups. + ### External supervisors Set `OPENCLAW_SUPERVISOR_MODE=external` only when another process manager owns the Gateway lifecycle. In this mode: diff --git a/docs/docs_map.md b/docs/docs_map.md index fdc67ce436dd..ddc4cf504413 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1641,6 +1641,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Run the Gateway - H3: Options - H2: Restart the Gateway + - H3: Install identity - H3: External supervisors - H3: Gateway profiling - H2: Query a running Gateway diff --git a/docs/help/environment.md b/docs/help/environment.md index 0a10c7025502..bc0e68f2ae5b 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -250,6 +250,8 @@ unavailable instead of triggering a network request. When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for internal OpenClaw path defaults. This includes the default state directory, config path, agent directories, credentials, installer onboarding workspace, and the default dev checkout used by `openclaw update --channel dev`. +`OPENCLAW_HOME` does not grant ownership of the OS account's native Gateway service. Gateway service-management commands treat a relocated home as isolated state; use the OS account home and a named profile when a separate native service identity is required. + **Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > Termux `PREFIX` home fallback on Android > `os.homedir()` **Example** (macOS LaunchDaemon): diff --git a/package.json b/package.json index fc5c45b19d30..a2f0b7236bd8 100644 --- a/package.json +++ b/package.json @@ -1894,6 +1894,7 @@ "test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs", "test:watch": "node scripts/test-projects.mjs --watch", "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:schtasks:integration": "node scripts/run-with-env.mjs CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", "ts-topology": "node --import tsx scripts/ts-topology.ts", diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 53242f7d580d..b78b5813c78f 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({ vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: isDefaultInstallIdentityMock, + resolveNativeServiceProfileConflict: () => null, resolveGatewayPort: resolveGatewayPortMock, resolveIsNixMode: resolveIsNixModeMock, })); diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 861d6079b373..d4ed026ddd9e 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -254,6 +254,55 @@ describe("runServiceRestart token drift", () => { ); }); + it("runs the service mutation guard before restarting a loaded service", async () => { + const beforeServiceMutation = vi.fn(); + + await runServiceRestart({ + ...createServiceRunArgs(), + beforeServiceMutation, + }); + + expect(beforeServiceMutation).toHaveBeenCalledTimes(1); + expect(beforeServiceMutation.mock.invocationCallOrder[0]).toBeLessThan( + service.restart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it("aborts loaded-service mutation when the service guard rejects", async () => { + const repairLoadedService = vi.fn(); + + await expect( + runServiceRestart({ + ...createServiceRunArgs(), + beforeServiceMutation: () => { + throw new Error("service mutation denied"); + }, + repairLoadedService, + }), + ).rejects.toThrow("service mutation denied"); + + expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled(); + expect(repairLoadedService).not.toHaveBeenCalled(); + expect(service.restart).not.toHaveBeenCalled(); + }); + + it("does not run the service mutation guard before not-loaded recovery", async () => { + service.isLoaded.mockResolvedValue(false); + const beforeServiceMutation = vi.fn(); + + await runServiceRestart({ + ...createServiceRunArgs(), + beforeServiceMutation, + onNotLoaded: async () => ({ + result: "restarted", + message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.", + }), + }); + + expect(beforeServiceMutation).not.toHaveBeenCalled(); + expect(service.restart).not.toHaveBeenCalled(); + }); + it("repairs managed port drift before restarting", async () => { service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 }); service.readCommand.mockResolvedValue({ diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 2d8cb9e81679..f684edef5feb 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -459,6 +459,7 @@ export async function runServiceRestart(params: { opts?: DaemonLifecycleOptions; checkTokenDrift?: boolean; expectedPort?: number; + beforeServiceMutation?: () => void; repairLoadedService?: ( ctx: ServiceStartRepairContext, ) => Promise | null>; @@ -533,6 +534,12 @@ export async function runServiceRestart(params: { } } + // Loaded services cross the native mutation boundary here. Not-loaded recovery + // may still target a separately verified unmanaged listener. + if (loaded) { + params.beforeServiceMutation?.(); + } + if (!loaded) { try { handledRecovery = (await params.onNotLoaded?.({ json, stdout, warn, fail })) ?? null; diff --git a/src/cli/daemon-cli/lifecycle.test-helpers.ts b/src/cli/daemon-cli/lifecycle.test-helpers.ts new file mode 100644 index 000000000000..1c50747221fa --- /dev/null +++ b/src/cli/daemon-cli/lifecycle.test-helpers.ts @@ -0,0 +1,41 @@ +type RestartPostCheckContext = { + json: boolean; + stdout: NodeJS.WritableStream; + warnings: string[]; + fail: (message: string, hints?: string[]) => void; +}; + +export type RestartParams = { + opts?: { json?: boolean }; + beforeServiceMutation?: () => void; + repairLoadedService?: (ctx: { + json: boolean; + stdout: NodeJS.WritableStream; + state: unknown; + issues: unknown[]; + }) => Promise; + postRestartCheck?: (ctx: RestartPostCheckContext) => Promise; +}; + +export function requireMockCallArg( + mockFn: { mock: { calls: unknown[][] } }, + label: string, + index = 0, +): Record { + const arg = mockFn.mock.calls[index]?.[0] as Record | undefined; + if (!arg) { + throw new Error(`expected ${label} call #${index + 1}`); + } + return arg; +} + +export async function expectRestartError( + promise: Promise, +): Promise { + try { + await promise; + } catch (error) { + return error as Error & { hints?: string[] }; + } + throw new Error("expected restart to fail"); +} diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 130e54db053a..719f265d1408 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -1,6 +1,11 @@ // Daemon lifecycle tests cover CLI service lifecycle orchestration and cleanup. import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; +import { + expectRestartError, + requireMockCallArg, + type RestartParams, +} from "./lifecycle.test-helpers.js"; type RestartHealthSnapshot = { healthy: boolean; @@ -11,30 +16,13 @@ type RestartHealthSnapshot = { elapsedMs?: number; }; -type RestartPostCheckContext = { - json: boolean; - stdout: NodeJS.WritableStream; - warnings: string[]; - fail: (message: string, hints?: string[]) => void; -}; - -type RestartParams = { - opts?: { json?: boolean }; - repairLoadedService?: (ctx: { - json: boolean; - stdout: NodeJS.WritableStream; - state: unknown; - issues: unknown[]; - }) => Promise; - postRestartCheck?: (ctx: RestartPostCheckContext) => Promise; -}; - const service = { readCommand: vi.fn(), readRuntime: vi.fn(), restart: vi.fn(), stop: vi.fn(), }; +const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true)); const runServiceStart = vi.fn(); const runServiceRestart = vi.fn(); @@ -99,29 +87,6 @@ const createGatewayLifecycleMutationAudit = vi.fn( }), ); -function requireMockCallArg( - mockFn: { mock: { calls: unknown[][] } }, - label: string, - index = 0, -): Record { - const arg = mockFn.mock.calls[index]?.[0] as Record | undefined; - if (!arg) { - throw new Error(`expected ${label} call #${index + 1}`); - } - return arg; -} - -async function expectRestartError( - promise: Promise, -): Promise { - try { - await promise; - } catch (error) { - return error as Error & { hints?: string[] }; - } - throw new Error("expected restart to fail"); -} - vi.mock("../../config/config.js", () => ({ getRuntimeConfig: () => loadConfig(), loadConfig: () => loadConfig(), @@ -129,7 +94,10 @@ vi.mock("../../config/config.js", () => ({ resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env), })); -vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: () => true })); +vi.mock("../../config/paths.js", () => ({ + isDefaultInstallIdentity: () => isDefaultInstallIdentity(), + resolveNativeServiceProfileConflict: () => null, +})); vi.mock("../../infra/gateway-processes.js", () => ({ findVerifiedGatewayListenerPidsOnPortSync, @@ -276,13 +244,12 @@ describe("runDaemonRestart health checks", () => { ]); delete process.env.OPENCLAW_CONTAINER_HINT; service.readCommand.mockReset(); - service.readRuntime.mockReset(); - service.readRuntime.mockResolvedValue({ status: "stopped" }); - service.restart.mockReset(); + service.readRuntime.mockReset().mockResolvedValue({ status: "stopped" }); + service.restart.mockReset().mockResolvedValue({ outcome: "completed" }); service.stop.mockReset(); - runServiceStart.mockReset(); + runServiceStart.mockReset().mockResolvedValue(undefined); runServiceRestart.mockReset(); - runServiceStop.mockReset(); + runServiceStop.mockReset().mockResolvedValue(undefined); waitForGatewayHealthyListener.mockReset(); waitForGatewayHealthyRestart.mockReset(); terminateStaleGatewayPids.mockReset(); @@ -290,43 +257,36 @@ describe("runDaemonRestart health checks", () => { renderRestartDiagnostics.mockReset(); resolveGatewayPort.mockReset(); findVerifiedGatewayListenerPidsOnPortSync.mockReset(); - signalVerifiedGatewayPidSync.mockReset(); - writeGatewayRestartIntentSync.mockReset(); + signalVerifiedGatewayPidSync.mockReset().mockImplementation(() => {}); + writeGatewayRestartIntentSync.mockReset().mockReturnValue(true); clearGatewayRestartIntentSync.mockReset(); - formatGatewayPidList.mockReset(); + formatGatewayPidList.mockReset().mockImplementation((pids) => pids.join(", ")); probeGateway.mockReset(); callGatewayCli.mockReset(); isRestartEnabled.mockReset(); loadConfig.mockReset(); - readActiveGatewayLockPort.mockReset(); + readActiveGatewayLockPort.mockReset().mockResolvedValue(undefined); readActiveGatewayLockIdentity.mockReset(); - recoverInstalledLaunchAgent.mockReset(); + recoverInstalledLaunchAgent.mockReset().mockResolvedValue(null); repairLoadedGatewayServiceForStart.mockReset(); - isTerminalInteractive.mockReset(); - isTerminalInteractive.mockReturnValue(true); + isTerminalInteractive.mockReset().mockReturnValue(true); appendGatewayLifecycleAudit.mockClear(); createGatewayLifecycleMutationAudit.mockClear(); + isDefaultInstallIdentity.mockReset().mockReturnValue(true); service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway", "--port", "18789"], environment: {}, }); - service.restart.mockResolvedValue({ outcome: "completed" }); - runServiceStart.mockResolvedValue(undefined); - recoverInstalledLaunchAgent.mockResolvedValue(null); - readActiveGatewayLockPort.mockResolvedValue(undefined); readActiveGatewayLockIdentity.mockResolvedValue({ pid: 4200, ownerId: "gateway-owner-old", createdAt: "2026-07-16T12:00:00.000Z", port: 18_789, }); - findInstalledSystemdGatewayScope.mockReset(); - findInstalledSystemdGatewayScope.mockResolvedValue(null); - restartSystemdService.mockReset(); - restartSystemdService.mockResolvedValue({ outcome: "completed" }); - stopSystemdService.mockReset(); - stopSystemdService.mockResolvedValue(undefined); + findInstalledSystemdGatewayScope.mockReset().mockResolvedValue(null); + restartSystemdService.mockReset().mockResolvedValue({ outcome: "completed" }); + stopSystemdService.mockReset().mockResolvedValue(undefined); runServiceRestart.mockImplementation(async (params: RestartParams) => { const fail = (message: string, hints?: string[]) => { @@ -342,7 +302,6 @@ describe("runDaemonRestart health checks", () => { }); return true; }); - runServiceStop.mockResolvedValue(undefined); waitForGatewayHealthyListener.mockResolvedValue({ healthy: true, portUsage: { port: 18789, status: "busy", listeners: [], hints: [] }, @@ -383,9 +342,6 @@ describe("runDaemonRestart health checks", () => { }, }); isRestartEnabled.mockReturnValue(true); - signalVerifiedGatewayPidSync.mockImplementation(() => {}); - writeGatewayRestartIntentSync.mockReturnValue(true); - formatGatewayPidList.mockImplementation((pids) => pids.join(", ")); }); afterEach(() => { @@ -417,6 +373,16 @@ describe("runDaemonRestart health checks", () => { expect(requireMockCallArg(runServiceRestart, "runServiceRestart").expectedPort).toBeUndefined(); }); + it("guards loaded service restart at the native mutation boundary", async () => { + await runDaemonRestart({ json: true }); + + const restartParams = requireMockCallArg(runServiceRestart, "runServiceRestart"); + isDefaultInstallIdentity.mockReturnValue(false); + expect(() => (restartParams.beforeServiceMutation as () => void)()).toThrow( + /non-default state dir/, + ); + }); + it("uses the installed service environment for managed restart health", async () => { process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-caller-state"; process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway-maintenance.service"; @@ -840,12 +806,15 @@ describe("runDaemonRestart health checks", () => { }); it("signals a single unmanaged gateway process on restart", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + isDefaultInstallIdentity.mockReturnValue(false); findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]); mockUnmanagedRestart({ runPostRestartCheck: true }); await runDaemonRestart({ json: true }); expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789); + expect(findInstalledSystemdGatewayScope).not.toHaveBeenCalled(); expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1"); expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({ action: "restart", @@ -860,6 +829,17 @@ describe("runDaemonRestart health checks", () => { expect(service.restart).not.toHaveBeenCalled(); }); + it("rejects denied Darwin recovery when no unmanaged listener exists", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + isDefaultInstallIdentity.mockReturnValue(false); + mockUnmanagedRestart(); + + await expect(runDaemonRestart({ json: true })).rejects.toThrow(/non-default state dir/); + + expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled(); + expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled(); + }); + it("uses targeted RPC for an unmanaged Windows gateway restart", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("win32"); findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]); @@ -1025,6 +1005,7 @@ describe("runDaemonRestart health checks", () => { }); it("fails unmanaged restart when multiple gateway listeners are present", async () => { + isDefaultInstallIdentity.mockReturnValue(false); findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4300]); mockUnmanagedRestart(); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 52fce8413a8c..cd067ffea18a 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -28,6 +28,7 @@ import { assertGatewayServiceMutationAllowed, formatExternalSupervisorActionRequired, isGatewayExternallySupervised, + resolveGatewayServiceMutationError, } from "../../infra/gateway-supervision.js"; import { clearGatewayRestartIntentSync, @@ -383,16 +384,13 @@ async function signalGatewayRestart( }; } -async function restartGatewayWithoutServiceManager( - port: number, - restartIntent?: GatewayRestartIntent, -) { - const managed = await handleSystemScopeSystemdGateway("restart"); +async function restartUnmanaged(port: number, intent?: GatewayRestartIntent, allowSystem = true) { + const managed = allowSystem ? await handleSystemScopeSystemdGateway("restart") : null; if (managed) { return managed; } return await signalGatewayRestart(port, { - restartIntent, + restartIntent: intent, enforceRestartConfig: true, processLabel: "unmanaged", auditSource: "cli", @@ -402,7 +400,7 @@ async function restartGatewayWithoutServiceManager( type GatewaySignalRestartResult = NonNullable>>; function isGatewaySignalRestartResult( - result: Awaited>, + result: Awaited>, ): result is GatewaySignalRestartResult { return result !== null && "pid" in result && typeof result.pid === "number"; } @@ -595,6 +593,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi }, checkTokenDrift: true, expectedPort: configuredPort, + beforeServiceMutation: () => assertGatewayServiceMutationAllowed("restart the gateway"), repairLoadedService: async ({ json, stdout, warn, state, issues }) => { const result = await repairLoadedGatewayServiceForStart({ action: "restart", @@ -612,7 +611,8 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi return result; }, onNotLoaded: async () => { - if (process.platform === "darwin") { + const mutationError = resolveGatewayServiceMutationError("restart the gateway"); + if (process.platform === "darwin" && !mutationError) { const recovered = await recoverInstalledLaunchAgent({ result: "restarted" }); if (recovered) { appendGatewayLifecycleAudit({ @@ -623,7 +623,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi return recovered; } } - const handled = await restartGatewayWithoutServiceManager(unmanagedPort, restartIntent); + const handled = await restartUnmanaged(unmanagedPort, restartIntent, !mutationError); if (handled) { restartedWithoutServiceManager = true; if (isGatewaySignalRestartResult(handled) && handled.previousLockIdentity) { @@ -635,6 +635,9 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi } return handled; } + if (mutationError) { + throw mutationError; + } return null; }, postRestartCheck: async ({ warnings, fail, stdout, warn }) => { diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 862904295e86..1e2d82e5354c 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -358,6 +358,41 @@ describe("applyCliProfileEnv", () => { expect(env.OPENCLAW_CONFIG_PATH).toBe("/srv/openclaw/custom.json"); }); + it.each(["openclaw-gateway-main", "openclaw-gateway-main.service"])( + "drops inherited canonical service identities when switching profiles (%s)", + (systemdUnit) => { + const env: Record = { + OPENCLAW_PROFILE: "main", + OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main", + OPENCLAW_CONFIG_PATH: "/home/peter/.openclaw-main/openclaw.json", + OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.main", + OPENCLAW_SYSTEMD_UNIT: systemdUnit, + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (main)", + }; + + applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" }); + + expect(env.OPENCLAW_LAUNCHD_LABEL).toBeUndefined(); + expect(env.OPENCLAW_SYSTEMD_UNIT).toBeUndefined(); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBeUndefined(); + }, + ); + + it("preserves explicit custom service identities when switching profiles", () => { + const env: Record = { + OPENCLAW_PROFILE: "main", + OPENCLAW_LAUNCHD_LABEL: "com.example.gateway", + OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service", + OPENCLAW_WINDOWS_TASK_NAME: "Custom Gateway", + }; + + applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" }); + + expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("com.example.gateway"); + expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("custom-gateway.service"); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("Custom Gateway"); + }); + it.each([ { inheritedProfile: "Main", selectedProfile: "main" }, { inheritedProfile: "main", selectedProfile: "Main" }, diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 309613c7cc3a..0d6e42fdcab5 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -5,6 +5,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { + resolveGatewayLaunchAgentLabel, + resolveGatewaySystemdServiceName, + resolveGatewayWindowsTaskName, +} from "../daemon/constants.js"; import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { isValidProfileName } from "./profile-utils.js"; @@ -129,6 +134,24 @@ export function applyCliProfileEnv(params: { env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); } + if (switchesInheritedProfile) { + const inheritedSystemdServiceName = resolveGatewaySystemdServiceName(inheritedProfile); + const inheritedServiceIdentities = { + OPENCLAW_LAUNCHD_LABEL: [resolveGatewayLaunchAgentLabel(inheritedProfile)], + OPENCLAW_SYSTEMD_UNIT: [ + inheritedSystemdServiceName, + `${inheritedSystemdServiceName}.service`, + ], + OPENCLAW_WINDOWS_TASK_NAME: [resolveGatewayWindowsTaskName(inheritedProfile)], + }; + for (const [key, inheritedValues] of Object.entries(inheritedServiceIdentities)) { + const activeValue = normalizeOptionalString(env[key]); + if (activeValue && inheritedValues.includes(activeValue)) { + delete env[key]; + } + } + } + if (profile === "dev" && !env.OPENCLAW_GATEWAY_PORT?.trim()) { env.OPENCLAW_GATEWAY_PORT = "19001"; } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 29fbfa3d93bb..1ca2851c9ff1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -44,6 +44,11 @@ const resolveGlobalManager = vi.fn(); const serviceLoaded = vi.fn(); const serviceStop = vi.fn(); const serviceRestart = vi.fn(); +const isDefaultInstallIdentity = vi.hoisted(() => + vi.fn<(env?: NodeJS.ProcessEnv, homedir?: () => string, platform?: NodeJS.Platform) => boolean>( + () => true, + ), +); const suspendScheduledTaskAutoStartForUpdate = vi.fn(); const resumeScheduledTaskAutoStartAfterUpdate = vi.fn(); const prepareRestartScript = vi.fn(); @@ -322,7 +327,12 @@ vi.mock("../config/backup-rotation.js", () => ({ })); vi.mock("../daemon/service.js", () => ({ - readGatewayServiceState: async () => { + readGatewayServiceState: async ( + _service: unknown, + args?: { + validateEnvBeforeStatusRead?: (env: NodeJS.ProcessEnv) => void; + }, + ) => { const command = await serviceReadCommand(); const env = { ...process.env, @@ -330,6 +340,7 @@ vi.mock("../daemon/service.js", () => ({ ? (command.environment as NodeJS.ProcessEnv | undefined) : undefined), }; + args?.validateEnvBeforeStatusRead?.(env); const [loaded, runtime] = await Promise.all([ serviceLoaded({ env }).catch(() => false), serviceReadRuntime(env).catch(() => undefined), @@ -365,6 +376,15 @@ vi.mock("../daemon/schtasks.js", () => ({ resumeScheduledTaskAutoStartAfterUpdate(...args), })); +vi.mock("../config/paths.js", async (importOriginal) => ({ + ...(await importOriginal()), + isDefaultInstallIdentity: ( + env?: NodeJS.ProcessEnv, + homedir?: () => string, + platform?: NodeJS.Platform, + ) => isDefaultInstallIdentity(env, homedir, platform), +})); + vi.mock("../infra/ports.js", () => ({ inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args), classifyPortListener: (...args: unknown[]) => classifyPortListener(...args), @@ -1364,6 +1384,8 @@ describe("update-cli", () => { resolveGlobalManager.mockResolvedValue("npm"); serviceStop.mockResolvedValue(undefined); serviceRestart.mockResolvedValue({ outcome: "completed" }); + isDefaultInstallIdentity.mockReset(); + isDefaultInstallIdentity.mockReturnValue(true); suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(false); resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(false); serviceLoaded.mockResolvedValue(false); @@ -4206,6 +4228,99 @@ describe("update-cli", () => { processOffSpy.mockRestore(); }); + it("does not inspect or mutate a Windows host service from an isolated install", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const tempDir = await createTrackedTempDir("openclaw-update-isolated-service-"); + const { nodeModules } = await setupInstalledPackageRoot(tempDir); + mockRunningManagedGateway(); + mockFileBackedPathExists(); + mockNpmGlobalRoot(nodeModules); + isDefaultInstallIdentity.mockReturnValue(false); + + await withEnvAsync({ OPENCLAW_HOME: path.join(tempDir, "relocated-home") }, async () => { + await updateCommand({ yes: true }); + }); + platformSpy.mockRestore(); + + expect(isDefaultInstallIdentity).toHaveBeenCalled(); + expect(serviceReadCommand).not.toHaveBeenCalled(); + expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled(); + expect(serviceStop).not.toHaveBeenCalled(); + expect(prepareRestartScript).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + expect(packageInstallCommandCall()).toBeDefined(); + }); + + it.each([ + { + platform: "darwin" as const, + envKey: "OPENCLAW_LAUNCHD_LABEL", + value: "ai.openclaw.gateway", + }, + { + platform: "linux" as const, + envKey: "OPENCLAW_SYSTEMD_UNIT", + value: "openclaw-gateway.service", + }, + { + platform: "win32" as const, + envKey: "OPENCLAW_WINDOWS_TASK_NAME", + value: "OpenClaw Gateway", + }, + ])( + "does not reuse a conflicting $envKey selector from the managed service on $platform", + async ({ platform, envKey, value }) => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); + const tempDir = await createTrackedTempDir(`openclaw-update-${platform}-selector-`); + const home = path.join(tempDir, "home"); + const stateDir = path.join(home, ".openclaw-work"); + const { nodeModules } = await setupInstalledPackageRoot(tempDir); + serviceReadCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_PROFILE: "work", + [envKey]: value, + }, + }); + serviceLoaded.mockResolvedValue(true); + serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" }); + mockFileBackedPathExists(); + mockNpmGlobalRoot(nodeModules); + + try { + await withEnvAsync( + { + HOME: home, + USERPROFILE: undefined, + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + [envKey]: undefined, + }, + async () => { + await updateCommand({ yes: true }); + }, + ); + } finally { + platformSpy.mockRestore(); + } + + expect(isDefaultInstallIdentity).toHaveBeenCalled(); + expect(serviceReadRuntime).not.toHaveBeenCalled(); + expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled(); + expect(serviceStop).not.toHaveBeenCalled(); + expect(serviceRestart).not.toHaveBeenCalled(); + expect(prepareRestartScript).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + expect(packageInstallCommandCall()).toBeUndefined(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(getErrorOutput()).toContain(envKey); + }, + ); + it("restores Windows Scheduled Task autostart when service stop fails", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); mockPackageInstallStatus(createCaseDir("openclaw-update-stop-failure")); diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index 946920efb0ca..e6b517c211a1 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -38,9 +38,13 @@ import { } from "./update-command-post-core.js"; import { POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON } from "./update-command-post-plugin-validation.js"; import { + assertGatewayServiceManagementAllowedForUpdate, + GatewayServiceUpdateOwnershipError, gatewayServiceCommandUsesRoot, + isGatewayServiceManagementAllowedForUpdate, maybeRestartService, maybeRestartServiceAfterFailedMutableUpdate, + resolveGatewayServiceManagementBlockMessageForUpdate, resolvePostUpdateServiceStateReadEnv, resolveUpdatedGatewayRestartPort, restoreWindowsTaskAutoStartOrExit, @@ -340,18 +344,30 @@ export async function finishUpdate(params: { let refreshGatewayServiceEnv = false; let gatewayServiceEnv: NodeJS.ProcessEnv | undefined; let skipLegacyServiceRestart = false; + const serviceStateReadEnv = resolvePostUpdateServiceStateReadEnv({ + updateMode: resultWithPostUpdate.mode, + processEnv: process.env, + preManagedServiceEnv: params.preManagedServiceStop?.serviceEnv, + }); + const serviceMutationAllowed = + params.preManagedServiceStop?.serviceMutationAllowed !== false && + isGatewayServiceManagementAllowedForUpdate(process.env) && + isGatewayServiceManagementAllowedForUpdate(serviceStateReadEnv); + const serviceMutationSkipMessage = + params.shouldRestart && !serviceMutationAllowed + ? (params.preManagedServiceStop?.serviceMutationSkipMessage ?? + resolveGatewayServiceManagementBlockMessageForUpdate(process.env) ?? + resolveGatewayServiceManagementBlockMessageForUpdate(serviceStateReadEnv)) + : undefined; let gatewayPort = resolveUpdatedGatewayRestartPort({ config: restartConfigSnapshot.valid ? restartConfigSnapshot.config : undefined, processEnv: process.env, }); - if (params.shouldRestart) { + if (params.shouldRestart && serviceMutationAllowed) { try { const serviceState = await readGatewayServiceState(resolveGatewayService(), { - env: resolvePostUpdateServiceStateReadEnv({ - updateMode: resultWithPostUpdate.mode, - processEnv: process.env, - preManagedServiceEnv: params.preManagedServiceStop?.serviceEnv, - }), + env: serviceStateReadEnv, + validateEnvBeforeStatusRead: assertGatewayServiceManagementAllowedForUpdate, }); const serviceMatchesUpdateRoot = (await gatewayServiceCommandUsesRoot({ @@ -399,7 +415,12 @@ export async function finishUpdate(params: { // ownership authorizes rewriting the service definition. refreshGatewayServiceEnv = serviceOwnershipConfirmed; } - } catch { + } catch (err) { + if (err instanceof GatewayServiceUpdateOwnershipError) { + defaultRuntime.error(err.message); + defaultRuntime.exit(1); + return; + } // Ignore errors during pre-check; fallback to standard restart } } @@ -420,7 +441,7 @@ export async function finishUpdate(params: { return; } const restartOk = await maybeRestartService({ - shouldRestart: params.shouldRestart, + shouldRestart: params.shouldRestart && serviceMutationAllowed, result: resultWithPostUpdate, opts: params.opts, refreshServiceEnv: refreshGatewayServiceEnv, @@ -432,6 +453,7 @@ export async function finishUpdate(params: { skipLegacyServiceRestart, requireRunningServiceAfterRestart: resultWithPostUpdate.mode === "git" && params.preManagedServiceStop?.stopped === true, + serviceMutationSkipMessage, timeoutMs: params.updateStepTimeoutMs, }); if (!restartOk) { diff --git a/src/cli/update-cli/update-command-service.ts b/src/cli/update-cli/update-command-service.ts index e4d6495a0b7f..640b9127cac8 100644 --- a/src/cli/update-cli/update-command-service.ts +++ b/src/cli/update-cli/update-command-service.ts @@ -29,6 +29,7 @@ import { import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js"; import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js"; import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js"; +import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js"; import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; @@ -111,6 +112,8 @@ export type PreManagedServiceStop = { inspected: boolean; runtimeInspected: boolean; running: boolean; + serviceMutationAllowed?: boolean; + serviceMutationSkipMessage?: string; serviceMatchesMutationRoot?: boolean; blockMessage?: string; serviceEnv?: NodeJS.ProcessEnv; @@ -128,6 +131,41 @@ export type UpdateCommandRecoveryState = { windowsTaskAutoStartRecovery?: WindowsTaskAutoStartRecovery; }; +export class GatewayServiceUpdateOwnershipError extends Error { + constructor(message: string, cause: unknown) { + super(message, { cause }); + this.name = "GatewayServiceUpdateOwnershipError"; + } +} + +export function resolveGatewayServiceManagementBlockMessageForUpdate( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + try { + assertGatewayServiceManagementAllowedForUpdate(env); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +export function assertGatewayServiceManagementAllowedForUpdate( + env: NodeJS.ProcessEnv = process.env, +): void { + try { + assertGatewayServiceMutationAllowed("manage the gateway service during update", env); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new GatewayServiceUpdateOwnershipError(message, err); + } +} + +export function isGatewayServiceManagementAllowedForUpdate( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return resolveGatewayServiceManagementBlockMessageForUpdate(env) === undefined; +} + export class UpdateCommandAbort extends Error { constructor() { super("openclaw-update-abort"); @@ -334,12 +372,38 @@ export async function maybeStopManagedServiceBeforeMutableUpdate(params: { shouldRestart: boolean; jsonMode: boolean; }): Promise { + const serviceMutationSkipMessage = resolveGatewayServiceManagementBlockMessageForUpdate( + process.env, + ); + if (serviceMutationSkipMessage) { + return { + stopped: false, + inspected: false, + runtimeInspected: false, + running: false, + serviceMutationAllowed: false, + serviceMutationSkipMessage, + }; + } let service: ReturnType; let serviceState: Awaited>; try { service = resolveGatewayService(); - serviceState = await readGatewayServiceState(service, { env: process.env }); - } catch { + serviceState = await readGatewayServiceState(service, { + env: process.env, + validateEnvBeforeStatusRead: assertGatewayServiceManagementAllowedForUpdate, + }); + } catch (err) { + if (err instanceof GatewayServiceUpdateOwnershipError) { + return { + stopped: false, + inspected: false, + runtimeInspected: false, + running: false, + serviceMutationAllowed: false, + blockMessage: err.message, + }; + } return { stopped: false, inspected: false, runtimeInspected: false, running: false }; } @@ -949,6 +1013,9 @@ function resolveManagedServiceNodeRunner( * when the package root is the same. */ export async function resolveManagedServiceNodeRunnerOverride(): Promise { + if (!isGatewayServiceManagementAllowedForUpdate(process.env)) { + return undefined; + } const command = await resolveGatewayService() .readCommand(process.env) .catch(() => null); @@ -970,6 +1037,9 @@ export async function resolveManagedServiceNodeRunnerOverride(): Promise { + if (!isGatewayServiceManagementAllowedForUpdate(process.env)) { + return null; + } const command = await resolveGatewayService() .readCommand(process.env) .catch(() => null); @@ -1004,9 +1074,11 @@ export async function gatewayServiceCommandUsesRoot(params: { } const command = params.command === undefined - ? await resolveGatewayService() - .readCommand(params.env ?? process.env) - .catch(() => null) + ? isGatewayServiceManagementAllowedForUpdate(params.env ?? process.env) + ? await resolveGatewayService() + .readCommand(params.env ?? process.env) + .catch(() => null) + : null : params.command; const layout = await summarizeGatewayServiceLayout(command); const serviceRoot = layout?.packageRoot; @@ -1037,8 +1109,22 @@ export async function maybeRestartService(params: { nodeRunner?: string; skipLegacyServiceRestart?: boolean; requireRunningServiceAfterRestart?: boolean; + serviceMutationSkipMessage?: string; timeoutMs: number; }): Promise { + if ( + params.shouldRestart && + (!isGatewayServiceManagementAllowedForUpdate(process.env) || + !isGatewayServiceManagementAllowedForUpdate(params.serviceEnv ?? process.env)) + ) { + const message = + resolveGatewayServiceManagementBlockMessageForUpdate(process.env) ?? + resolveGatewayServiceManagementBlockMessageForUpdate(params.serviceEnv ?? process.env); + if (message) { + defaultRuntime.error(message); + } + return false; + } const verifyRestartedGateway = async ( expectedGatewayVersion: string | undefined, opts: { requireRunningService?: boolean } = {}, @@ -1325,6 +1411,18 @@ export async function maybeRestartService(params: { return true; } + if (params.serviceMutationSkipMessage) { + if (params.opts.json) { + defaultRuntime.error(params.serviceMutationSkipMessage); + } else { + defaultRuntime.log(""); + defaultRuntime.log( + theme.warn(`Gateway: restart skipped: ${params.serviceMutationSkipMessage}`), + ); + } + return true; + } + if (!params.opts.json) { defaultRuntime.log(""); defaultRuntime.log(theme.muted("Gateway: restart skipped (--no-restart).")); diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index a31191252910..5ce9b8951da7 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import type { GatewayService } from "../../daemon/service.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; +import { defaultRuntime } from "../../runtime.js"; import { updatePluginsAfterCoreUpdate, type PostCorePluginUpdateResult, @@ -17,6 +18,7 @@ import { resolvePostInstallDoctorEnv, resolvePostUpdateServiceStateReadEnv, resolveUpdatedGatewayRestartPort, + maybeRestartService, shouldPrepareUpdatedInstallRestart, } from "./update-command-service.js"; import { testing as updateCommandServiceTesting } from "./update-command-service.test-support.js"; @@ -176,6 +178,31 @@ describe("resolveUpdatedGatewayRestartPort", () => { }); }); +describe("maybeRestartService", () => { + it("reports service ownership skips to JSON callers", async () => { + const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + + await expect( + maybeRestartService({ + shouldRestart: false, + result: { + status: "ok", + mode: "npm", + steps: [], + durationMs: 0, + }, + opts: { json: true }, + refreshServiceEnv: false, + gatewayPort: 18789, + serviceMutationSkipMessage: "service management skipped: ownership conflict", + timeoutMs: 1_000, + }), + ).resolves.toBe(true); + + expect(errorSpy).toHaveBeenCalledWith("service management skipped: ownership conflict"); + }); +}); + describe("resolvePostUpdateServiceStateReadEnv", () => { it("keeps package restart preparation anchored to the pre-update service env", () => { const processEnv = { diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 0fecb67b9e5d..1de67155fb40 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -12,6 +12,7 @@ import { isNixMode, normalizeStateDirEnv, pinRuntimePaths, + resolveNativeServiceProfileConflict, resolveDefaultConfigCandidates, resolveConfigPathCandidate, resolveConfigPath, @@ -57,6 +58,21 @@ describe("default install identity", () => { ).toBe(true); }); + it("preserves implicit legacy config discovery for the default profile", async () => { + await withTempDir({ prefix: "openclaw-default-install-legacy-config-" }, async (home) => { + const stateDir = path.join(home, ".openclaw"); + const legacyStateDir = path.join(home, ".clawdbot"); + const legacyConfigPath = path.join(legacyStateDir, "clawdbot.json"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.mkdir(legacyStateDir, { recursive: true }); + await fs.writeFile(legacyConfigPath, "{}"); + + const env = { HOME: home }; + expect(resolveConfigPathCandidate(env, () => home)).toBe(legacyConfigPath); + expect(isDefaultInstallIdentity(env, () => home)).toBe(true); + }); + }); + it("rejects non-default state or config paths", () => { const home = "/home/test"; @@ -73,11 +89,208 @@ describe("default install identity", () => { it("rejects process home overrides that relocate the implicit install", () => { const accountHome = "/home/test"; + const stateDir = path.join(accountHome, ".openclaw"); expect(isDefaultInstallIdentity({ HOME: "/tmp/copied-home" }, () => accountHome)).toBe(false); - expect(isDefaultInstallIdentity({ OPENCLAW_HOME: "/tmp/copied-home" }, () => accountHome)).toBe( - false, - ); + expect( + isDefaultInstallIdentity( + { + HOME: "/tmp/copied-home", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + }, + () => accountHome, + ), + ).toBe(false); + expect( + isDefaultInstallIdentity( + { + USERPROFILE: "/tmp/copied-home", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + }, + () => accountHome, + ), + ).toBe(false); + }); + + it("rejects installs relocated through OPENCLAW_HOME", () => { + const accountHome = "/home/test"; + const installHome = "/srv/openclaw"; + const stateDir = path.join(installHome, ".openclaw"); + + expect(isDefaultInstallIdentity({ OPENCLAW_HOME: installHome }, () => accountHome)).toBe(false); + expect( + isDefaultInstallIdentity( + { + OPENCLAW_HOME: installHome, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + }, + () => accountHome, + ), + ).toBe(false); + expect( + isDefaultInstallIdentity( + { + OPENCLAW_HOME: installHome, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: path.join(installHome, ".openclaw-work"), + OPENCLAW_CONFIG_PATH: path.join(installHome, ".openclaw-work", "openclaw.json"), + }, + () => accountHome, + ), + ).toBe(false); + }); + + it("accepts the canonical paths a named profile projects", async () => { + await withTempDir({ prefix: "openclaw-profile-install-" }, async (home) => { + const defaultStateDir = path.join(home, ".openclaw"); + const profileStateDir = path.join(home, ".openclaw-work"); + await fs.mkdir(defaultStateDir, { recursive: true }); + await fs.writeFile(path.join(defaultStateDir, "openclaw.json"), "{}"); + + expect( + isDefaultInstallIdentity( + { + HOME: home, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: profileStateDir, + OPENCLAW_CONFIG_PATH: path.join(profileStateDir, "openclaw.json"), + }, + () => home, + ), + ).toBe(true); + expect( + isDefaultInstallIdentity( + { + HOME: home, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: profileStateDir, + }, + () => home, + ), + ).toBe(false); + + await fs.mkdir(profileStateDir, { recursive: true }); + await fs.writeFile(path.join(profileStateDir, "openclaw.json"), "{}"); + expect( + isDefaultInstallIdentity( + { + HOME: home, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: profileStateDir, + }, + () => home, + ), + ).toBe(true); + expect( + isDefaultInstallIdentity( + { + HOME: home, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: path.join(home, ".openclaw-other"), + }, + () => home, + ), + ).toBe(false); + expect( + isDefaultInstallIdentity( + { + HOME: home, + OPENCLAW_PROFILE: "default", + OPENCLAW_STATE_DIR: defaultStateDir, + }, + () => home, + ), + ).toBe(true); + }); + }); + + it.each([ + { + platform: "darwin" as const, + envKey: "OPENCLAW_LAUNCHD_LABEL", + value: "ai.openclaw.gateway", + }, + { + platform: "linux" as const, + envKey: "OPENCLAW_SYSTEMD_UNIT", + value: "openclaw-gateway.service", + }, + { + platform: "win32" as const, + envKey: "OPENCLAW_WINDOWS_TASK_NAME", + value: "OpenClaw Gateway", + }, + ])("rejects a named profile overriding $envKey on $platform", ({ platform, envKey, value }) => { + const home = "/home/test"; + const stateDir = path.join(home, ".openclaw-work"); + expect( + isDefaultInstallIdentity( + { + HOME: home, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + [envKey]: value, + }, + () => home, + platform, + ), + ).toBe(false); + }); + + it.each(["../escape", "work/../../escape", "work\\..\\escape", "."])( + "rejects invalid profile %j even when its derived paths match", + (profile) => { + const home = "/home/test"; + const profileStateDir = path.join(home, `.openclaw-${profile}`); + + expect( + isDefaultInstallIdentity( + { + HOME: home, + OPENCLAW_PROFILE: profile, + OPENCLAW_STATE_DIR: profileStateDir, + OPENCLAW_CONFIG_PATH: path.join(profileStateDir, "openclaw.json"), + }, + () => home, + ), + ).toBe(false); + }, + ); + + it.each(["gateway", "node"])( + "rejects macOS profile %j because its LaunchAgent label is reserved", + (profile) => { + expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "darwin")).toBe( + profile, + ); + expect( + resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "linux"), + ).toBeNull(); + }, + ); + + it.each(["Main", "MAIN", "Work"])( + "rejects mixed-case native service profile %j on case-insensitive platforms", + (profile) => { + expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "darwin")).toBe( + profile, + ); + expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "win32")).toBe( + profile, + ); + expect( + resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "linux"), + ).toBeNull(); + }, + ); + + it("keeps lowercase native service profiles byte-compatible", () => { + expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: "main" }, "darwin")).toBeNull(); + expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: "main" }, "win32")).toBeNull(); }); }); diff --git a/src/config/paths.ts b/src/config/paths.ts index aecd004d6f66..59be6d7011e1 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { isValidProfileName } from "../cli/profile-utils.js"; +import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js"; import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js"; import { parseTcpPort } from "../infra/tcp-port.js"; import { isFastTestRuntimeEnv } from "../infra/test-runtime-env.js"; @@ -124,32 +126,88 @@ export function isDefaultStateDir( ); } +/** Canonical state directory name for the selected profile, mirroring root `--profile`. */ +function profileStateDirName(env: NodeJS.ProcessEnv): string | null { + const profile = env.OPENCLAW_PROFILE?.trim(); + if (!profile || profile.toLowerCase() === "default") { + return NEW_STATE_DIRNAME; + } + if (!isValidProfileName(profile)) { + return null; + } + return `${NEW_STATE_DIRNAME}-${profile}`; +} + +export function resolveNativeServiceProfileConflict( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string | null { + if (platform !== "darwin" && platform !== "win32") { + return null; + } + const profile = env.OPENCLAW_PROFILE?.trim(); + if (!profile || profile.toLowerCase() === "default") { + return null; + } + // Normal macOS and Windows filesystems fold case, so case-distinct profile + // names can share state and native-service paths even though the CLI keeps + // them distinct. Leave the runtime profile valid, but deny service mutation. + if (profile !== profile.toLowerCase()) { + return profile; + } + if (platform !== "darwin") { + return null; + } + // These names map to the shipped default Gateway and node-host LaunchAgent + // labels, so authorizing them would let one profile control another service. + return profile === "gateway" || profile === "node" ? profile : null; +} + /** Whether host service management belongs to the active default install identity. */ export function isDefaultInstallIdentity( env: NodeJS.ProcessEnv = process.env, homedir: () => string = resolveSystemAccountHomeDir, + platform: NodeJS.Platform = process.platform, ): boolean { const accountHome = resolveRequiredHomeDir({}, homedir); - const accountHomedir = () => accountHome; + // Profiles have distinct host-service names; relocated homes do not. Keep + // OPENCLAW_HOME isolated so an alternate state tree cannot adopt that service. + if (env.OPENCLAW_HOME?.trim()) { + return false; + } if ( - normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !== - normalizePathForComparison(newStateDir(accountHomedir)) + normalizePathForComparison(resolveRequiredHomeDir(env, homedir)) !== + normalizePathForComparison(accountHome) ) { return false; } - if (!env.OPENCLAW_CONFIG_PATH?.trim()) { + if ( + resolveNativeServiceProfileConflict(env, platform) || + resolveGatewayNativeServiceIdentityConflict(env, platform) + ) { + return false; + } + const stateDirName = profileStateDirName(env); + // Environment profiles can bypass root CLI parsing. Reject them before path + // construction so separators or dot segments cannot authorize a host service. + if (!stateDirName) { + return false; + } + const canonicalStateDir = path.join(accountHome, stateDirName); + if ( + normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !== + normalizePathForComparison(canonicalStateDir) + ) { + return false; + } + // Default installs historically allow implicit legacy config discovery. + // Named profiles must resolve their own config so they cannot inherit the default profile. + if (stateDirName === NEW_STATE_DIRNAME && !env.OPENCLAW_CONFIG_PATH?.trim()) { return true; } - const defaultConfigEnv = { - ...env, - HOME: accountHome, - OPENCLAW_HOME: undefined, - OPENCLAW_STATE_DIR: undefined, - OPENCLAW_CONFIG_PATH: undefined, - }; return ( normalizePathForComparison(resolveConfigPathCandidate(env, envHomedir(env))) === - normalizePathForComparison(resolveConfigPathCandidate(defaultConfigEnv, accountHomedir)) + normalizePathForComparison(path.join(canonicalStateDir, CONFIG_FILENAME)) ); } diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index 6c3a4d829e70..573b0ec07f67 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -4,6 +4,7 @@ import { GATEWAY_LAUNCH_AGENT_LABEL, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, resolveGatewayLaunchAgentLabel, + resolveGatewayNativeServiceIdentityConflict, resolveGatewayProfileSuffix, resolveGatewayServiceDescription, resolveGatewaySystemdServiceName, @@ -47,6 +48,48 @@ describe("resolveGatewayWindowsTaskName", () => { }); }); +describe("resolveGatewayNativeServiceIdentityConflict", () => { + it.each([ + { + platform: "darwin" as const, + envKey: "OPENCLAW_LAUNCHD_LABEL", + value: "ai.openclaw.gateway", + }, + { + platform: "linux" as const, + envKey: "OPENCLAW_SYSTEMD_UNIT", + value: "openclaw-gateway.service", + }, + { + platform: "win32" as const, + envKey: "OPENCLAW_WINDOWS_TASK_NAME", + value: "OpenClaw Gateway", + }, + ])("rejects $envKey overrides for named profiles on $platform", ({ platform, envKey, value }) => { + expect( + resolveGatewayNativeServiceIdentityConflict( + { OPENCLAW_PROFILE: "work", [envKey]: value }, + platform, + ), + ).toMatchObject({ envKey }); + }); + + it("accepts canonical named-profile identities and default-profile overrides", () => { + expect( + resolveGatewayNativeServiceIdentityConflict( + { OPENCLAW_PROFILE: "work", OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-work" }, + "linux", + ), + ).toBeNull(); + expect( + resolveGatewayNativeServiceIdentityConflict( + { OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service" }, + "linux", + ), + ).toBeNull(); + }); +}); + describe("resolveGatewayProfileSuffix", () => { it("returns empty string when no profile is set", () => { expect(resolveGatewayProfileSuffix()).toBe(""); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 7523de9c7442..d21380063478 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -59,6 +59,42 @@ export function resolveGatewayWindowsTaskName(profile?: string): string { return `OpenClaw Gateway (${normalized})`; } +type GatewayNativeServiceIdentityConflict = { + envKey: "OPENCLAW_LAUNCHD_LABEL" | "OPENCLAW_SYSTEMD_UNIT" | "OPENCLAW_WINDOWS_TASK_NAME"; + expected: string; +}; + +export function resolveGatewayNativeServiceIdentityConflict( + env: Record, + platform: NodeJS.Platform = process.platform, +): GatewayNativeServiceIdentityConflict | null { + const profile = normalizeGatewayProfile(env.OPENCLAW_PROFILE); + if (!profile) { + return null; + } + + if (platform === "darwin") { + const envKey = "OPENCLAW_LAUNCHD_LABEL"; + const actual = env[envKey]?.trim(); + const expected = resolveGatewayLaunchAgentLabel(profile); + return actual && actual !== expected ? { envKey, expected } : null; + } + if (platform === "linux") { + const envKey = "OPENCLAW_SYSTEMD_UNIT"; + const actual = env[envKey]?.trim(); + const normalizedActual = actual?.endsWith(".service") ? actual : actual && `${actual}.service`; + const expected = `${resolveGatewaySystemdServiceName(profile)}.service`; + return normalizedActual && normalizedActual !== expected ? { envKey, expected } : null; + } + if (platform === "win32") { + const envKey = "OPENCLAW_WINDOWS_TASK_NAME"; + const actual = env[envKey]?.trim(); + const expected = resolveGatewayWindowsTaskName(profile); + return actual && actual !== expected ? { envKey, expected } : null; + } + return null; +} + function formatGatewayServiceDescription(params?: { profile?: string; version?: string }): string { const profile = normalizeGatewayProfile(params?.profile); const version = params?.version?.trim(); diff --git a/src/daemon/launchd.integration.e2e.test.ts b/src/daemon/launchd.integration.e2e.test.ts index f5cd6a90961e..e0b24b99eacb 100644 --- a/src/daemon/launchd.integration.e2e.test.ts +++ b/src/daemon/launchd.integration.e2e.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; import { withTimeout } from "../utils/with-timeout.js"; import { installLaunchAgent, @@ -13,6 +14,7 @@ import { repairLaunchAgentBootstrap, restartLaunchAgent, resolveLaunchAgentPlistPath, + startLaunchAgent, stopLaunchAgent, uninstallLaunchAgent, } from "./launchd.js"; @@ -192,6 +194,81 @@ describeLaunchdIntegration("launchd integration", () => { await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); }, 60_000); + it("manages a named profile through the guarded host-service lifecycle", async () => { + const testId = randomUUID().slice(0, 8); + const profile = `launchd-int-${testId}`; + const accountHome = os.userInfo().homedir; + const stateDir = path.join(accountHome, `.openclaw-${profile}`); + const profileEnv: GatewayServiceEnv = { + HOME: accountHome, + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: profile, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + OPENCLAW_LAUNCHD_LABEL: undefined, + OPENCLAW_SUPERVISOR_MODE: undefined, + }; + + await withEnvAsync(profileEnv, async () => { + const service = resolveGatewayService(); + try { + await service.install({ + env: profileEnv, + stdout, + programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], + }); + const installed = await waitForRunningRuntime({ env: profileEnv }); + + await service.stop({ env: profileEnv, stdout }); + await waitForNotRunningRuntime({ env: profileEnv }); + + const startResult = await startGatewayService(service, { env: profileEnv, stdout }); + expect(startResult.outcome).toBe("started"); + const started = await waitForRunningRuntime({ + env: profileEnv, + pidNot: installed.pid, + }); + + await service.restart({ env: profileEnv, stdout }); + await expectRuntimePidReplaced({ env: profileEnv, previousPid: started.pid }); + } finally { + try { + await service.uninstall({ env: profileEnv, stdout }); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + } + }); + }, 60_000); + + it("refuses a relocated OPENCLAW_HOME before launchd mutation", async () => { + const testId = randomUUID().slice(0, 8); + const relocatedHome = await fs.mkdtemp( + path.join(os.tmpdir(), `openclaw-relocated-home-${testId}-`), + ); + const relocatedEnv: GatewayServiceEnv = { + HOME: os.userInfo().homedir, + OPENCLAW_HOME: relocatedHome, + OPENCLAW_PROFILE: `launchd-int-${testId}`, + }; + + try { + await withEnvAsync(relocatedEnv, async () => { + const service = resolveGatewayService(); + await expect( + service.install({ + env: relocatedEnv, + stdout, + programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], + }), + ).rejects.toThrow("service management skipped: non-default state dir or config path"); + await expect(fs.access(resolveLaunchAgentPlistPath(relocatedEnv))).rejects.toThrow(); + }); + } finally { + await fs.rm(relocatedHome, { recursive: true, force: true }); + } + }); + it("keeps LaunchAgent supervision after a raw SIGTERM", async () => { const launchEnv = launchEnvOrThrow(env); await initializeLaunchdRuntime(launchEnv, stdout); @@ -208,9 +285,7 @@ describeLaunchdIntegration("launchd integration", () => { const before = await waitForRunningRuntime({ env: launchEnv }); await stopLaunchAgent({ env: launchEnv, stdout }); await waitForNotRunningRuntime({ env: launchEnv }); - const service = resolveGatewayService(); - const startResult = await startGatewayService(service, { env: launchEnv, stdout }); - expect(startResult.outcome).toBe("started"); + await startLaunchAgent({ env: launchEnv, stdout }); await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); }, 60_000); diff --git a/src/daemon/schtasks.integration.e2e.test.ts b/src/daemon/schtasks.integration.e2e.test.ts new file mode 100644 index 000000000000..7d193c508f89 --- /dev/null +++ b/src/daemon/schtasks.integration.e2e.test.ts @@ -0,0 +1,1052 @@ +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import { createServer, type AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { getWindowsPowerShellExePath } from "../infra/windows-install-roots.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { resolveGatewayWindowsTaskName } from "./constants.js"; +import { execSchtasks } from "./schtasks-exec.js"; +import { resolveStartupEntryPaths, resolveTaskLauncherScriptPath } from "./schtasks-layout.js"; +import { readWindowsProcessSnapshot } from "./schtasks-process.js"; +import { probeScheduledTaskExists } from "./schtasks-runtime.js"; +import { type ProbeRunEvent, waitForExactProbeRun } from "./schtasks.integration.test-helpers.js"; +import { resolveTaskScriptPath } from "./schtasks.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; +import type { GatewayServiceEnv } from "./service-types.js"; +import { resolveGatewayService } from "./service.js"; + +const WAIT_INTERVAL_MS = 200; +const WAIT_TIMEOUT_MS = 30_000; +const DIAGNOSTIC_TEXT_LIMIT = 16_384; +const DIAGNOSTIC_PROCESS_LIMIT = 32; +const TASK_LOGON_INTERACTIVE_TOKEN = 3; +const TASK_RUNLEVEL_LEAST_PRIVILEGE = 0; + +type ScheduledTaskPrincipal = { + lastRunTime: string; + lastTaskResult: number; + logonType: number; + runLevel: number; + taskState: number; +}; + +type WindowsProcessDiagnostic = { + CommandLine?: string | null; + ParentProcessId?: number; + ProcessId?: number; +}; + +type FailureDiagnosticSnapshot = { + capturedAt: string; + principal: ScheduledTaskPrincipal | null; + principalError: string | null; + processCapture: { + error: string | null; + ok: boolean; + processes: WindowsProcessDiagnostic[]; + truncated: boolean; + }; + taskXml: string | null; + verboseQuery: { + code: number; + stdout: string | null; + stderr: string | null; + }; +}; + +type TaskDefinitionSnapshot = { exists: false; taskXml: null } | { exists: true; taskXml: string }; + +async function sleep(delayMs = WAIT_INTERVAL_MS): Promise { + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} + +async function waitForRuntimeStatus( + readRuntime: () => Promise, + expected: "running" | "stopped", + expectedPid?: number, +): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let lastStatus = "unknown"; + let lastDetail = ""; + let lastPid: number | undefined; + while (Date.now() < deadline) { + const runtime = await readRuntime(); + lastStatus = runtime.status ?? "unknown"; + lastDetail = runtime.detail ?? ""; + lastPid = runtime.pid; + if (runtime.status === expected && (expectedPid === undefined || runtime.pid === expectedPid)) { + return; + } + await sleep(); + } + throw new Error( + `Timed out waiting for Scheduled Task status=${expected}${ + expectedPid === undefined ? "" : ` pid=${expectedPid}` + }; observed ${lastStatus}${lastPid === undefined ? "" : ` pid=${lastPid}`}: ${lastDetail}`, + ); +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) { + return; + } + await sleep(); + } + throw new Error(`Timed out waiting for Scheduled Task process ${pid} to exit`); +} + +async function reserveLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not reserve a loopback port for the Scheduled Task probe"); + } + const port = (address as AddressInfo).port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function canBindLoopbackPort(port: number): Promise { + const server = createServer(); + return new Promise((resolve) => { + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function waitForLoopbackPortRelease(port: number): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await canBindLoopbackPort(port)) { + return; + } + await sleep(); + } + throw new Error(`Timed out waiting for Scheduled Task loopback port ${port} to be reusable`); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +async function readTaskXml(taskName: string): Promise { + const result = await execSchtasks(["/Query", "/TN", taskName, "/XML"]); + return result.code === 0 + ? result.stdout.replace(/^\uFEFF/u, "").replaceAll(String.fromCharCode(0), "") + : null; +} + +function readTaskPrincipal(taskName: string): ScheduledTaskPrincipal { + const encodedTaskName = Buffer.from(taskName, "utf8").toString("base64"); + const script = [ + "$ErrorActionPreference='Stop'", + `$taskName=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedTaskName}'))`, + "$service=New-Object -ComObject 'Schedule.Service'", + "$service.Connect()", + "$task=$service.GetFolder('\\').GetTask($taskName)", + "$principal=$task.Definition.Principal", + "$result=@{logonType=[int]$principal.LogonType;runLevel=[int]$principal.RunLevel;taskState=[int]$task.State;lastTaskResult=[int64]$task.LastTaskResult;lastRunTime=$task.LastRunTime.ToUniversalTime().ToString('o')}", + "[Console]::Out.Write(($result | ConvertTo-Json -Compress))", + ].join("; "); + const result = spawnSync( + getWindowsPowerShellExePath(), + [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + { encoding: "utf8", timeout: 5_000, windowsHide: true }, + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `Could not inspect Scheduled Task principal for ${taskName}: ${ + result.stderr.trim() || `PowerShell exited ${result.status ?? "without status"}` + }`, + ); + } + const parsed = JSON.parse(result.stdout.trim()) as Partial; + if ( + typeof parsed.logonType !== "number" || + !Number.isInteger(parsed.logonType) || + typeof parsed.runLevel !== "number" || + !Number.isInteger(parsed.runLevel) || + typeof parsed.taskState !== "number" || + !Number.isInteger(parsed.taskState) || + typeof parsed.lastTaskResult !== "number" || + !Number.isInteger(parsed.lastTaskResult) || + typeof parsed.lastRunTime !== "string" + ) { + throw new Error(`Scheduled Task principal returned invalid data for ${taskName}`); + } + return { + lastRunTime: parsed.lastRunTime, + lastTaskResult: parsed.lastTaskResult, + logonType: parsed.logonType, + runLevel: parsed.runLevel, + taskState: parsed.taskState, + }; +} + +function readRelatedProcessDiagnostics(needles: string[]): { + error: string | null; + ok: boolean; + processes: WindowsProcessDiagnostic[]; + truncated: boolean; +} { + const script = [ + "$ErrorActionPreference='Stop'", + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress", + ].join("; "); + const result = spawnSync( + getWindowsPowerShellExePath(), + [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: 5_000, windowsHide: true }, + ); + if (result.error) { + return { error: result.error.message, ok: false, processes: [], truncated: false }; + } + if (result.status !== 0) { + return { + error: result.stderr.trim() || `PowerShell exited ${result.status ?? "without status"}`, + ok: false, + processes: [], + truncated: false, + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout.trim() || "[]"); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + ok: false, + processes: [], + truncated: false, + }; + } + const entries = (Array.isArray(parsed) ? parsed : [parsed]).filter( + (entry): entry is WindowsProcessDiagnostic => typeof entry === "object" && entry !== null, + ); + const normalizedNeedles = needles.map((needle) => needle.replaceAll("/", "\\").toLowerCase()); + const matching = entries.filter((entry) => { + const commandLine = (entry.CommandLine ?? "").replaceAll("/", "\\").toLowerCase(); + return normalizedNeedles.some((needle) => commandLine.includes(needle)); + }); + const parentPids = new Set( + matching + .map((entry) => entry.ParentProcessId) + .filter((pid): pid is number => typeof pid === "number"), + ); + const processes = entries.filter( + (entry) => + matching.includes(entry) || + (typeof entry.ProcessId === "number" && parentPids.has(entry.ProcessId)), + ); + return { + error: null, + ok: true, + processes: processes.slice(0, DIAGNOSTIC_PROCESS_LIMIT), + truncated: processes.length > DIAGNOSTIC_PROCESS_LIMIT, + }; +} + +function sanitizeDiagnosticText( + value: string | null | undefined, + replacements: Array<[string, string]>, +): string | null { + if (value === null || value === undefined) { + return null; + } + const variantPlaceholders = new Map(); + for (const [privateValue, placeholder] of replacements) { + if (privateValue) { + for (const variant of new Set([ + privateValue, + privateValue.replaceAll("/", "\\"), + privateValue.replaceAll("\\", "/"), + ])) { + variantPlaceholders.set(variant.toLowerCase(), placeholder); + } + } + } + const pattern = Array.from(variantPlaceholders.keys()) + .toSorted((left, right) => right.length - left.length) + .map((variant) => variant.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) + .join("|"); + const sanitized = pattern + ? value.replace(new RegExp(pattern, "giu"), (match) => { + return variantPlaceholders.get(match.toLowerCase()) ?? match; + }) + : value; + return sanitized.length <= DIAGNOSTIC_TEXT_LIMIT + ? sanitized + : `${sanitized.slice(0, DIAGNOSTIC_TEXT_LIMIT)}\n[truncated]`; +} + +function sanitizeTaskXml( + value: string | null, + replacements: Array<[string, string]>, +): string | null { + const identityRedacted = + value?.replace( + /<(UserId|Author)>([\s\S]*?)<\/\1>/giu, + (_match, tag: string) => `<${tag}>`, + ) ?? null; + if (identityRedacted === null) { + return null; + } + return identityRedacted + .split(/(<[^>]+>)/gu) + .map((segment) => + segment.startsWith("<") ? segment : (sanitizeDiagnosticText(segment, replacements) ?? ""), + ) + .join(""); +} + +function sanitizeVerboseQuery(value: string, replacements: Array<[string, string]>): string | null { + return ( + sanitizeDiagnosticText(value, replacements)?.replace( + /^(\s*(?:HostName|Run As User)\s*:\s*).*$/gimu, + "$1", + ) ?? null + ); +} + +function resolveDiagnosticReplacements(params: { + rootDir: string; + stateDir: string; +}): Array<[string, string]> { + const username = os.userInfo().username; + const domain = process.env.USERDOMAIN?.trim(); + return [ + [os.userInfo().homedir, ""], + [params.rootDir, ""], + [params.stateDir, ""], + [domain && username ? `${domain}\\${username}` : "", ""], + [process.env.COMPUTERNAME?.trim() ?? "", ""], + [os.hostname(), ""], + ]; +} + +function assertInteractiveLeastPrivilegeTask(params: { + principal: ScheduledTaskPrincipal; + taskXml: string; +}): void { + expect(params.taskXml).toContain("InteractiveToken"); + expect(params.principal.logonType).toBe(TASK_LOGON_INTERACTIVE_TOKEN); + expect(params.principal.runLevel).toBe(TASK_RUNLEVEL_LEAST_PRIVILEGE); + const exportedRunLevel = params.taskXml.match(/([^<]+)<\/RunLevel>/u)?.[1]; + // Task Scheduler may omit the default LeastPrivilege node when exporting XML. + // If present, it must agree with the effective COM principal checked above. + expect(exportedRunLevel === undefined || exportedRunLevel === "LeastPrivilege").toBe(true); +} + +async function waitForSuccessfulScheduledTaskRun( + taskName: string, +): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let lastPrincipal: ScheduledTaskPrincipal | null = null; + let lastError: unknown; + while (Date.now() < deadline) { + try { + lastPrincipal = readTaskPrincipal(taskName); + if ( + lastPrincipal.lastTaskResult === 0 && + !Number.isNaN(Date.parse(lastPrincipal.lastRunTime)) && + Date.parse(lastPrincipal.lastRunTime) > 0 + ) { + return lastPrincipal; + } + } catch (error) { + lastError = error; + } + await sleep(); + } + throw new Error( + `Timed out waiting for Scheduled Task ${taskName} to record a successful run; ${ + lastPrincipal + ? `observed state=${lastPrincipal.taskState} result=${lastPrincipal.lastTaskResult}` + : `last inspection failed: ${lastError instanceof Error ? lastError.message : String(lastError)}` + }`, + ); +} + +async function readTaskDefinitionSnapshot(taskName: string): Promise { + const exists = probeScheduledTaskExists(taskName); + if (exists === null) { + throw new Error(`Could not determine whether Scheduled Task ${taskName} exists`); + } + if (!exists) { + return { exists: false, taskXml: null }; + } + const taskXml = await readTaskXml(taskName); + if (!taskXml) { + throw new Error(`Could not export Scheduled Task XML for ${taskName}`); + } + return { exists: true, taskXml }; +} + +async function clearActivePid(activePidPath: string, pid: number): Promise { + const activePid = Number.parseInt(await fs.readFile(activePidPath, "utf8").catch(() => ""), 10); + if (activePid === pid) { + await fs.rm(activePidPath, { force: true }); + } +} + +async function forceKillActiveProcess(params: { + activePidPath: string; + eventsPath: string; + probePath: string; +}): Promise { + await sleep(); + let activePidText: string; + try { + activePidText = await fs.readFile(params.activePidPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + const activePid = Number.parseInt(activePidText.trim(), 10); + if (!Number.isSafeInteger(activePid) || activePid <= 1) { + throw new Error(`Invalid Scheduled Task active process id: ${activePidText.trim() || "empty"}`); + } + if (!isProcessAlive(activePid)) { + await fs.rm(params.activePidPath, { force: true }); + return; + } + const normalizedProbePath = params.probePath.replaceAll("/", "\\").toLowerCase(); + const normalizedEventsPath = params.eventsPath.replaceAll("/", "\\").toLowerCase(); + const snapshot = readWindowsProcessSnapshot(); + if (!snapshot) { + throw new Error("Could not verify Scheduled Task probe ownership during cleanup"); + } + const activeProcess = snapshot.find((entry) => entry.ProcessId === activePid); + const commandLine = (activeProcess?.CommandLine ?? "").replaceAll("/", "\\").toLowerCase(); + if (!commandLine.includes(normalizedProbePath) || !commandLine.includes(normalizedEventsPath)) { + throw new Error( + `Refused to kill reused or unverifiable Scheduled Task process id ${activePid}`, + ); + } + try { + process.kill(activePid, "SIGKILL"); + } catch {} + await waitForProcessExit(activePid); + await fs.rm(params.activePidPath, { force: true }); +} + +async function readFailureDiagnosticSnapshot(params: { + eventsPath: string; + probePath: string; + replacements: Array<[string, string]>; + scriptPath: string; + taskName: string; +}): Promise { + const verboseQuery = await execSchtasks(["/Query", "/TN", params.taskName, "/V", "/FO", "LIST"]); + const taskXml = await readTaskXml(params.taskName); + let principal: ScheduledTaskPrincipal | null = null; + let principalError: string | null = null; + try { + principal = readTaskPrincipal(params.taskName); + } catch (error) { + principalError = error instanceof Error ? error.message : String(error); + } + const processCapture = readRelatedProcessDiagnostics([ + params.scriptPath, + params.probePath, + params.eventsPath, + ]); + for (const process of processCapture.processes) { + process.CommandLine = sanitizeDiagnosticText(process.CommandLine, params.replacements); + } + return { + capturedAt: new Date().toISOString(), + principal, + principalError: sanitizeDiagnosticText(principalError, params.replacements), + processCapture: { + ...processCapture, + error: sanitizeDiagnosticText(processCapture.error, params.replacements), + }, + taskXml: sanitizeTaskXml(taskXml, params.replacements), + verboseQuery: { + code: verboseQuery.code, + stdout: sanitizeVerboseQuery(verboseQuery.stdout, params.replacements), + stderr: sanitizeDiagnosticText(verboseQuery.stderr, params.replacements), + }, + }; +} + +async function writeFailureDiagnostics(params: { + cleanupEnd: { stdout: string; stderr: string; code: number } | null; + postEnd: FailureDiagnosticSnapshot | null; + postEndError: string | null; + preCleanup: FailureDiagnosticSnapshot | null; + preCleanupError: string | null; + replacements: Array<[string, string]>; + rootDir: string; + serviceOutput: string; +}): Promise { + await fs.writeFile( + path.join(params.rootDir, "failure-diagnostics.json"), + `${JSON.stringify( + { + preCleanup: params.preCleanup, + preCleanupError: sanitizeDiagnosticText(params.preCleanupError, params.replacements), + cleanupEnd: params.cleanupEnd + ? { + code: params.cleanupEnd.code, + stdout: sanitizeDiagnosticText(params.cleanupEnd.stdout, params.replacements), + stderr: sanitizeDiagnosticText(params.cleanupEnd.stderr, params.replacements), + } + : null, + postEnd: params.postEnd, + postEndError: sanitizeDiagnosticText(params.postEndError, params.replacements), + serviceOutput: sanitizeDiagnosticText(params.serviceOutput, params.replacements), + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +async function cleanupNativeTask(params: { + activePidPath: string; + eventsPath: string; + preserveEvidence: boolean; + probePath: string; + rootDir: string; + scriptPath: string; + serviceOutput: string; + stateDir: string; + taskName: string; +}): Promise { + const cleanupErrors: unknown[] = []; + const replacements = resolveDiagnosticReplacements({ + rootDir: params.rootDir, + stateDir: params.stateDir, + }); + const snapshotParams = { + eventsPath: params.eventsPath, + probePath: params.probePath, + replacements, + scriptPath: params.scriptPath, + taskName: params.taskName, + }; + let preCleanup: FailureDiagnosticSnapshot | null = null; + let preCleanupError: string | null = null; + if (params.preserveEvidence) { + try { + preCleanup = await readFailureDiagnosticSnapshot(snapshotParams); + } catch (error) { + preCleanupError = error instanceof Error ? error.message : String(error); + } + } + const endResult = await execSchtasks(["/End", "/TN", params.taskName]).catch((error: unknown) => { + cleanupErrors.push(error); + return null; + }); + if (params.preserveEvidence) { + let postEnd: FailureDiagnosticSnapshot | null = null; + let postEndError: string | null = null; + try { + postEnd = await readFailureDiagnosticSnapshot(snapshotParams); + } catch (error) { + postEndError = error instanceof Error ? error.message : String(error); + } + try { + await writeFailureDiagnostics({ + cleanupEnd: endResult, + postEnd, + postEndError, + preCleanup, + preCleanupError, + replacements, + rootDir: params.rootDir, + serviceOutput: params.serviceOutput, + }); + } catch (error) { + cleanupErrors.push(error); + } + } + try { + await forceKillActiveProcess({ + activePidPath: params.activePidPath, + eventsPath: params.eventsPath, + probePath: params.probePath, + }); + } catch (error) { + cleanupErrors.push(error); + } + const deletion = await execSchtasks(["/Delete", "/F", "/TN", params.taskName]).catch( + (error: unknown) => { + cleanupErrors.push(error); + return null; + }, + ); + const taskExists = probeScheduledTaskExists(params.taskName); + if (taskExists === null) { + cleanupErrors.push(new Error(`Could not verify Scheduled Task cleanup for ${params.taskName}`)); + } else if (taskExists) { + const detail = deletion ? (deletion.stderr || deletion.stdout).trim() : ""; + cleanupErrors.push( + new Error( + `Scheduled Task cleanup left ${params.taskName} registered${detail ? `: ${detail}` : ""}`, + ), + ); + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Native Scheduled Task process or task cleanup failed"); + } + if (params.preserveEvidence) { + return; + } + for (const cleanupPath of [params.stateDir, params.rootDir]) { + try { + await fs.rm(cleanupPath, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(error); + } + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Native Scheduled Task path cleanup failed"); + } +} + +function expectProbeProcessAlive(pid: number): void { + expect(isProcessAlive(pid), `Expected Scheduled Task probe process ${pid} to remain alive`).toBe( + true, + ); +} + +function expectScheduledTaskProbeOrigin(params: { + eventsPath: string; + probePath: string; + run: ProbeRunEvent; + scriptPath: string; +}): void { + expect(params.run.ppid).not.toBe(process.pid); + const capture = readRelatedProcessDiagnostics([ + params.eventsPath, + params.probePath, + params.scriptPath, + ]); + expect(capture.ok).toBe(true); + expect(capture.truncated).toBe(false); + const processEntry = capture.processes.find((entry) => entry.ProcessId === params.run.pid); + expect(processEntry?.ParentProcessId).toBe(params.run.ppid); + const parentEntry = capture.processes.find((entry) => entry.ProcessId === params.run.ppid); + const normalizeCommandLine = (value: string | null | undefined) => + (value ?? "").replaceAll("/", "\\").toLowerCase(); + const processCommandLine = normalizeCommandLine(processEntry?.CommandLine); + expect(processCommandLine.includes(normalizeCommandLine(params.probePath))).toBe(true); + expect(processCommandLine.includes(normalizeCommandLine(params.eventsPath))).toBe(true); + expect( + normalizeCommandLine(parentEntry?.CommandLine).includes( + normalizeCommandLine(params.scriptPath), + ), + ).toBe(true); +} + +function resolveTestId(): string { + const configured = process.env.CI_WINDOWS_SCHTASKS_TEST_ID?.trim(); + if (!configured) { + return randomUUID().slice(0, 8); + } + if (!/^[a-z0-9-]{1,48}$/u.test(configured)) { + throw new Error("CI_WINDOWS_SCHTASKS_TEST_ID must use lowercase letters, digits, or -"); + } + return configured; +} + +async function createIntegrationRoot( + configuredRoot: string | undefined, + id: string, +): Promise { + if (!configuredRoot) { + return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-schtasks-int-${id}-`)); + } + const rootDir = path.resolve(configuredRoot); + try { + // Cleanup may only remove a directory this exact run created. + await fs.mkdir(rootDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`CI_WINDOWS_SCHTASKS_ROOT must not already exist: ${rootDir}`, { + cause: error, + }); + } + throw error; + } + return rootDir; +} + +describe("schtasks Windows integration principal assertion", () => { + it("accepts omitted default run level when COM reports least privilege", () => { + expect(() => + assertInteractiveLeastPrivilegeTask({ + taskXml: "InteractiveToken", + principal: { + lastRunTime: "2026-07-31T00:00:00.0000000Z", + lastTaskResult: 0, + logonType: TASK_LOGON_INTERACTIVE_TOKEN, + runLevel: TASK_RUNLEVEL_LEAST_PRIVILEGE, + taskState: 3, + }, + }), + ).not.toThrow(); + }); + + it("rejects an elevated effective run level", () => { + expect(() => + assertInteractiveLeastPrivilegeTask({ + taskXml: "InteractiveTokenLeastPrivilege", + principal: { + lastRunTime: "2026-07-31T00:00:00.0000000Z", + lastTaskResult: 0, + logonType: TASK_LOGON_INTERACTIVE_TOKEN, + runLevel: 1, + taskState: 3, + }, + }), + ).toThrow(); + }); + + it("refuses to reuse or delete an existing configured root", async () => { + const existingRoot = path.join(os.tmpdir(), `openclaw-schtasks-existing-${randomUUID()}`); + await fs.mkdir(existingRoot); + try { + await expect(createIntegrationRoot(existingRoot, "existing")).rejects.toThrow( + "CI_WINDOWS_SCHTASKS_ROOT must not already exist", + ); + await expect(fs.access(existingRoot)).resolves.toBeUndefined(); + } finally { + await fs.rm(existingRoot, { recursive: true, force: true }); + } + }); + + it("redacts task identities without rewriting placeholders", () => { + expect( + sanitizeDiagnosticText("openclaw user on host-user", [ + ["openclaw", ""], + ["user", ""], + ]), + ).toBe(" on host-"); + expect( + sanitizeTaskXml("privateS-1-5-21", [ + ["user", ""], + ]), + ).toBe(""); + expect( + sanitizeTaskXml("privateS-1-5-21", [ + ["openclaw", ""], + ]), + ).toBe(""); + }); +}); + +const nativeIntegrationEnabled = + process.platform === "win32" && process.env.CI_WINDOWS_SCHTASKS_INTEGRATION === "1"; + +describe.runIf(nativeIntegrationEnabled)("schtasks Windows integration", () => { + it("isolates and completes the native Scheduled Task lifecycle", async () => { + const id = resolveTestId(); + const configuredRoot = process.env.CI_WINDOWS_SCHTASKS_ROOT?.trim(); + const rootDir = await createIntegrationRoot(configuredRoot, id); + const accountHome = os.userInfo().homedir; + const profile = `schtasks-int-${id}`; + const stateDir = path.join(accountHome, `.openclaw-${profile}`); + const activePidPath = path.join(rootDir, "active-pid.txt"); + const eventsPath = path.join(rootDir, "runs.txt"); + const probePath = path.join(rootDir, "probe.cjs"); + const gatewayPort = await reserveLoopbackPort(); + const taskName = resolveGatewayWindowsTaskName(profile); + const stdout = new PassThrough(); + let serviceOutput = ""; + stdout.setEncoding("utf8"); + stdout.on("data", (chunk: string) => { + if (serviceOutput.length < DIAGNOSTIC_TEXT_LIMIT) { + serviceOutput = `${serviceOutput}${chunk}`.slice(0, DIAGNOSTIC_TEXT_LIMIT); + } + }); + const env: GatewayServiceEnv = { + ...process.env, + APPDATA: path.join(rootDir, "appdata"), + HOME: accountHome, + USERPROFILE: accountHome, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + OPENCLAW_GATEWAY_PORT: String(gatewayPort), + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: profile, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TASK_SCRIPT: undefined, + OPENCLAW_TASK_SCRIPT_NAME: undefined, + OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1", + OPENCLAW_WINDOWS_TASK_NAME: undefined, + }; + const defaultTaskBefore = await readTaskDefinitionSnapshot("OpenClaw Gateway"); + const scriptPath = resolveTaskScriptPath(env); + const launcherPath = resolveTaskLauncherScriptPath(env, scriptPath); + + await fs.writeFile( + probePath, + [ + 'const fs = require("node:fs");', + 'const net = require("node:net");', + "const eventsPath = process.argv[5];", + "const activePidPath = process.argv[6];", + "const appendEvent = (phase) => fs.appendFileSync(eventsPath, `${JSON.stringify({ phase, pid: process.pid, ppid: process.ppid })}\\n`);", + 'const portIndex = process.argv.indexOf("--port");', + "const port = Number.parseInt(process.argv[portIndex + 1] ?? '', 10);", + "if (!Number.isInteger(port) || port < 1) throw new Error('Missing gateway --port');", + "const activePidTempPath = `${activePidPath}.${process.pid}.tmp`;", + "const server = net.createServer((socket) => socket.end());", + 'appendEvent("started");', + "server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {", + " fs.writeFileSync(activePidTempPath, String(process.pid));", + " fs.renameSync(activePidTempPath, activePidPath);", + ' appendEvent("listening");', + "});", + "server.on('error', (error) => { console.error(error); process.exit(1); });", + "setInterval(() => {}, 1000).unref();", + "", + ].join("\n"), + "utf8", + ); + + let testFailed = false; + let testError: unknown; + let lifecyclePids: number[] = []; + let installedPrincipal: ScheduledTaskPrincipal | null = null; + const programArguments = [ + process.execPath, + probePath, + "gateway", + "--port", + String(gatewayPort), + eventsPath, + activePidPath, + ]; + try { + await withEnvAsync(env, async () => { + const service = resolveGatewayService(); + const readRuntime = () => service.readRuntime(env); + + expect((await execSchtasks(["/Query", "/TN", taskName])).code).not.toBe(0); + expect(path.relative(stateDir, scriptPath)).not.toMatch(/^\.\.(?:[\\/]|$)/u); + expect(await canBindLoopbackPort(gatewayPort)).toBe(true); + + await service.install({ + env, + stdout, + programArguments, + workingDirectory: rootDir, + environment: { OPENCLAW_GATEWAY_PORT: String(gatewayPort) }, + description: `OpenClaw CI Scheduled Task integration ${id}`, + }); + + expect((await execSchtasks(["/Query", "/TN", taskName])).code).toBe(0); + const taskXml = await readTaskXml(taskName); + if (!taskXml) { + throw new Error(`Could not export Scheduled Task XML for ${taskName}`); + } + expect(taskXml).toContain(""); + expect(taskXml.replaceAll("/", "\\").toLowerCase()).toContain( + launcherPath.replaceAll("/", "\\").toLowerCase(), + ); + installedPrincipal = await waitForSuccessfulScheduledTaskRun(taskName); + assertInteractiveLeastPrivilegeTask({ + taskXml, + principal: installedPrincipal, + }); + for (const startupEntryPath of resolveStartupEntryPaths(env)) { + await expect(fs.access(startupEntryPath)).rejects.toThrow(); + } + const command = await service.readCommand(env); + expect(command?.programArguments).toEqual(programArguments); + expect(command?.environment?.OPENCLAW_GATEWAY_PORT).toBe(String(gatewayPort)); + const installedRun = await waitForExactProbeRun(eventsPath, 1); + const installedPid = installedRun.pid; + expectProbeProcessAlive(installedPid); + expectScheduledTaskProbeOrigin({ + eventsPath, + probePath, + run: installedRun, + scriptPath, + }); + expect(await canBindLoopbackPort(gatewayPort)).toBe(false); + await waitForRuntimeStatus(readRuntime, "running", installedPid); + + const stopMutations: string[] = []; + await service.stop({ + env, + stdout, + onMutation: (mutation) => stopMutations.push(mutation.mode), + }); + expect(stopMutations).toEqual(["schtasks-stop"]); + await waitForProcessExit(installedPid); + await clearActivePid(activePidPath, installedPid); + await waitForLoopbackPortRelease(gatewayPort); + await waitForRuntimeStatus(readRuntime, "stopped"); + expect((await execSchtasks(["/Query", "/TN", taskName])).code).toBe(0); + + const startMutations: string[] = []; + await service.start({ + env, + stdout, + onMutation: (mutation) => startMutations.push(mutation.mode), + }); + expect(startMutations).toEqual(["schtasks-start"]); + const startedRun = await waitForExactProbeRun(eventsPath, 2); + const startedPid = startedRun.pid; + expect(startedPid).not.toBe(installedPid); + expectProbeProcessAlive(startedPid); + expectScheduledTaskProbeOrigin({ + eventsPath, + probePath, + run: startedRun, + scriptPath, + }); + expect(await canBindLoopbackPort(gatewayPort)).toBe(false); + await waitForRuntimeStatus(readRuntime, "running", startedPid); + + const restartMutations: string[] = []; + const restartResult = await service.restart({ + env, + stdout, + onMutation: (mutation) => restartMutations.push(mutation.mode), + }); + expect(restartResult).toEqual({ outcome: "completed" }); + expect(restartMutations).toEqual(["schtasks-end", "schtasks-restart"]); + const restartedRun = await waitForExactProbeRun(eventsPath, 3); + const restartedPid = restartedRun.pid; + lifecyclePids = [installedPid, startedPid, restartedPid]; + expect(restartedPid).not.toBe(startedPid); + expectProbeProcessAlive(restartedPid); + expectScheduledTaskProbeOrigin({ + eventsPath, + probePath, + run: restartedRun, + scriptPath, + }); + await waitForProcessExit(startedPid); + await clearActivePid(activePidPath, startedPid); + expect(await canBindLoopbackPort(gatewayPort)).toBe(false); + await waitForRuntimeStatus(readRuntime, "running", restartedPid); + + await service.stop({ env, stdout }); + await waitForProcessExit(restartedPid); + await clearActivePid(activePidPath, restartedPid); + await waitForLoopbackPortRelease(gatewayPort); + await waitForRuntimeStatus(readRuntime, "stopped"); + + await service.uninstall({ env, stdout }); + expect((await execSchtasks(["/Query", "/TN", taskName])).code).not.toBe(0); + await expect(fs.access(scriptPath)).rejects.toThrow(); + await expect(fs.access(launcherPath)).rejects.toThrow(); + expect(await canBindLoopbackPort(gatewayPort)).toBe(true); + expect(await readTaskDefinitionSnapshot("OpenClaw Gateway")).toEqual(defaultTaskBefore); + + const proofPath = process.env.CI_WINDOWS_SCHTASKS_PROOF_PATH?.trim(); + if (proofPath) { + const proofHead = process.env.CI_WINDOWS_SCHTASKS_HEAD?.trim(); + if (!proofHead || !/^[0-9a-f]{40}$/u.test(proofHead)) { + throw new Error( + "CI_WINDOWS_SCHTASKS_HEAD must identify the exact 40-character checkout SHA", + ); + } + await fs.mkdir(path.dirname(proofPath), { recursive: true }); + await fs.writeFile( + proofPath, + `${JSON.stringify( + { + result: "pass", + head: proofHead, + profile, + taskName, + lifecycle: ["install", "stop", "start", "restart", "stop", "uninstall"], + pids: lifecyclePids, + gatewayPort, + portReleaseRebind: true, + startupFallback: false, + defaultTaskUnchanged: true, + taskXml: { + interactiveToken: true, + leastPrivilege: true, + logonType: installedPrincipal?.logonType, + runLevel: installedPrincipal?.runLevel, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + }); + } catch (error) { + testFailed = true; + testError = error; + } + + let cleanupFailed = false; + let cleanupError: unknown; + try { + await cleanupNativeTask({ + activePidPath, + eventsPath, + preserveEvidence: testFailed, + probePath, + rootDir, + scriptPath, + serviceOutput, + stateDir, + taskName, + }); + } catch (error) { + cleanupFailed = true; + cleanupError = error; + } + if (cleanupFailed) { + throw new AggregateError( + testFailed ? [testError, cleanupError] : [cleanupError], + "Native Scheduled Task cleanup failed", + ); + } + if (testFailed) { + throw testError; + } + }, 180_000); +}); diff --git a/src/daemon/schtasks.integration.test-helpers.ts b/src/daemon/schtasks.integration.test-helpers.ts new file mode 100644 index 000000000000..dd0b2d8bc06b --- /dev/null +++ b/src/daemon/schtasks.integration.test-helpers.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises"; + +const POLL_INTERVAL_MS = 200; +const RUN_EVENT_SETTLE_MS = 2_000; +const WAIT_TIMEOUT_MS = 30_000; + +export type ProbeRunEvent = { + phase: "listening" | "started"; + pid: number; + ppid: number; +}; + +async function readRunEvents(eventsPath: string): Promise { + const content = await fs.readFile(eventsPath, "utf8").catch(() => ""); + return content + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const parsed = JSON.parse(line) as Partial; + if ( + (parsed.phase !== "started" && parsed.phase !== "listening") || + !Number.isSafeInteger(parsed.pid) || + (parsed.pid ?? 0) <= 1 || + !Number.isSafeInteger(parsed.ppid) || + (parsed.ppid ?? 0) <= 1 + ) { + throw new Error("Scheduled Task probe recorded an invalid run event"); + } + return parsed as ProbeRunEvent; + }); +} + +export async function waitForExactProbeRun( + eventsPath: string, + expectedCount: number, +): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let events: ProbeRunEvent[] = []; + while (Date.now() < deadline) { + events = await readRunEvents(eventsPath); + if (events.filter((event) => event.phase === "listening").length >= expectedCount) { + await new Promise((resolve) => { + setTimeout(resolve, RUN_EVENT_SETTLE_MS); + }); + events = await readRunEvents(eventsPath); + break; + } + await new Promise((resolve) => { + setTimeout(resolve, POLL_INTERVAL_MS); + }); + } + + const started = events.filter((event) => event.phase === "started"); + const listening = events.filter((event) => event.phase === "listening"); + if (started.length !== expectedCount || listening.length !== expectedCount) { + throw new Error( + `Expected exactly ${expectedCount} Scheduled Task probe runs; observed ${started.length} starts and ${listening.length} listeners`, + ); + } + const startedEvent = started[expectedCount - 1]; + const listeningEvent = listening[expectedCount - 1]; + if (!startedEvent || !listeningEvent) { + throw new Error(`Scheduled Task run ${expectedCount} did not record complete process events`); + } + if (startedEvent.pid !== listeningEvent.pid || startedEvent.ppid !== listeningEvent.ppid) { + throw new Error( + `Scheduled Task run ${expectedCount} changed process identity before listening`, + ); + } + return startedEvent; +} diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts index 80ca1db84bcd..1ce4bb2be1a9 100644 --- a/src/daemon/schtasks.stop.test.ts +++ b/src/daemon/schtasks.stop.test.ts @@ -23,8 +23,16 @@ const sleepMock = vi.hoisted(() => timeState.now += ms; }), ); +type SpawnSyncResult = { + pid: number; + output: (string | null)[]; + stdout: string; + stderr: string; + status: number; + signal: null; +}; const spawnSync = vi.hoisted(() => - vi.fn(() => ({ + vi.fn<(command: string, args?: readonly string[]) => SpawnSyncResult>(() => ({ pid: 0, output: [null, "-2147024891", ""], stdout: "-2147024891", @@ -52,12 +60,14 @@ vi.mock("../utils.js", async () => { }); const { + resolveTaskScriptPath, restartScheduledTask, resumeScheduledTaskAutoStartAfterUpdate, startScheduledTask, stopScheduledTask, suspendScheduledTaskAutoStartForUpdate, } = await import("./schtasks.js"); +const { resolveScheduledTaskOwnedGatewayPids } = await import("./schtasks-process.js"); const GATEWAY_PORT = 18789; const SUCCESS_RESPONSE = { code: 0, stdout: "", stderr: "" } as const; const INSTALLED_GATEWAY_COMMAND_LINE = @@ -465,6 +475,93 @@ describe("Scheduled Task stop/restart cleanup", () => { }); }); + it("does not adopt a portless arbitrary task action", async () => { + await withPreparedGatewayTask(async ({ env }) => { + delete env.OPENCLAW_GATEWAY_PORT; + const scriptPath = resolveTaskScriptPath(env); + await fs.writeFile( + scriptPath, + '@echo off\r\n"C:\\Program Files\\nodejs\\node.exe" "C:\\probe.cjs"\r\n', + "utf8", + ); + + await expect(resolveScheduledTaskOwnedGatewayPids(env)).resolves.toEqual([]); + + expect(inspectPortUsage).not.toHaveBeenCalled(); + }); + }); + + it("adopts exact persisted Windows argv and escalates through taskkill tree cleanup", async () => { + await withPreparedGatewayTask(async ({ env, stdout }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + pushSuccessfulSchtasksResponses(3); + inspectPortUsage.mockResolvedValue(freePortUsage()); + let forced = false; + spawnSync.mockImplementation((command, args) => { + const executable = command.toLowerCase(); + if (executable.endsWith("taskkill.exe")) { + const argv = Array.isArray(args) ? args.map(String) : []; + if (argv.includes("/F")) { + forced = true; + return { + pid: 0, + output: [null, "", ""], + stdout: "", + stderr: "", + status: 0, + signal: null, + }; + } + return { + pid: 0, + output: [null, "", ""], + stdout: "", + stderr: "", + status: 1, + signal: null, + }; + } + const processes = [ + { + ProcessId: 3131, + CommandLine: + '"C:\\Program Files\\nodejs\\node.exe" "C:\\other-openclaw.cjs" gateway --port 18789', + }, + ...(!forced + ? [ + { + ProcessId: 4242, + CommandLine: INSTALLED_GATEWAY_COMMAND_LINE, + }, + ] + : []), + { ProcessId: 9999, CommandLine: "powershell.exe" }, + ]; + const output = JSON.stringify(processes); + return { + pid: 0, + output: [null, output, ""], + stdout: output, + stderr: "", + status: 0, + signal: null, + }; + }); + + await stopScheduledTask({ env, stdout }); + + const taskkillCalls = spawnSync.mock.calls + .filter(([command]) => command.toLowerCase().endsWith("taskkill.exe")) + .map(([, args]) => args); + expect(taskkillCalls).toEqual([ + ["/T", "/PID", "4242"], + ["/F", "/T", "/PID", "4242"], + ]); + expect(taskkillCalls.flat()).not.toContain("3131"); + expect(killProcessTree).not.toHaveBeenCalled(); + }); + }); + it("starts a registered task and ignores audit observer failures", async () => { await withPreparedGatewayTask(async ({ env }) => { schtasksResponses.push( diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index 151eafb2f08d..ae9c36bdbb56 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -185,6 +185,31 @@ describe("readGatewayServiceState", () => { { timeoutMs: undefined }, ); }); + + it("validates merged service env before native status probes", async () => { + const isLoaded = vi.fn(async () => true); + const readRuntime = vi.fn(async () => ({ status: "running" as const })); + const service = createService({ + isLoaded, + readCommand: vi.fn(async () => ({ + programArguments: ["openclaw", "gateway", "run"], + environment: { OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service" }, + })), + readRuntime, + }); + + await expect( + readGatewayServiceState(service, { + env: {}, + validateEnvBeforeStatusRead: (env) => { + throw new Error(`refused ${env.OPENCLAW_SYSTEMD_UNIT}`); + }, + }), + ).rejects.toThrow("refused openclaw-gateway.service"); + + expect(isLoaded).not.toHaveBeenCalled(); + expect(readRuntime).not.toHaveBeenCalled(); + }); }); describe("startGatewayService", () => { diff --git a/src/daemon/service.ts b/src/daemon/service.ts index ddc32500068c..4ea791652ee2 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -90,6 +90,10 @@ export type GatewayService = { ) => Promise; }; +type ReadGatewayServiceStateArgs = GatewayServiceEnvArgs & { + validateEnvBeforeStatusRead?: (env: GatewayServiceEnv) => void; +}; + const TEMP_PROGRAM_ROOTS = [os.tmpdir(), "/tmp", "/private/tmp", "/var/tmp"].map((entry) => path.resolve(entry), ); @@ -179,11 +183,14 @@ export function formatGatewayServiceStartRepairIssues( export async function readGatewayServiceState( service: GatewayService, - args: GatewayServiceEnvArgs = {}, + args: ReadGatewayServiceStateArgs = {}, ): Promise { const baseEnv = args.env ?? (process.env as GatewayServiceEnv); const command = await service.readCommand(baseEnv).catch(() => null); const env = mergeGatewayServiceEnv(baseEnv, command); + // Callers that may mutate the selected service can reject persisted selector + // drift before isLoaded/readRuntime invoke the native service manager. + args.validateEnvBeforeStatusRead?.(env); // Propagate the status read deadline so a wedged service manager fails soft // instead of hanging both probes. readCommand parses local files and needs no // bound; isLoaded/readRuntime can spawn service-manager subprocesses. diff --git a/src/infra/gateway-supervision.test.ts b/src/infra/gateway-supervision.test.ts index ac7311e3f403..6442dda66ba4 100644 --- a/src/infra/gateway-supervision.test.ts +++ b/src/infra/gateway-supervision.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; import { assertGatewayServiceMutationAllowed, formatExternalSupervisorUpdateRequired, @@ -45,10 +47,99 @@ describe("gateway supervision", () => { ...override, }), ).toThrow( - `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home and without OPENCLAW_HOME, OPENCLAW_STATE_DIR, or OPENCLAW_CONFIG_PATH overrides to restart the gateway.`, + `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home, without OPENCLAW_HOME, and with OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH either unset or pointing at the canonical paths for that account home and profile to restart the gateway.`, ); }); + it("allows native service mutation for a named profile's canonical state dir", () => { + const accountHome = os.userInfo().homedir; + + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + HOME: accountHome, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: path.join(accountHome, ".openclaw-work"), + OPENCLAW_CONFIG_PATH: path.join(accountHome, ".openclaw-work", "openclaw.json"), + }), + ).not.toThrow(); + }); + + it.each([ + { + platform: "darwin" as const, + platformName: "macOS", + envKey: "OPENCLAW_LAUNCHD_LABEL", + value: "ai.openclaw.gateway", + }, + { + platform: "linux" as const, + platformName: "Linux", + envKey: "OPENCLAW_SYSTEMD_UNIT", + value: "openclaw-gateway.service", + }, + { + platform: "win32" as const, + platformName: "Windows", + envKey: "OPENCLAW_WINDOWS_TASK_NAME", + value: "OpenClaw Gateway", + }, + ])( + "rejects named-profile $envKey overrides on $platformName", + ({ platform, platformName, envKey, value }) => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); + const accountHome = os.userInfo().homedir; + try { + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + HOME: accountHome, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: path.join(accountHome, ".openclaw-work"), + OPENCLAW_CONFIG_PATH: path.join(accountHome, ".openclaw-work", "openclaw.json"), + [envKey]: value, + }), + ).toThrow( + `named profiles cannot override ${envKey} for ${platformName} service management`, + ); + } finally { + platformSpy.mockRestore(); + } + }, + ); + + it("rejects macOS profile names that collide with reserved LaunchAgent identities", () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + try { + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + OPENCLAW_PROFILE: "gateway", + }), + ).toThrow('macOS profile "gateway" conflicts with a reserved LaunchAgent identity'); + } finally { + platformSpy.mockRestore(); + } + }); + + it.each([ + { platform: "darwin" as const, platformName: "macOS" }, + { platform: "win32" as const, platformName: "Windows" }, + ])( + "rejects case-distinct native service identities on $platformName", + ({ platform, platformName }) => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); + try { + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + OPENCLAW_PROFILE: "Main", + }), + ).toThrow( + `${platformName} profile "Main" is not lowercase-safe for case-insensitive state and native-service paths`, + ); + } finally { + platformSpy.mockRestore(); + } + }, + ); + it("explains why self-update must be delegated", () => { expect(formatExternalSupervisorUpdateRequired()).toContain( "stop the gateway, update and finalize the runtime, then restart it safely", diff --git a/src/infra/gateway-supervision.ts b/src/infra/gateway-supervision.ts index 455146e0d172..8f2e864e3cfc 100644 --- a/src/infra/gateway-supervision.ts +++ b/src/infra/gateway-supervision.ts @@ -1,5 +1,6 @@ // Defines gateway lifecycle ownership shared by service, restart, and update paths. -import { isDefaultInstallIdentity } from "../config/paths.js"; +import { isDefaultInstallIdentity, resolveNativeServiceProfileConflict } from "../config/paths.js"; +import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js"; const GATEWAY_SUPERVISOR_MODE_ENV = "OPENCLAW_SUPERVISOR_MODE"; export const EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON = "external-supervisor-update-required"; @@ -39,9 +40,41 @@ export function assertGatewayServiceMutationAllowed( if (isGatewayExternallySupervised(env)) { throw new Error(formatExternalSupervisorActionRequired(action)); } + const conflictingProfile = resolveNativeServiceProfileConflict(env); + if (conflictingProfile) { + if (conflictingProfile !== conflictingProfile.toLowerCase()) { + const platformName = process.platform === "win32" ? "Windows" : "macOS"; + throw new Error( + `service management skipped: ${platformName} profile "${conflictingProfile}" is not lowercase-safe for case-insensitive state and native-service paths. Use a lowercase profile name to ${action}, or keep this profile runtime-only without a native service.`, + ); + } + throw new Error( + `service management skipped: macOS profile "${conflictingProfile}" conflicts with a reserved LaunchAgent identity. Choose a different profile name to ${action}.`, + ); + } + const serviceIdentityConflict = resolveGatewayNativeServiceIdentityConflict(env); + if (serviceIdentityConflict) { + const platformName = + process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"; + throw new Error( + `service management skipped: named profiles cannot override ${serviceIdentityConflict.envKey} for ${platformName} service management. Unset ${serviceIdentityConflict.envKey} so OpenClaw derives the native service identity from OPENCLAW_PROFILE to ${action}, or keep this profile runtime-only without a native service.`, + ); + } if (!isDefaultInstallIdentity(env)) { throw new Error( - `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home and without OPENCLAW_HOME, OPENCLAW_STATE_DIR, or OPENCLAW_CONFIG_PATH overrides to ${action}.`, + `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home, without OPENCLAW_HOME, and with OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH either unset or pointing at the canonical paths for that account home and profile to ${action}.`, ); } } + +export function resolveGatewayServiceMutationError( + action: string, + env: NodeJS.ProcessEnv = process.env, +): Error | null { + try { + assertGatewayServiceMutationAllowed(action, env); + return null; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 6bad21627a3c..bacf8607f177 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -209,6 +209,18 @@ describe("package scripts", () => { ); }); + it("keeps the native Scheduled Task lifecycle proof opt-in", () => { + const scripts = readPackageJson().scripts; + + expect(scripts["test:windows:ci"]).not.toContain("schtasks.integration.e2e.test.ts"); + expect(scripts["test:windows:schtasks:integration"]).toContain( + "CI_WINDOWS_SCHTASKS_INTEGRATION=1", + ); + expect(scripts["test:windows:schtasks:integration"]).toContain( + "src/daemon/schtasks.integration.e2e.test.ts", + ); + }); + it("runs shared test-state cleanup coverage in Windows CI", () => { expect(readPackageJson().scripts["test:windows:ci"]).toContain( "src/test-utils/openclaw-test-state.test.ts", diff --git a/test/scripts/check-workflows.test.ts b/test/scripts/check-workflows.test.ts index 1251acde81cf..c5e137a7275a 100644 --- a/test/scripts/check-workflows.test.ts +++ b/test/scripts/check-workflows.test.ts @@ -243,7 +243,7 @@ describe("check-workflows", () => { expect(workflow).toContain("run_windows_ci:"); expect(workflow).toContain( - 'description: "Run the focused Windows-native CI test shard after probing"', + 'description: "Run the focused Windows CI shard and native Scheduled Task proof"', ); expect(workflow).toContain("default: false"); expect(workflow).toContain("if: ${{ inputs.run_windows_ci }}"); @@ -251,6 +251,20 @@ describe("check-workflows", () => { expect(workflow).toContain("uses: ./.github/actions/setup-pnpm-store-cache"); expect(workflow).toContain("pnpm install --frozen-lockfile --prefer-offline"); expect(workflow).toContain("pnpm test:windows:ci"); + expect(workflow).toContain("pnpm test:windows:schtasks:integration"); + expect(workflow).toContain('CI_WINDOWS_SCHTASKS_HEAD="$(git rev-parse HEAD)"'); + expect(workflow).toContain('if [[ "$CI_WINDOWS_SCHTASKS_HEAD" != "$EXPECTED_HEAD" ]]; then'); + expect(workflow).toContain('$activePidPath = Join-Path $env:TEST_ROOT "active-pid.txt"'); + expect(workflow).toContain('$process.CommandLine -like "*$probePath*"'); + expect(workflow).toContain('$process.CommandLine -like "*$eventsPath*"'); + expect(workflow).toContain("schtasks.exe /Delete /F /TN $taskName"); + expect(workflow).toContain('$service = New-Object -ComObject "Schedule.Service"'); + expect(workflow).toContain("failure-diagnostics.json"); + expect(workflow).toContain("cleanup-summary.txt"); + expect(workflow).not.toContain("task-before-cleanup.xml"); + expect(workflow).not.toContain("Copy-Item -LiteralPath $stateDir"); + expect(workflow).toContain(" exit 0"); + expect(workflow).toContain(".artifacts/windows-schtasks/"); expect(workflow).toContain("if: ${{ always() && !cancelled() }}"); expect(workflow).toContain("if: ${{ always() && !cancelled() && inputs.require_wsl2 }}"); }); From 654744da1738dd1ad2d2476459de4266440012ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 20:33:20 -0700 Subject: [PATCH 10/15] refactor(channels): unify setup ownership across bundled channels (#117188) --- extensions/matrix/src/channel.setup.ts | 9 +- extensions/matrix/src/channel.ts | 60 +---- extensions/nostr/src/channel.setup.ts | 91 ++----- extensions/nostr/src/setup-adapter.ts | 61 +++-- extensions/nostr/src/setup-surface.ts | 18 +- extensions/slack/src/shared.ts | 30 +-- src/channels/plugins/setup-helpers.ts | 261 ++++++--------------- src/channels/plugins/setup-wizard-proxy.ts | 44 ++-- 8 files changed, 173 insertions(+), 401 deletions(-) diff --git a/extensions/matrix/src/channel.setup.ts b/extensions/matrix/src/channel.setup.ts index 3e9aedf6193c..16ddc36ab7e0 100644 --- a/extensions/matrix/src/channel.setup.ts +++ b/extensions/matrix/src/channel.setup.ts @@ -10,7 +10,7 @@ const matrixSetupWizard = createMatrixSetupWizardProxy(async () => ({ matrixSetupWizard: (await import("./setup-surface.js")).matrixSetupWizard, })); -export const matrixSetupPlugin: ChannelPlugin = { +export const matrixPluginBase = { id: "matrix", meta: { id: "matrix", @@ -44,6 +44,13 @@ export const matrixSetupPlugin: ChannelPlugin = { baseUrl: account.homeserver, }, }), + }, +} satisfies ChannelPlugin; + +export const matrixSetupPlugin: ChannelPlugin = { + ...matrixPluginBase, + config: { + ...matrixPluginBase.config, hasConfiguredState: ({ cfg }) => resolveMatrixAccount({ cfg }).configured, }, }; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index c797c07e9512..fdb5e587ddf3 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -1,5 +1,4 @@ // Matrix plugin module implements channel behavior. -import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; import { adaptScopedAccountAccessor, createScopedDmSecurityResolver, @@ -45,8 +44,8 @@ import { import { matrixMessageActions } from "./actions.js"; import { matrixApprovalCapability } from "./approval-native.js"; import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js"; -import { DEFAULT_ACCOUNT_ID, matrixConfigAdapter } from "./config-adapter.js"; -import { MatrixChannelConfigSchema } from "./config-schema.js"; +import { matrixPluginBase } from "./channel.setup.js"; +import { DEFAULT_ACCOUNT_ID } from "./config-adapter.js"; import { legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES, normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig, @@ -75,12 +74,6 @@ import { import { matrixResolverAdapter } from "./resolver.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { resolveMatrixOutboundSessionRoute } from "./session-route.js"; -import { - namedAccountPromotionKeys, - resolveSingleAccountPromotionTarget, - singleAccountKeysToMove, -} from "./setup-contract.js"; -import { createMatrixSetupWizardProxy, matrixSetupContract } from "./setup-core.js"; import { defaultTopLevelPlacement, resolveMatrixInboundConversation, @@ -89,10 +82,6 @@ import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); -const loadMatrixSetupWizard = createLazyRuntimeNamedExport( - () => import("./setup-surface.js"), - "matrixSetupWizard", -); const loadMatrixChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), "matrixChannelRuntime", @@ -100,18 +89,6 @@ const loadMatrixChannelRuntime = createLazyRuntimeNamedExport( const loadMatrixDoctorModule = createLazyRuntimeModule(() => import("./doctor.js")); -const meta = { - id: "matrix", - label: "Matrix", - selectionLabel: "Matrix (plugin)", - docsPath: "/channels/matrix", - docsLabel: "matrix", - blurb: "open protocol; configure a homeserver + access token.", - order: 70, - markdownCapable: true, - quickstartAllowFrom: true, -}; - function buildMatrixTrafficStatusSummary( snapshot?: { lastInboundAt?: number | null; @@ -439,37 +416,16 @@ const matrixMessageAdapter = createChannelMessageAdapterFromOutbound({ export const matrixPlugin: ChannelPlugin = createChatChannelPlugin({ base: { - id: "matrix", - meta, - setupWizard: createMatrixSetupWizardProxy(async () => ({ - matrixSetupWizard: await loadMatrixSetupWizard(), - })), + ...matrixPluginBase, + meta: { ...matrixPluginBase.meta, markdownCapable: true }, capabilities: { - chatTypes: ["direct", "group", "thread"], - polls: true, - reactions: true, - threads: true, - media: true, + ...matrixPluginBase.capabilities, tts: { voice: { synthesisTarget: "voice-note", }, }, }, - reload: { configPrefixes: ["channels.matrix"] }, - configSchema: MatrixChannelConfigSchema, - config: { - ...matrixConfigAdapter, - isConfigured: (account) => account.configured, - describeAccount: (account) => - describeAccountSnapshot({ - account, - configured: account.configured, - extra: { - baseUrl: account.homeserver, - }, - }), - }, approvalCapability: matrixApprovalCapability, groups: { resolveRequireMention: resolveMatrixGroupRequireMention, @@ -540,12 +496,6 @@ export const matrixPlugin: ChannelPlugin = secretTargetRegistryEntries, collectRuntimeConfigAssignments, }, - setupContract: { - ...matrixSetupContract, - singleAccountKeysToMove, - namedAccountPromotionKeys, - resolveSingleAccountPromotionTarget, - }, bindings: { compileConfiguredBinding: ({ conversationId }) => normalizeMatrixAcpConversationId(conversationId), diff --git a/extensions/nostr/src/channel.setup.ts b/extensions/nostr/src/channel.setup.ts index 7cf289ff6557..16c9c8ec0eec 100644 --- a/extensions/nostr/src/channel.setup.ts +++ b/extensions/nostr/src/channel.setup.ts @@ -3,41 +3,21 @@ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createDelegatedSetupWizardProxy, - createStandardChannelSetupStatus, DEFAULT_ACCOUNT_ID, - createSetupTranslator, } from "openclaw/plugin-sdk/setup-runtime"; import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js"; import { NostrConfigSchema } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; -import { createNostrSetupAdapter, createNostrSetupContract } from "./setup-adapter.js"; - -const t = createSetupTranslator(); +import { + createNostrSetupAdapter, + createNostrSetupContract, + createNostrSetupStatus, +} from "./setup-adapter.js"; +import type { ResolvedNostrAccount } from "./types.js"; const channel = "nostr" as const; -type NostrAccountConfig = { - enabled?: boolean; - name?: string; - defaultAccount?: string; - privateKey?: unknown; - relays?: string[]; - dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; - allowFrom?: Array; - profile?: unknown; -}; - -type ResolvedNostrSetupAccount = { - accountId: string; - name?: string; - enabled: boolean; - configured: boolean; - privateKey: string; - publicKey: string; - relays: string[]; - profile?: unknown; - config: NostrAccountConfig; -}; +type NostrAccountConfig = ResolvedNostrAccount["config"]; function getNostrConfig(cfg: OpenClawConfig): NostrAccountConfig | undefined { return (cfg.channels as Record | undefined)?.nostr as @@ -45,15 +25,6 @@ function getNostrConfig(cfg: OpenClawConfig): NostrAccountConfig | undefined { | undefined; } -function listSetupNostrAccountIds(cfg: OpenClawConfig): string[] { - const nostrCfg = getNostrConfig(cfg); - const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : ""; - if (!privateKey) { - return []; - } - return [resolveDefaultSetupNostrAccountId(cfg)]; -} - function resolveDefaultSetupNostrAccountId(cfg: OpenClawConfig): string { const configured = getNostrConfig(cfg)?.defaultAccount; return typeof configured === "string" && configured.trim() @@ -64,7 +35,7 @@ function resolveDefaultSetupNostrAccountId(cfg: OpenClawConfig): string { function resolveSetupNostrAccount(params: { cfg: OpenClawConfig; accountId?: string | null; -}): ResolvedNostrSetupAccount { +}): ResolvedNostrAccount { const nostrCfg = getNostrConfig(params.cfg); const accountId = params.accountId?.trim() || resolveDefaultSetupNostrAccountId(params.cfg); const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : ""; @@ -90,47 +61,16 @@ function resolveSetupNostrAccount(params: { }; } -function looksLikeNostrPrivateKey(privateKey: string): boolean { - return ( - privateKey.startsWith("nsec1") || - privateKey.startsWith("NSEC1") || - /^[0-9a-fA-F]{64}$/.test(privateKey) - ); -} - -const nostrSetupAdapter = createNostrSetupAdapter({ - resolveAccountId: (cfg, accountId) => accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg), - validatePrivateKey: looksLikeNostrPrivateKey, -}); -const nostrSetupContract = createNostrSetupContract(nostrSetupAdapter); - const nostrSetupWizard = createDelegatedSetupWizardProxy({ channel, loadWizard: async () => (await import("./setup-surface.js")).nostrSetupWizard, - status: { - ...createStandardChannelSetupStatus({ - channelLabel: "Nostr", - configuredLabel: t("wizard.channels.statusConfigured"), - unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"), - configuredHint: t("wizard.channels.statusConfigured"), - unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"), - configuredScore: 1, - unconfiguredScore: 0, - includeStatusLine: true, - resolveConfigured: ({ cfg, accountId }) => - resolveSetupNostrAccount({ cfg, accountId }).configured, - resolveExtraStatusLines: ({ cfg }) => { - const account = resolveSetupNostrAccount({ cfg }); - return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`]; - }, - }), - }, + status: createNostrSetupStatus(resolveSetupNostrAccount), resolveShouldPromptAccountIds: () => false, delegatePrepare: true, delegateFinalize: true, }); -export const nostrSetupPlugin: ChannelPlugin = { +export const nostrSetupPlugin: ChannelPlugin = { id: channel, meta: { id: channel, @@ -147,10 +87,17 @@ export const nostrSetupPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), - setupContract: nostrSetupContract, + setupContract: createNostrSetupContract( + createNostrSetupAdapter({ + resolveAccountId: (cfg, accountId) => + accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg), + validatePrivateKey: (privateKey) => /^(?:nsec1|NSEC1)|^[0-9a-fA-F]{64}$/u.test(privateKey), + }), + ), setupWizard: nostrSetupWizard, config: { - listAccountIds: listSetupNostrAccountIds, + listAccountIds: (cfg) => + resolveSetupNostrAccount({ cfg }).configured ? [resolveDefaultSetupNostrAccountId(cfg)] : [], resolveAccount: (cfg, accountId) => resolveSetupNostrAccount({ cfg, accountId }), defaultAccountId: resolveDefaultSetupNostrAccountId, isConfigured: (account) => account.configured, diff --git a/extensions/nostr/src/setup-adapter.ts b/extensions/nostr/src/setup-adapter.ts index 879c3a9a00c5..177e7c227504 100644 --- a/extensions/nostr/src/setup-adapter.ts +++ b/extensions/nostr/src/setup-adapter.ts @@ -2,18 +2,25 @@ import { defineChannelSetupContract, type ChannelSetupAdapter, - type ChannelSetupInput, } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { patchTopLevelChannelConfigSection, splitSetupEntries } from "openclaw/plugin-sdk/setup"; +import { + createSetupTranslator, + createStandardChannelSetupStatus, + patchTopLevelChannelConfigSection, + splitSetupEntries, +} from "openclaw/plugin-sdk/setup"; import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { DEFAULT_RELAYS } from "./default-relays.js"; const channel = "nostr" as const; -type NostrSetupInput = ChannelSetupInput & { +type NostrSetupInput = { + name?: string; privateKey?: string; relayUrls?: string; + useEnv?: boolean; }; export function buildNostrSetupPatch(accountId: string, patch: Record) { @@ -42,7 +49,7 @@ export function parseRelayUrls(raw: string): { relays: string[]; error?: string export function createNostrSetupAdapter(params: { resolveAccountId: (cfg: OpenClawConfig, accountId?: string | null) => string; validatePrivateKey: (privateKey: string) => boolean; -}): ChannelSetupAdapter { +}): ChannelSetupAdapter { return { resolveAccountId: ({ cfg, accountId }) => params.resolveAccountId(cfg, accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -52,9 +59,8 @@ export function createNostrSetupAdapter(params: { patch: buildNostrSetupPatch(accountId, name?.trim() ? { name: name.trim() } : {}), }), validateInput: ({ input }) => { - const typedInput = input as NostrSetupInput; - if (!typedInput.useEnv) { - const privateKey = typedInput.privateKey?.trim(); + if (!input.useEnv) { + const privateKey = input.privateKey?.trim(); if (!privateKey) { return "Nostr requires --private-key or --use-env."; } @@ -62,23 +68,22 @@ export function createNostrSetupAdapter(params: { return "Nostr private key must be valid nsec or 64-character hex."; } } - if (typedInput.relayUrls?.trim()) { - return parseRelayUrls(typedInput.relayUrls).error ?? null; + if (input.relayUrls?.trim()) { + return parseRelayUrls(input.relayUrls).error ?? null; } return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { - const typedInput = input as NostrSetupInput; - const relayResult = typedInput.relayUrls?.trim() - ? parseRelayUrls(typedInput.relayUrls) + const relayResult = input.relayUrls?.trim() + ? parseRelayUrls(input.relayUrls) : { relays: [] }; return patchTopLevelChannelConfigSection({ cfg, channel, enabled: true, - clearFields: typedInput.useEnv ? ["privateKey"] : undefined, + clearFields: input.useEnv ? ["privateKey"] : undefined, patch: buildNostrSetupPatch(accountId, { - ...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }), + ...(input.useEnv ? {} : { privateKey: input.privateKey?.trim() }), ...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}), }), }); @@ -86,7 +91,7 @@ export function createNostrSetupAdapter(params: { }; } -export function createNostrSetupContract(adapter: ChannelSetupAdapter) { +export function createNostrSetupContract(adapter: ChannelSetupAdapter) { return defineChannelSetupContract({ fields: { privateKey: { @@ -103,6 +108,30 @@ export function createNostrSetupContract(adapter: ChannelSetupAdapter) { cli: { flags: "--use-env", description: "Use NOSTR_PRIVATE_KEY" }, }, }, - legacyAdapter: adapter, + adapter, + }); +} + +export function createNostrSetupStatus( + resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => { + configured: boolean; + relays: string[]; + }, +) { + const t = createSetupTranslator(); + return createStandardChannelSetupStatus({ + channelLabel: "Nostr", + configuredLabel: t("wizard.channels.statusConfigured"), + unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"), + configuredHint: t("wizard.channels.statusConfigured"), + unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"), + configuredScore: 1, + unconfiguredScore: 0, + includeStatusLine: true, + resolveConfigured: ({ cfg, accountId }) => resolveAccount({ cfg, accountId }).configured, + resolveExtraStatusLines: ({ cfg }) => { + const account = resolveAccount({ cfg }); + return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`]; + }, }); } diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index d50949eb3425..31c3512b591d 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -7,7 +7,6 @@ import { import type { ChannelSetupDmPolicy, ChannelSetupWizard, DmPolicy } from "openclaw/plugin-sdk/setup"; import { createSetupTranslator, - createStandardChannelSetupStatus, createTopLevelChannelDmPolicy, createTopLevelChannelParsedAllowFromPrompt, defineTokenCredential, @@ -23,6 +22,7 @@ import { buildNostrSetupPatch, createNostrSetupAdapter, createNostrSetupContract, + createNostrSetupStatus, parseRelayUrls, } from "./setup-adapter.js"; import { resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; @@ -96,21 +96,7 @@ export const nostrSetupWizard: ChannelSetupWizard = { resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId }) => accountOverride?.trim() || defaultAccountId, resolveShouldPromptAccountIds: () => false, - status: createStandardChannelSetupStatus({ - channelLabel: "Nostr", - configuredLabel: t("wizard.channels.statusConfigured"), - unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"), - configuredHint: t("wizard.channels.statusConfigured"), - unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"), - configuredScore: 1, - unconfiguredScore: 0, - includeStatusLine: true, - resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured, - resolveExtraStatusLines: ({ cfg }) => { - const account = resolveNostrAccount({ cfg }); - return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`]; - }, - }), + status: createNostrSetupStatus(resolveNostrAccount), introNote: { title: t("wizard.nostr.setupTitle"), lines: NOSTR_SETUP_HELP_LINES, diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 9f9bec7a88e2..3ebd5948e59a 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -5,12 +5,11 @@ import { isSlackPluginAccountConfigured } from "./account-configured.js"; import { inspectSlackAccount } from "./account-inspect.js"; import type { ResolvedSlackAccount } from "./accounts.js"; import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js"; +import { slackSetupPlugin } from "./channel.setup.js"; import { slackBaseConfigAdapter } from "./config-adapter.js"; -import { SlackChannelConfigSchema } from "./config-schema.js"; import { slackDoctor } from "./doctor.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { slackSecurityAdapter } from "./security.js"; -import { SLACK_CHANNEL } from "./setup-shared.js"; export { SLACK_CHANNEL } from "./setup-shared.js"; @@ -42,26 +41,13 @@ export function createSlackPluginBase(params: { | "secrets" > { return { - id: SLACK_CHANNEL, + ...slackSetupPlugin, meta: { - ...getChatChannelMeta(SLACK_CHANNEL), + ...getChatChannelMeta(slackSetupPlugin.id), preferSessionLookupForAnnounceTarget: true, }, setupWizard: params.setupWizard, setupContract: params.setupContract, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - commands: { - nativeCommandsAutoEnabled: false, - nativeSkillsAutoEnabled: false, - resolveNativeCommandName: ({ commandKey, defaultName }) => - commandKey === "status" ? "agentstatus" : defaultName, - }, doctor: slackDoctor, agentPrompt: { inboundFormattingHints: () => ({ @@ -83,18 +69,10 @@ export function createSlackPluginBase(params: { "- Slack Block Kit or presentation text fields are sent as Slack mrkdwn directly; use `*bold*`, `_italic_`, `~strike~`, `` links, and avoid Markdown headings or pipe tables there.", ], }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, security: slackSecurityAdapter, - configSchema: SlackChannelConfigSchema, config: { + ...slackSetupPlugin.config, ...slackConfigAdapter, - hasConfiguredState: ({ env }) => - ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some( - (key) => typeof env?.[key] === "string" && env[key]?.trim().length > 0, - ), isConfigured: (account) => isSlackPluginAccountConfigured(account), describeAccount: (account) => describeAccountSnapshot({ diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 2b3f01a5ece6..efbc78c041ff 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -1,4 +1,3 @@ -import { expectDefined } from "@openclaw/normalization-core"; /** * Channel setup config mutation helpers. * @@ -10,31 +9,26 @@ import { resolveSingleAccountKeysToMove } from "./setup-promotion-helpers.js"; import type { ChannelSetupAdapter } from "./types.adapters.js"; import type { ChannelSetupInput } from "./types.core.js"; -type ChannelSectionBase = { +type ChannelSectionBase = Record & { name?: string; defaultAccount?: string; accounts?: Record>; }; -function channelHasAccounts(cfg: OpenClawConfig, channelKey: string): boolean { - const channels = cfg.channels as Record | undefined; - const base = channels?.[channelKey] as ChannelSectionBase | undefined; - return Boolean(base?.accounts && Object.keys(base.accounts).length > 0); +function getChannelSection( + cfg: OpenClawConfig, + channelKey: string, +): ChannelSectionBase | undefined { + const section = (cfg.channels as Record | undefined)?.[channelKey]; + return section && typeof section === "object" ? (section as ChannelSectionBase) : undefined; } -function shouldStoreNameInAccounts(params: { - cfg: OpenClawConfig; - channelKey: string; - accountId: string; - alwaysUseAccounts?: boolean; -}): boolean { - if (params.alwaysUseAccounts) { - return true; - } - if (params.accountId !== DEFAULT_ACCOUNT_ID) { - return true; - } - return channelHasAccounts(params.cfg, params.channelKey); +function writeChannelSection( + cfg: OpenClawConfig, + channelKey: string, + section: ChannelSectionBase, +): OpenClawConfig { + return { ...cfg, channels: { ...cfg.channels, [channelKey]: section } } as OpenClawConfig; } export function applyAccountNameToChannelSection(params: { @@ -49,51 +43,23 @@ export function applyAccountNameToChannelSection(params: { return params.cfg; } const accountId = normalizeAccountId(params.accountId); - const channels = params.cfg.channels as Record | undefined; - const baseConfig = channels?.[params.channelKey]; - const base = - typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionBase) : undefined; - const useAccounts = shouldStoreNameInAccounts({ - cfg: params.cfg, - channelKey: params.channelKey, - accountId, - alwaysUseAccounts: params.alwaysUseAccounts, - }); - if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) { - const safeBase = base ?? {}; - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - [params.channelKey]: { - ...safeBase, - name: trimmed, - }, - }, - } as OpenClawConfig; + const base = getChannelSection(params.cfg, params.channelKey); + const accounts = base?.accounts ?? {}; + const useAccounts = + params.alwaysUseAccounts || + accountId !== DEFAULT_ACCOUNT_ID || + Object.keys(accounts).length > 0; + if (!useAccounts) { + return writeChannelSection(params.cfg, params.channelKey, { ...base, name: trimmed }); } - const baseAccounts: Record> = base?.accounts ?? {}; - const existingAccount = baseAccounts[accountId] ?? {}; const baseWithoutName = accountId === DEFAULT_ACCOUNT_ID ? (({ name: _ignored, ...rest }) => rest)(base ?? {}) : (base ?? {}); - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - [params.channelKey]: { - ...baseWithoutName, - accounts: { - ...baseAccounts, - [accountId]: { - ...existingAccount, - name: trimmed, - }, - }, - }, - }, - } as OpenClawConfig; + return writeChannelSection(params.cfg, params.channelKey, { + ...baseWithoutName, + accounts: { ...accounts, [accountId]: { ...accounts[accountId], name: trimmed } }, + }); } /** Moves a root-level channel name into `accounts.default` before adding named accounts. */ @@ -105,8 +71,7 @@ export function migrateBaseNameToDefaultAccount(params: { if (params.alwaysUseAccounts) { return params.cfg; } - const channels = params.cfg.channels as Record | undefined; - const base = channels?.[params.channelKey] as ChannelSectionBase | undefined; + const base = getChannelSection(params.cfg, params.channelKey); const baseName = base?.name?.trim(); if (!baseName) { return params.cfg; @@ -119,16 +84,7 @@ export function migrateBaseNameToDefaultAccount(params: { accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName }; } const { name: _ignored, ...rest } = base ?? {}; - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - [params.channelKey]: { - ...rest, - accounts, - }, - }, - } as OpenClawConfig; + return writeChannelSection(params.cfg, params.channelKey, { ...rest, accounts }); } /** Applies setup-time account naming and optional root-name migration in one step. */ @@ -164,12 +120,7 @@ export function applySetupAccountConfigPatch(params: { accountId: string; patch: Record; }): OpenClawConfig { - return patchScopedAccountConfig({ - cfg: params.cfg, - channelKey: params.channelKey, - accountId: params.accountId, - patch: params.patch, - }); + return patchScopedAccountConfig(params); } /** Creates a setup adapter that turns validated setup input into an account config patch. */ @@ -301,14 +252,7 @@ export function patchScopedAccountConfig(params: { scopeDefaultToAccounts?: boolean; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); - const channels = params.cfg.channels as Record | undefined; - const channelConfig = channels?.[params.channelKey]; - const base = - typeof channelConfig === "object" && channelConfig - ? (channelConfig as Record & { - accounts?: Record>; - }) - : undefined; + const base = getChannelSection(params.cfg, params.channelKey); const ensureChannelEnabled = params.ensureChannelEnabled ?? true; const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled; const patch = params.patch; @@ -325,102 +269,67 @@ export function patchScopedAccountConfig(params: { }; if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) { // Default accounts historically live at channel root unless the channel opts into accounts.default. - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - [params.channelKey]: { - ...clearFields(base ?? {}), - ...(ensureChannelEnabled ? { enabled: true } : {}), - ...patch, - }, - }, - } as OpenClawConfig; + return writeChannelSection(params.cfg, params.channelKey, { + ...clearFields(base ?? {}), + ...(ensureChannelEnabled ? { enabled: true } : {}), + ...patch, + }); } const accounts = base?.accounts ?? {}; const existingAccount = clearFields(accounts[accountId] ?? {}); // Preserve an explicit disabled account while enabling newly created accounts by default. - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - [params.channelKey]: { - ...base, - ...(ensureChannelEnabled ? { enabled: true } : {}), - accounts: { - ...accounts, - [accountId]: { - ...existingAccount, - ...(ensureAccountEnabled - ? { - enabled: - typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true, - } - : {}), - ...accountPatch, - }, - }, + return writeChannelSection(params.cfg, params.channelKey, { + ...base, + ...(ensureChannelEnabled ? { enabled: true } : {}), + accounts: { + ...accounts, + [accountId]: { + ...existingAccount, + ...(ensureAccountEnabled + ? { + enabled: + typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true, + } + : {}), + ...accountPatch, }, }, - } as OpenClawConfig; -} - -type ChannelSectionRecord = Record & { - accounts?: Record>; -}; - -function cloneIfObject(value: T): T { - if (value && typeof value === "object") { - return structuredClone(value); - } - return value; + }); } function moveSingleAccountKeysIntoAccount(params: { cfg: OpenClawConfig; channelKey: string; - channel: ChannelSectionRecord; + channel: ChannelSectionBase; accounts: Record>; keysToMove: string[]; targetAccountId: string; baseAccount?: Record; }): OpenClawConfig { const nextAccount: Record = { ...params.baseAccount }; + const nextChannel: ChannelSectionBase = { ...params.channel }; for (const key of params.keysToMove) { if (!(key in nextAccount)) { - nextAccount[key] = cloneIfObject(params.channel[key]); + const value = params.channel[key]; + nextAccount[key] = value && typeof value === "object" ? structuredClone(value) : value; } - } - const nextChannel: ChannelSectionRecord = { ...params.channel }; - for (const key of params.keysToMove) { delete nextChannel[key]; } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - [params.channelKey]: { - ...nextChannel, - accounts: { - ...params.accounts, - [params.targetAccountId]: nextAccount, - }, - }, - }, - } as OpenClawConfig; + return writeChannelSection(params.cfg, params.channelKey, { + ...nextChannel, + accounts: { ...params.accounts, [params.targetAccountId]: nextAccount }, + }); } function resolveExistingAccountKey( accounts: Record>, targetAccountId: string, ): string { - for (const existingKey of Object.keys(accounts)) { - if (normalizeAccountId(existingKey) === targetAccountId) { - return existingKey; - } - } - return targetAccountId; + return ( + Object.keys(accounts).find((key) => normalizeAccountId(key) === targetAccountId) ?? + targetAccountId + ); } function resolveSingleAccountPromotionTarget(params: { @@ -446,9 +355,7 @@ function resolveSingleAccountPromotionTarget(params: { ); } const namedAccounts = Object.keys(accounts).filter(Boolean); - return namedAccounts.length === 1 - ? expectDefined(namedAccounts[0], "named accounts entry at 0") - : DEFAULT_ACCOUNT_ID; + return namedAccounts.length === 1 ? (namedAccounts[0] ?? DEFAULT_ACCOUNT_ID) : DEFAULT_ACCOUNT_ID; } /** @@ -459,54 +366,34 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { channelKey: string; setupSurface?: ChannelSetupAdapter; }): OpenClawConfig { - const channels = params.cfg.channels as Record | undefined; - const baseConfig = channels?.[params.channelKey]; - const base = - typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionRecord) : undefined; + const base = getChannelSection(params.cfg, params.channelKey); if (!base) { return params.cfg; } const accounts = base.accounts ?? {}; - if (Object.keys(accounts).length > 0) { - const keysToMove = resolveSingleAccountKeysToMove({ - channelKey: params.channelKey, - channel: base, - setupSurface: params.setupSurface, - includeSetupKeys: true, - }); - if (keysToMove.length === 0) { - return params.cfg; - } - - const targetAccountId = resolveSingleAccountPromotionTarget({ - channel: base, - setupSurface: params.setupSurface, - }); - // Reuse the existing account key spelling so configs like `accounts.Ops` keep their shape. - const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId); - return moveSingleAccountKeysIntoAccount({ - cfg: params.cfg, - channelKey: params.channelKey, - channel: base, - accounts, - keysToMove, - targetAccountId: resolvedTargetAccountKey, - baseAccount: accounts[resolvedTargetAccountKey], - }); - } + const hasAccounts = Object.keys(accounts).length > 0; const keysToMove = resolveSingleAccountKeysToMove({ channelKey: params.channelKey, channel: base, setupSurface: params.setupSurface, includeSetupKeys: true, }); + if (hasAccounts && keysToMove.length === 0) { + return params.cfg; + } + const targetAccountId = hasAccounts + ? resolveSingleAccountPromotionTarget({ channel: base, setupSurface: params.setupSurface }) + : DEFAULT_ACCOUNT_ID; + // Reuse the existing account key spelling so configs like `accounts.Ops` keep their shape. + const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId); return moveSingleAccountKeysIntoAccount({ cfg: params.cfg, channelKey: params.channelKey, channel: base, accounts, keysToMove, - targetAccountId: DEFAULT_ACCOUNT_ID, + targetAccountId: resolvedTargetAccountKey, + baseAccount: accounts[resolvedTargetAccountKey], }); } diff --git a/src/channels/plugins/setup-wizard-proxy.ts b/src/channels/plugins/setup-wizard-proxy.ts index cd181a9bcd20..e191d01d408c 100644 --- a/src/channels/plugins/setup-wizard-proxy.ts +++ b/src/channels/plugins/setup-wizard-proxy.ts @@ -9,7 +9,6 @@ import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js"; import type { ChannelSetupWizard } from "./setup-wizard.js"; type PromptAllowFromParams = Parameters>[0]; -type ResolveConfiguredParams = Parameters[0]; type ResolveAllowFromEntriesParams = Parameters< NonNullable["resolveEntries"] >[0]; @@ -20,30 +19,6 @@ type ResolveGroupAllowlistParams = Parameters< NonNullable["resolveAllowlist"]> >[0]; -/** - * Delegates setup configured-state checks to a lazily loaded wizard. - */ -function createDelegatedResolveConfigured(loadWizard: () => Promise) { - return async ({ cfg, accountId }: ResolveConfiguredParams) => - await (await loadWizard()).status.resolveConfigured({ cfg, accountId }); -} - -/** - * Delegates setup preparation to a lazily loaded wizard. - */ -function createDelegatedPrepare(loadWizard: () => Promise) { - return async (params: Parameters>[0]) => - await (await loadWizard()).prepare?.(params); -} - -/** - * Delegates setup finalization to a lazily loaded wizard. - */ -function createDelegatedFinalize(loadWizard: () => Promise) { - return async (params: Parameters>[0]) => - await (await loadWizard()).finalize?.(params); -} - type DelegatedStatusBase = Omit< ChannelSetupWizard["status"], "resolveConfigured" | "resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore" @@ -70,7 +45,8 @@ export function createDelegatedSetupWizardProxy(params: { channel: params.channel, status: { ...params.status, - resolveConfigured: createDelegatedResolveConfigured(params.loadWizard), + resolveConfigured: async (statusParams) => + await (await params.loadWizard()).status.resolveConfigured(statusParams), ...createDelegatedSetupWizardStatusResolvers(params.loadWizard), }, // Keep static setup metadata available immediately, while expensive @@ -78,10 +54,22 @@ export function createDelegatedSetupWizardProxy(params: { ...(params.resolveShouldPromptAccountIds ? { resolveShouldPromptAccountIds: params.resolveShouldPromptAccountIds } : {}), - ...(params.delegatePrepare ? { prepare: createDelegatedPrepare(params.loadWizard) } : {}), + ...(params.delegatePrepare + ? { + prepare: async ( + prepareParams: Parameters>[0], + ) => await (await params.loadWizard()).prepare?.(prepareParams), + } + : {}), credentials: params.credentials ?? [], ...(params.textInputs ? { textInputs: params.textInputs } : {}), - ...(params.delegateFinalize ? { finalize: createDelegatedFinalize(params.loadWizard) } : {}), + ...(params.delegateFinalize + ? { + finalize: async ( + finalizeParams: Parameters>[0], + ) => await (await params.loadWizard()).finalize?.(finalizeParams), + } + : {}), ...(params.completionNote ? { completionNote: params.completionNote } : {}), ...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}), ...(params.disable ? { disable: params.disable } : {}), From 982add961804097f5a6b0b94255c5c855e8ad863 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 20:34:34 -0700 Subject: [PATCH 11/15] fix(signal): restore provider-safe original attachment filenames (#115107) Co-authored-by: Peter Steinberger Co-authored-by: ZengWen-DT --- .../src/client-container.real-server.test.ts | 98 +++++++++++++++++++ .../signal/src/client-container.test.ts | 72 ++++++++++++++ extensions/signal/src/client-container.ts | 10 +- 3 files changed, 177 insertions(+), 3 deletions(-) diff --git a/extensions/signal/src/client-container.real-server.test.ts b/extensions/signal/src/client-container.real-server.test.ts index 4905b13eb6af..7f4bfbbd9a3c 100644 --- a/extensions/signal/src/client-container.real-server.test.ts +++ b/extensions/signal/src/client-container.real-server.test.ts @@ -3,8 +3,11 @@ // by the request deadline, not only by the per-chunk idle guard. This exercises the // production containerRpcRequest -> containerRestRequest -> readSignalRestText path // without mocking fetch, unlike the fake-timer unit tests. +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import http from "node:http"; import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { containerRpcRequest } from "./client-container.js"; @@ -95,4 +98,99 @@ describe("signal REST real-server deadline", () => { ); expect(result).toEqual({ versions: ["v1"], build: 2 }); }); + + it.each([ + { + stagedFilename: "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "report.jpg", + }, + { + stagedFilename: "quarter;final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "quarter_final.jpg", + }, + { + stagedFilename: "first;middle;last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "first_middle_last.jpg", + }, + { + stagedFilename: "quarter,final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "quarter_final.jpg", + }, + { + stagedFilename: "first,middle,last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "first_middle_last.jpg", + }, + { + stagedFilename: "hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "hash_name.jpg", + }, + { + stagedFilename: "mixed;comma,hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "mixed_comma_hash_name.jpg", + }, + { stagedFilename: "quarter;final.jpg", expectedFilename: "quarter_final.jpg" }, + { stagedFilename: "quarter,final.jpg", expectedFilename: "quarter_final.jpg" }, + { stagedFilename: "hash#name.jpg", expectedFilename: "hash_name.jpg" }, + { + stagedFilename: "quarter final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "quarter final.jpg", + }, + ])( + "posts the provider-safe original filename $expectedFilename", + async ({ stagedFilename, expectedFilename }) => { + let receivedPayload: unknown; + const server = await startServer((req, res) => { + if (req.method !== "POST" || req.url !== "/v2/send") { + res.writeHead(404); + res.end(); + return; + } + + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer | string) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + req.on("end", () => { + receivedPayload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ timestamp: "1735689600000" })); + }); + }); + + const mediaDir = await mkdtemp(join(tmpdir(), "signal-real-filename-")); + const content = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + const stagedFile = join(mediaDir, stagedFilename); + + try { + await writeFile(stagedFile, content); + await expect( + containerRpcRequest( + "send", + { + account: "+14259798283", + recipient: ["+15550001111"], + message: "Photo", + attachments: [stagedFile], + }, + { baseUrl: server.baseUrl, timeoutMs: 1_000 }, + ), + ).resolves.toEqual({ timestamp: 1735689600000 }); + + expect(receivedPayload).toEqual({ + message: "Photo", + number: "+14259798283", + recipients: ["+15550001111"], + base64_attachments: [ + `data:image/jpeg;filename=${expectedFilename};base64,${content.toString("base64")}`, + ], + }); + const attachment = (receivedPayload as { base64_attachments: [string] }) + .base64_attachments[0]; + const decoded = await (await fetch(attachment)).arrayBuffer(); + expect(Buffer.from(decoded)).toEqual(content); + } finally { + await rm(mediaDir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/extensions/signal/src/client-container.test.ts b/extensions/signal/src/client-container.test.ts index 79928f541247..3f674bd6b092 100644 --- a/extensions/signal/src/client-container.test.ts +++ b/extensions/signal/src/client-container.test.ts @@ -912,6 +912,78 @@ describe("containerSendMessage", () => { await fs.rm(tmpDir, { recursive: true }); }); + it.each([ + { + stagedFilename: "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "report.jpg", + }, + { + stagedFilename: "quarter;final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "quarter_final.jpg", + }, + { + stagedFilename: "first;middle;last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "first_middle_last.jpg", + }, + { + stagedFilename: "quarter,final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "quarter_final.jpg", + }, + { + stagedFilename: "first,middle,last---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "first_middle_last.jpg", + }, + { + stagedFilename: "hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "hash_name.jpg", + }, + { + stagedFilename: "mixed;comma,hash#name---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "mixed_comma_hash_name.jpg", + }, + { stagedFilename: "quarter;final.jpg", expectedFilename: "quarter_final.jpg" }, + { stagedFilename: "quarter,final.jpg", expectedFilename: "quarter_final.jpg" }, + { stagedFilename: "hash#name.jpg", expectedFilename: "hash_name.jpg" }, + { + stagedFilename: "quarter final---a1b2c3d4-5678-90ab-cdef-1234567890ab.jpg", + expectedFilename: "quarter final.jpg", + }, + ])( + "restores the provider-safe original attachment filename $expectedFilename", + async ({ stagedFilename, expectedFilename }) => { + const fs = await import("node:fs/promises"); + const os = await import("node:os"); + const path = await import("node:path"); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "signal-test-")); + try { + const stagedFile = path.join(tmpDir, stagedFilename); + const content = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + await fs.writeFile(stagedFile, content); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + ...bodyStream(JSON.stringify({})), + }); + + await containerSendMessage({ + baseUrl: "http://localhost:8080", + account: "+14259798283", + recipients: ["+15550001111"], + message: "Photo", + attachments: [stagedFile], + }); + + expect(parseFetchBody().base64_attachments).toEqual([ + `data:image/jpeg;filename=${expectedFilename};base64,${content.toString("base64")}`, + ]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }, + ); + it("rejects outbound attachments that exceed the size cap", async () => { const fs = await import("node:fs/promises"); const os = await import("node:os"); diff --git a/extensions/signal/src/client-container.ts b/extensions/signal/src/client-container.ts index db0157b4e640..6d1bfc33a0eb 100644 --- a/extensions/signal/src/client-container.ts +++ b/extensions/signal/src/client-container.ts @@ -6,10 +6,13 @@ * to keep the two modes cleanly isolated. */ -import nodePath from "node:path"; import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime"; -import { detectMime, parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime"; +import { + detectMime, + extractOriginalFilename, + parseMediaContentLength, +} from "openclaw/plugin-sdk/media-runtime"; import { parseStrictNonNegativeInteger, resolveTimerTimeoutMs, @@ -554,7 +557,8 @@ async function filesToBase64DataUris( }); remainingBytes -= buffer.byteLength; const mime = (await detectMime({ buffer, filePath })) ?? "application/octet-stream"; - const filename = nodePath.basename(filePath); + // Signal splits on semicolons; commas and fragments break RFC 2397 attachment data. + const filename = extractOriginalFilename(filePath).replace(/[,;#]/g, "_"); const b64 = buffer.toString("base64"); results.push(`data:${mime};filename=${filename};base64,${b64}`); } From c50237e37dff697dfad850ef18f3b8bd233de7d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 20:36:00 -0700 Subject: [PATCH 12/15] perf: count large histories before Gateway prewarm (#117118) * perf(gateway): count before sidebar prewarm * fix(sessions): narrow count row before normalization * fix(gateway): align sidebar prewarm admission targets * test(gateway): prove large prewarm stays optional * fix(gateway): budget repeated shared-store prewarm --- src/config/sessions/combined-store-gateway.ts | 182 ++++++++++++------ src/config/sessions/session-accessor.entry.ts | 2 + .../sessions/session-accessor.sqlite-entry.ts | 16 ++ .../sessions/session-accessor.sqlite.ts | 1 + src/config/sessions/session-accessor.test.ts | 20 ++ src/config/sessions/session-accessor.ts | 1 + .../server-startup-handler-prewarm.test.ts | 44 +++-- src/gateway/server-startup-handler-prewarm.ts | 54 ++++-- ...essions.list-store-materialization.test.ts | 56 ++++++ src/gateway/session-utils.subagent.test.ts | 28 +++ 10 files changed, 309 insertions(+), 95 deletions(-) diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts index 01bec1ec03b1..1e1827442ccb 100644 --- a/src/config/sessions/combined-store-gateway.ts +++ b/src/config/sessions/combined-store-gateway.ts @@ -16,7 +16,11 @@ import { import { listOpenIncognitoAgentDatabases } from "../../state/openclaw-agent-db.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveStorePath } from "./paths.js"; -import { listSessionEntries, listSessionEntriesReadOnly } from "./session-accessor.js"; +import { + countSessionEntryRowsReadOnly, + listSessionEntries, + listSessionEntriesReadOnly, +} from "./session-accessor.js"; import type { SessionEntryListScope } from "./session-accessor.types.js"; import { canonicalSessionKeyMigrationRequiredError } from "./session-canonical-key.js"; import { resolveDeliveryProvenCanonicalSessionKey } from "./store-entry.js"; @@ -32,6 +36,23 @@ import type { SessionEntry } from "./types.js"; type GatewaySessionEntryProjection = NonNullable; +type GatewaySessionStoreOptions = { + agentId?: string; + configuredAgentsOnly?: boolean; + includeIncognito?: boolean; + projection?: SessionEntryListScope["projection"]; +}; + +type ResolvedGatewaySessionStoreTargets = { + configuredAgentIds?: ReadonlySet; + defaultAgentId: string; + diagnostics: string[]; + durableTargets: Array<{ agentId: string; storePath: string }>; + incognitoTargets: Array<{ agentId: string; storePath: string }>; + requestedAgentId?: string; + storeConfig?: string; +}; + // Template-backed stores need per-agent scans before they can be merged for Gateway views. function isStorePathTemplate(store?: string): boolean { return typeof store === "string" && store.includes("{agentId}"); @@ -97,20 +118,13 @@ function mergeSessionEntryIntoCombined(params: { } function mergeOpenIncognitoStores(params: { - allowedAgentIds?: ReadonlySet; cfg: OpenClawConfig; combined: Record; - agentId?: string; projection: GatewaySessionEntryProjection; + targets: Array<{ agentId: string; storePath: string }>; }): string[] { const storePaths: string[] = []; - for (const target of listOpenIncognitoAgentDatabases()) { - if (params.allowedAgentIds && !params.allowedAgentIds.has(target.agentId)) { - continue; - } - if (params.agentId && target.agentId !== params.agentId) { - continue; - } + for (const target of params.targets) { const store = loadGatewayStoreEntries({ agentId: target.agentId, includeOpenDatabases: true, @@ -138,27 +152,12 @@ function mergeOpenIncognitoStores(params: { return storePaths; } -/** Loads and canonicalizes session entries for gateway views across one or more agent stores. */ -export function loadCombinedSessionStoreForGateway( +function resolveGatewaySessionStoreTargets( cfg: OpenClawConfig, - opts: { - agentId?: string; - configuredAgentsOnly?: boolean; - includeIncognito?: boolean; - projection?: SessionEntryListScope["projection"]; - } = {}, -): { - diagnostics?: string[]; - durableStorePath?: string; - storePath: string; - store: Record; -} { + opts: GatewaySessionStoreOptions, +): ResolvedGatewaySessionStoreTargets { const storeConfig = cfg.session?.store; - const projection = opts.projection ?? "full"; const diagnostics: string[] = []; - // Exclusion happens before path aggregation; filtering rows afterward would - // still leak a live incognito handle by changing the projected store path. - const includeIncognito = opts.includeIncognito !== false; const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); const requestedAgentId = typeof opts.agentId === "string" && opts.agentId.trim() @@ -171,6 +170,13 @@ export function loadCombinedSessionStoreForGateway( const allowedIncognitoAgentIds = requestedAgentId ? new Set([requestedAgentId]) : configuredAgentIds; + const incognitoTargets = + opts.includeIncognito === false + ? [] + : listOpenIncognitoAgentDatabases().filter( + (target) => !allowedIncognitoAgentIds || allowedIncognitoAgentIds.has(target.agentId), + ); + if (storeConfig && !isStorePathTemplate(storeConfig)) { const ownerIds = [ ...new Set([ @@ -181,10 +187,7 @@ export function loadCombinedSessionStoreForGateway( ...(requestedAgentId ? [requestedAgentId] : []), ]), ]; - const combined: Record = {}; - // Runtime session access is SQLite-only: a fixed literal is a naming seed whose - // resolved database is partitioned per owner. Legacy flat JSON is migration-only. - const ownerTargets = dedupeSessionStoreTargetsBySqliteTarget( + const durableTargets = dedupeSessionStoreTargetsBySqliteTarget( ownerIds.map((agentId) => ({ agentId, storePath: resolveStorePath(storeConfig, { agentId }), @@ -194,7 +197,81 @@ export function loadCombinedSessionStoreForGateway( onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.message), }, ); - for (const { agentId, storePath } of ownerTargets) { + return { + configuredAgentIds, + defaultAgentId, + diagnostics, + durableTargets, + incognitoTargets, + requestedAgentId, + storeConfig, + }; + } + + const durableTargets = requestedAgentId + ? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId) + : opts.configuredAgentsOnly === true + ? resolveSessionStoreTargets(cfg, { allAgents: true }) + : resolveAllAgentSessionStoreTargetsSync(cfg); + return { + configuredAgentIds, + defaultAgentId, + diagnostics, + durableTargets, + incognitoTargets, + requestedAgentId, + storeConfig, + }; +} + +/** Checks whether Gateway prewarm can project the selected stores within a bounded row budget. */ +export function canPrewarmCombinedSessionStoresForGateway( + cfg: OpenClawConfig, + params: { agentIds: readonly string[]; maxRows: number }, +): boolean { + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); + let totalRows = 0; + for (const agentId of params.agentIds) { + const resolved = resolveGatewaySessionStoreTargets(cfg, { agentId }); + const projectionTargets = dedupeSessionStoreTargetsBySqliteTarget( + [...resolved.durableTargets, ...resolved.incognitoTargets], + { defaultAgentId }, + ); + for (const target of projectionTargets) { + totalRows += countSessionEntryRowsReadOnly(target); + if (totalRows > params.maxRows) { + return false; + } + } + } + return true; +} + +/** Loads and canonicalizes session entries for gateway views across one or more agent stores. */ +export function loadCombinedSessionStoreForGateway( + cfg: OpenClawConfig, + opts: GatewaySessionStoreOptions = {}, +): { + diagnostics?: string[]; + durableStorePath?: string; + storePath: string; + store: Record; +} { + const projection = opts.projection ?? "full"; + // Count admission and projection share this exact target set. Otherwise an optional + // prewarm can approve one database and synchronously materialize another. + const { + configuredAgentIds, + defaultAgentId, + diagnostics, + durableTargets, + incognitoTargets, + requestedAgentId, + storeConfig, + } = resolveGatewaySessionStoreTargets(cfg, opts); + if (storeConfig && !isStorePathTemplate(storeConfig)) { + const combined: Record = {}; + for (const { agentId, storePath } of durableTargets) { const store = loadGatewayStoreEntries({ agentId, projection, storePath }); for (const { sessionKey: key, entry } of store) { const canonicalKey = resolveStoredSessionKeyForAgentStore({ @@ -226,15 +303,12 @@ export function loadCombinedSessionStoreForGateway( } } const durableStorePath = resolveStorePath(storeConfig, { agentId: defaultAgentId }); - const incognitoStorePaths = includeIncognito - ? mergeOpenIncognitoStores({ - ...(allowedIncognitoAgentIds ? { allowedAgentIds: allowedIncognitoAgentIds } : {}), - cfg, - combined, - ...(requestedAgentId ? { agentId: requestedAgentId } : {}), - projection, - }) - : []; + const incognitoStorePaths = mergeOpenIncognitoStores({ + cfg, + combined, + projection, + targets: incognitoTargets, + }); return { diagnostics, durableStorePath, @@ -242,13 +316,8 @@ export function loadCombinedSessionStoreForGateway( store: combined, }; } - const targets = requestedAgentId - ? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId) - : opts.configuredAgentsOnly === true - ? resolveSessionStoreTargets(cfg, { allAgents: true }) - : resolveAllAgentSessionStoreTargetsSync(cfg); const combined: Record = {}; - for (const target of targets) { + for (const target of durableTargets) { const agentId = target.agentId; const storePath = target.storePath; const store = loadGatewayStoreEntries({ agentId, projection, storePath }); @@ -282,17 +351,14 @@ export function loadCombinedSessionStoreForGateway( } } - const incognitoStorePaths = includeIncognito - ? mergeOpenIncognitoStores({ - ...(allowedIncognitoAgentIds ? { allowedAgentIds: allowedIncognitoAgentIds } : {}), - cfg, - combined, - ...(requestedAgentId ? { agentId: requestedAgentId } : {}), - projection, - }) - : []; + const incognitoStorePaths = mergeOpenIncognitoStores({ + cfg, + combined, + projection, + targets: incognitoTargets, + }); - const durableStorePaths = targets.map((target) => target.storePath); + const durableStorePaths = durableTargets.map((target) => target.storePath); const durableStorePath = resolveCombinedStorePath(durableStorePaths, storeConfig); const storePath = resolveCombinedStorePath( [...durableStorePaths, ...incognitoStorePaths], diff --git a/src/config/sessions/session-accessor.entry.ts b/src/config/sessions/session-accessor.entry.ts index 0eb2d32dd95f..fd8e4b063445 100644 --- a/src/config/sessions/session-accessor.entry.ts +++ b/src/config/sessions/session-accessor.entry.ts @@ -10,6 +10,7 @@ import { resolveAgentMainSessionKey } from "./main-session.js"; import { resolveStorePath } from "./paths.js"; import { clearPluginOwnedSessionState } from "./plugin-host-cleanup.js"; import { + countSqliteSessionEntryRowsReadOnly as countSessionEntryRowsReadOnly, copySqliteSessionOwnedStateForCanonicalRepair as copySessionOwnedStateForCanonicalRepair, listSqliteSessionGenerationIdsForCanonicalRepair as listSessionGenerationIdsForCanonicalRepair, listSqliteSessionChildEntriesReadOnly as listSessionChildEntriesReadOnly, @@ -58,6 +59,7 @@ export { clearPluginOwnedSessionState }; // SQLite is the only runtime session store. Re-export its canonical entry // operations directly instead of maintaining a second pass-through layer. export { + countSessionEntryRowsReadOnly, copySessionOwnedStateForCanonicalRepair, listSessionGenerationIdsForCanonicalRepair, listSessionChildEntriesReadOnly, diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index beabd365aedc..d12f1f94b158 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -281,6 +281,22 @@ export function listSqliteSessionEntriesReadOnly( return result.found ? result.value : []; } +/** Counts durable session rows without materializing entry JSON or warming the entry cache. */ +export function countSqliteSessionEntryRowsReadOnly(scope: SessionEntryListScope = {}): number { + const resolved = resolveSqliteScope({ ...scope, sessionKey: "" }); + const result = withOpenClawAgentDatabaseReadOnly((database) => { + const db = getSessionKysely(database.db); + const row = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("session_nodes") + .select((expression) => expression.fn.countAll().as("count")), + ); + return row ? normalizeSqliteNumber(row.count) : 0; + }, toDatabaseOptions(resolved)); + return result.found ? result.value : 0; +} + function listSqliteSessionEntriesFromDatabase( database: Pick, resolved: ResolvedSqliteScope, diff --git a/src/config/sessions/session-accessor.sqlite.ts b/src/config/sessions/session-accessor.sqlite.ts index 6a1b80373e36..324ae6189c97 100644 --- a/src/config/sessions/session-accessor.sqlite.ts +++ b/src/config/sessions/session-accessor.sqlite.ts @@ -1,5 +1,6 @@ // Stable SQLite accessor surface. Domain owners live in the focused modules below. export { + countSqliteSessionEntryRowsReadOnly, listSqliteSessionEntries, listSqliteSessionChildEntriesReadOnly, listSqliteSessionEntriesReadOnly, diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index ba9bd9f84b88..7b64da17cc4c 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTestTimeout } from "../../../test/helpers/promise.js"; @@ -26,6 +27,7 @@ import { appendTranscriptMessage, applySessionEntryLifecycleMutation, commitReplySessionInitialization, + countSessionEntryRowsReadOnly, createSessionEntryWithTranscript, deleteSessionEntryLifecycle, findTranscriptEvent, @@ -225,6 +227,24 @@ describe("session accessor seam", () => { expect(readSqliteSessionEntryCount(database)).toBe(1); expect(readSqliteSessionEntryKeys(database)).toEqual(["agent:main:logical-entry"]); + expect(countSessionEntryRowsReadOnly({ agentId: "main", storePath })).toBe(2); + }); + + it("counts rows on a cold handle without parsing invalid entry JSON", async () => { + await replaceSessionEntry( + { sessionKey: "agent:main:cold-count", storePath }, + { sessionId: "cold-count-session", updatedAt: 10 }, + ); + const databasePath = expectDefined( + resolveSqliteTargetFromSessionStorePath(storePath, { agentId: "main" }).path, + "cold count database path", + ); + closeOpenClawAgentDatabasesForTest(); + const database = new DatabaseSync(databasePath); + database.prepare("UPDATE session_nodes SET entry_valid = 0").run(); + database.close(); + + expect(countSessionEntryRowsReadOnly({ agentId: "main", storePath })).toBe(1); }); it("retains legacy createdBy actor projections across rewrites", async () => { diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index dcfe5cdeef3b..2ae27420ac88 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -119,6 +119,7 @@ export type { UpdateSessionLastRouteParams, } from "./session-accessor.entry-mutation.js"; export { + countSessionEntryRowsReadOnly, copySessionOwnedStateForCanonicalRepair, listSessionGenerationIdsForCanonicalRepair, clearPluginOwnedSessionState, diff --git a/src/gateway/server-startup-handler-prewarm.test.ts b/src/gateway/server-startup-handler-prewarm.test.ts index b955dce9f62f..2a4d170285b0 100644 --- a/src/gateway/server-startup-handler-prewarm.test.ts +++ b/src/gateway/server-startup-handler-prewarm.test.ts @@ -3,19 +3,16 @@ import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js" const mocks = vi.hoisted(() => ({ events: [] as string[], - sessionEntryCounts: new Map(), + canPrewarmCombinedSessionStoresForGateway: vi.fn(() => { + mocks.events.push("sessions.count"); + return true; + }), loadCombinedSessionStoreForGateway: vi.fn((_cfg: unknown, options: { agentId: string }) => { mocks.events.push(`sessions.load.${options.agentId}`); - const entryCount = mocks.sessionEntryCounts.get(options.agentId) ?? 0; return { durableStorePath: `/state/${options.agentId}.sqlite`, storePath: `/state/${options.agentId}.sqlite`, - store: Object.fromEntries( - Array.from({ length: entryCount }, (_, index) => [ - `agent:${options.agentId}:fixture-${index}`, - { sessionId: `session-${index}`, updatedAt: index }, - ]), - ), + store: {}, }; }), listSessionsFromStoreAsync: vi.fn(async (params: { opts: { agentId: string } }) => { @@ -32,6 +29,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../config/sessions/combined-store-gateway.js", () => ({ + canPrewarmCombinedSessionStoresForGateway: mocks.canPrewarmCombinedSessionStoresForGateway, loadCombinedSessionStoreForGateway: mocks.loadCombinedSessionStoreForGateway, })); @@ -51,7 +49,11 @@ const { scheduleGatewayHandlerPrewarm } = await import("./server-startup-handler beforeEach(() => { mocks.events.length = 0; - mocks.sessionEntryCounts.clear(); + mocks.canPrewarmCombinedSessionStoresForGateway.mockClear(); + mocks.canPrewarmCombinedSessionStoresForGateway.mockImplementation(() => { + mocks.events.push("sessions.count"); + return true; + }); mocks.loadCombinedSessionStoreForGateway.mockClear(); mocks.listSessionsFromStoreAsync.mockClear(); mocks.listManagedPlugins.mockClear(); @@ -79,6 +81,7 @@ describe("scheduleGatewayHandlerPrewarm", () => { await vi.runAllTimersAsync(); expect(mocks.events).toEqual([ + "sessions.count", "sessions.load.main", "sessions.rows.main", "sessions.load.research", @@ -120,6 +123,10 @@ describe("scheduleGatewayHandlerPrewarm", () => { agentId: "research", limitPerHost: 40, }); + expect(mocks.canPrewarmCombinedSessionStoresForGateway).toHaveBeenCalledWith(cfg, { + agentIds: ["main", "research"], + maxRows: 2_000, + }); sidecar.stop(); }); @@ -203,25 +210,26 @@ describe("scheduleGatewayHandlerPrewarm", () => { it("skips optional catalog prewarm when the combined session stores are large", async () => { vi.useFakeTimers(); - mocks.sessionEntryCounts.set("main", 2_001); + const info = vi.fn(); + mocks.canPrewarmCombinedSessionStoresForGateway.mockImplementation(() => { + mocks.events.push("sessions.count"); + return false; + }); const cfg = { agents: { list: [{ id: "main", default: true }, { id: "research" }] }, } as never; scheduleGatewayHandlerPrewarm({ cfgAtStart: cfg, - log: { warn: vi.fn() }, + log: { info, warn: vi.fn() }, }); await vi.runAllTimersAsync(); - expect(mocks.events).toEqual([ - "sessions.load.main", - "sessions.rows.main", - "sessions.load.research", - "sessions.rows.research", - "plugins", - ]); + expect(mocks.events).toEqual(["sessions.count", "plugins"]); expect(mocks.prewarmSessionCatalogList).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith( + "skipping optional dashboard session prewarm: combined stores exceed 2000 rows", + ); }); it("stops before scheduling another event-loop turn", async () => { diff --git a/src/gateway/server-startup-handler-prewarm.ts b/src/gateway/server-startup-handler-prewarm.ts index 3092ed95aefc..ccd6ac534bd3 100644 --- a/src/gateway/server-startup-handler-prewarm.ts +++ b/src/gateway/server-startup-handler-prewarm.ts @@ -4,7 +4,7 @@ import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-w const SIDEBAR_SESSION_LIST_LIMIT = 60; const SIDEBAR_CATALOG_LIMIT_PER_HOST = 40; -const SIDEBAR_CATALOG_PREWARM_MAX_SESSION_ENTRIES = 2_000; +const SIDEBAR_PREWARM_MAX_SESSION_ENTRIES = 2_000; type StartupTrace = { measure: (name: string, run: () => T | Promise) => Promise; @@ -19,10 +19,7 @@ type GatewayHandlerPrewarmHandle = { stop: () => void; }; -async function prewarmGatewaySessionListData( - cfg: OpenClawConfig, - agentId: string, -): Promise { +async function prewarmGatewaySessionListData(cfg: OpenClawConfig, agentId: string): Promise { const [{ loadCombinedSessionStoreForGateway }, { listSessionsFromStoreAsync }] = await Promise.all([ import("../config/sessions/combined-store-gateway.js"), @@ -46,19 +43,43 @@ async function prewarmGatewaySessionListData( limit: SIDEBAR_SESSION_LIST_LIMIT, }, }); - return Object.keys(store).length; } -function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmItem[] { +function dashboardDataPrewarmItems( + cfg: OpenClawConfig, + log: { info?: (msg: string) => void }, +): GatewayHandlerPrewarmItem[] { const agentIds = listAgentIds(cfg); - let loadedSessionStores = 0; - let totalSessionEntries = 0; + let sessionDataPrewarmChecked = false; + let sessionDataPrewarmAllowed = false; + const shouldPrewarmSessionData = async () => { + if (sessionDataPrewarmChecked) { + return sessionDataPrewarmAllowed; + } + sessionDataPrewarmChecked = true; + const { canPrewarmCombinedSessionStoresForGateway } = + await import("../config/sessions/combined-store-gateway.js"); + sessionDataPrewarmAllowed = canPrewarmCombinedSessionStoresForGateway(cfg, { + agentIds, + maxRows: SIDEBAR_PREWARM_MAX_SESSION_ENTRIES, + }); + if (!sessionDataPrewarmAllowed) { + log.info?.( + `skipping optional dashboard session prewarm: combined stores exceed ${SIDEBAR_PREWARM_MAX_SESSION_ENTRIES} rows`, + ); + } + return sessionDataPrewarmAllowed; + }; return [ ...agentIds.map((agentId) => ({ name: `sessions.${agentId}`, load: async () => { - totalSessionEntries += await prewarmGatewaySessionListData(cfg, agentId); - loadedSessionStores += 1; + // A count-only query keeps unusually large stores off the synchronous JSON projection + // path. Request-time session and catalog handlers remain authoritative when skipped. + if (!(await shouldPrewarmSessionData())) { + return; + } + await prewarmGatewaySessionListData(cfg, agentId); }, })), { @@ -71,12 +92,7 @@ function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmIt ...agentIds.map((agentId) => ({ name: `session-catalog.${agentId}`, load: async () => { - // Catalog providers may project every OpenClaw session before returning their bounded - // page. Keep that optional cold-cache work off the event loop for unusually large stores. - if ( - loadedSessionStores !== agentIds.length || - totalSessionEntries > SIDEBAR_CATALOG_PREWARM_MAX_SESSION_ENTRIES - ) { + if (!(await shouldPrewarmSessionData())) { return; } const { prewarmSessionCatalogList } = await import("./server-methods/session-catalog.js"); @@ -93,13 +109,13 @@ function dashboardDataPrewarmItems(cfg: OpenClawConfig): GatewayHandlerPrewarmIt export function scheduleGatewayHandlerPrewarm(params: { cfgAtStart: OpenClawConfig; startupTrace?: StartupTrace; - log: { warn: (msg: string) => void }; + log: { info?: (msg: string) => void; warn: (msg: string) => void }; items?: readonly GatewayHandlerPrewarmItem[]; waitForPostReadyWork?: () => Promise; }): GatewayHandlerPrewarmHandle { // Frequent updater restarts make cold dashboard data the remaining slow tier. // Keep cheap session reads first, process-stable plugin data second, and provider catalogs last. - const items = params.items ?? dashboardDataPrewarmItems(params.cfgAtStart); + const items = params.items ?? dashboardDataPrewarmItems(params.cfgAtStart, params.log); let stopped = false; let nextIndex = 0; let currentItemName = "unknown"; diff --git a/src/gateway/server.sessions.list-store-materialization.test.ts b/src/gateway/server.sessions.list-store-materialization.test.ts index a488f7ab69b3..b358bf964bf2 100644 --- a/src/gateway/server.sessions.list-store-materialization.test.ts +++ b/src/gateway/server.sessions.list-store-materialization.test.ts @@ -175,6 +175,62 @@ test("startup prewarm fills session snapshot and title caches before the first l } }); +test("startup skips a large session prewarm while request-time listing remains available", async () => { + const { storePath } = await createSessionStoreDir(); + await writeSessionStore({ + entries: Object.fromEntries( + Array.from({ length: 2_001 }, (_, index) => [ + `agent:main:large-${index}`, + sessionStoreEntry(`large-${index}`, { updatedAt: 1_781_000_000_000 - index }), + ]), + ), + }); + const info = vi.fn(); + const listSpy = vi.spyOn(sessionAccessor, "listSessionEntriesReadOnly"); + let sidecar: ReturnType | undefined; + vi.useFakeTimers(); + try { + let resolveSessionPrewarm!: () => void; + const sessionPrewarm = new Promise((resolve) => { + resolveSessionPrewarm = resolve; + }); + sidecar = scheduleGatewayHandlerPrewarm({ + cfgAtStart: { + agents: { list: [{ id: "main", default: true }] }, + session: { store: storePath }, + } as never, + log: { info, warn: vi.fn() }, + startupTrace: { + measure: async (name, run) => { + try { + return await run(); + } finally { + if (name === "post-ready.gateway-data.sessions.main") { + resolveSessionPrewarm(); + } + } + }, + }, + }); + + await vi.advanceTimersToNextTimerAsync(); + await sessionPrewarm; + sidecar.stop(); + expect(info).toHaveBeenCalledWith( + "skipping optional dashboard session prewarm: combined stores exceed 2000 rows", + ); + expect(listSpy).not.toHaveBeenCalled(); + + vi.useRealTimers(); + const result = await directSessionReq("sessions.list", LIST_PARAMS); + expect(result.ok).toBe(true); + } finally { + sidecar?.stop(); + vi.useRealTimers(); + listSpy.mockRestore(); + } +}); + test("sessions.list projects out prompt snapshots without changing full entry reads", async () => { await createSessionStoreDir(); await writeSessionStore({ diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index 2902117becc4..8c889a772ee5 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -15,6 +15,7 @@ import { import type { SubagentRunRecord } from "../agents/subagent-registry.types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; +import { canPrewarmCombinedSessionStoresForGateway } from "../config/sessions/combined-store-gateway.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; import { registerAgentRunContext, resetAgentEventsForTest } from "../infra/agent-events.js"; import { @@ -1407,6 +1408,13 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" "main", ); + expect( + canPrewarmCombinedSessionStoresForGateway(cfg, { + agentIds: ["main", "ops"], + maxRows: 1, + }), + ).toBe(false); + const { diagnostics, store } = loadCombinedSessionStoreForGateway(cfg); expect(store["agent:main:main"]?.sessionId).toBe("s-main-unscoped"); expect(store["agent:ops:main"]).toBeUndefined(); @@ -1493,6 +1501,19 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" { incognito: true, sessionId: "s-incognito-dynamic", updatedAt: 500 }, "dynamic", ); + await seedSessionEntry( + resolveIncognitoOpenClawAgentSqlitePath({ agentId: "ops" }), + "dashboard:incognito-ops", + { incognito: true, sessionId: "s-incognito-ops", updatedAt: 600 }, + "ops", + ); + + expect( + canPrewarmCombinedSessionStoresForGateway(cfg, { + agentIds: ["ops"], + maxRows: 4, + }), + ).toBe(false); const { store } = loadCombinedSessionStoreForGateway(cfg); expect(store["agent:ops:main"]?.sessionId).toBe("s-ops"); @@ -1590,6 +1611,13 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" const { store, storePath } = loadCombinedSessionStoreForGateway(cfg, { agentId: "codex" }); + expect( + canPrewarmCombinedSessionStoresForGateway(cfg, { + agentIds: ["codex"], + maxRows: 0, + }), + ).toBe(false); + expect(path.resolve(storePath)).toBe(path.resolve(codexStorePath)); expect(store["agent:codex:acp-task"]?.sessionId).toBe("s-codex"); expect(store["agent:main:main"]).toBeUndefined(); From fcc20d14a3717434cff8a326f16479a221114a2c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 11:36:24 +0800 Subject: [PATCH 13/15] fix(google): stop scraping Gemini CLI OAuth credentials (#117167) * fix(google): delegate Gemini OAuth refresh to the CLI * refactor(google): remove retired Gemini OAuth stack * chore(ci): prune retired Google OAuth baseline * fix(google): retire Gemini CLI usage telemetry * fix(google): remove retired usage token parser --- config/max-lines-baseline.txt | 1 - extensions/google/cli-backend-auth.test.ts | 2 +- extensions/google/gemini-cli-provider.ts | 30 +- .../google/google-oauth.test-support.ts | 38 - extensions/google/index.test.ts | 50 +- extensions/google/oauth-token-shared.test.ts | 14 +- extensions/google/oauth-token-shared.ts | 10 - extensions/google/oauth.credentials.ts | 378 ----- extensions/google/oauth.flow.ts | 64 - extensions/google/oauth.http.proxy.test.ts | 121 -- extensions/google/oauth.http.test.ts | 187 --- extensions/google/oauth.http.ts | 52 - extensions/google/oauth.local-login.test.ts | 72 - extensions/google/oauth.project.ts | 254 ---- extensions/google/oauth.runtime.ts | 2 - extensions/google/oauth.settings.ts | 81 -- extensions/google/oauth.shared.ts | 46 - extensions/google/oauth.test.ts | 1244 ----------------- extensions/google/oauth.token.ts | 172 --- extensions/google/oauth.ts | 105 -- .../cli-runner/cli-backend-auth-policy.ts | 8 +- src/agents/cli-runner/prepare.test.ts | 163 +-- src/agents/cli-runner/prepare.ts | 14 +- 23 files changed, 49 insertions(+), 3059 deletions(-) delete mode 100644 extensions/google/oauth.credentials.ts delete mode 100644 extensions/google/oauth.flow.ts delete mode 100644 extensions/google/oauth.http.proxy.test.ts delete mode 100644 extensions/google/oauth.http.test.ts delete mode 100644 extensions/google/oauth.http.ts delete mode 100644 extensions/google/oauth.local-login.test.ts delete mode 100644 extensions/google/oauth.project.ts delete mode 100644 extensions/google/oauth.runtime.ts delete mode 100644 extensions/google/oauth.settings.ts delete mode 100644 extensions/google/oauth.shared.ts delete mode 100644 extensions/google/oauth.test.ts delete mode 100644 extensions/google/oauth.token.ts delete mode 100644 extensions/google/oauth.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index f8d861053556..a2f10d58a306 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -118,7 +118,6 @@ extensions/file-transfer/src/shared/node-invoke-policy.ts extensions/firecrawl/src/firecrawl-tools.test.ts extensions/github-copilot/index.test.ts extensions/google-meet/index.test.ts -extensions/google/oauth.test.ts extensions/google/realtime-voice-provider.test.ts extensions/google/realtime-voice-provider.ts extensions/google/transport-stream.test.ts diff --git a/extensions/google/cli-backend-auth.test.ts b/extensions/google/cli-backend-auth.test.ts index e68c8d5ba740..71021734664a 100644 --- a/extensions/google/cli-backend-auth.test.ts +++ b/extensions/google/cli-backend-auth.test.ts @@ -637,7 +637,7 @@ describe("google gemini cli backend auth bridge", () => { } }); - it("keeps expired but refreshable legacy OAuth profiles on the compatibility path", async () => { + it("stages expired legacy OAuth credentials for Gemini CLI-owned refresh", async () => { await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { const context = buildGeminiOAuthPrepareContext(workspaceDir); if (!context.authCredential) { diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index f21d605e5243..06cfdba56e44 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -1,25 +1,14 @@ -import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; // Google provider module implements model/runtime integration. -import type { - OpenClawPluginApi, - ProviderFetchUsageSnapshotContext, -} from "openclaw/plugin-sdk/plugin-entry"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; -import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { GOOGLE_GEMINI_CLI_PROVIDER_ID } from "./gemini-cli-auth-home.js"; -import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js"; +import { formatGoogleOauthApiKey } from "./oauth-token-shared.js"; import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; const PROVIDER_ID = GOOGLE_GEMINI_CLI_PROVIDER_ID; const PROVIDER_LABEL = "Gemini CLI runtime"; -const loadOauthRuntimeModule = createLazyRuntimeModule(() => import("./oauth.runtime.js")); - -async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { - return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); -} - export function buildGoogleGeminiCliProvider(): ProviderPlugin { return { id: PROVIDER_ID, @@ -36,21 +25,6 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin { ...GOOGLE_GEMINI_PROVIDER_HOOKS, isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), formatApiKey: (cred) => formatGoogleOauthApiKey(cred), - refreshOAuth: async (cred) => { - const { refreshGeminiCliOAuthToken } = await loadOauthRuntimeModule(); - return await refreshGeminiCliOAuthToken(cred); - }, - resolveUsageAuth: async (ctx) => { - const auth = await ctx.resolveOAuthToken(); - if (!auth) { - return null; - } - return { - ...auth, - token: parseGoogleUsageToken(auth.token), - }; - }, - fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), }; } diff --git a/extensions/google/google-oauth.test-support.ts b/extensions/google/google-oauth.test-support.ts index 62c5bb0a071f..0faa82ca10f4 100644 --- a/extensions/google/google-oauth.test-support.ts +++ b/extensions/google/google-oauth.test-support.ts @@ -1,25 +1,3 @@ -type OAuthSettingsFs = { - existsSync: (path: string) => boolean; - readFileSync: (path: string, encoding: "utf8") => string; - homedir: () => string; -}; - -type CredentialFs = { - existsSync: (path: string) => boolean; - readFileSync: (path: string, encoding: "utf8") => string; - realpathSync: (path: string) => string; - readdirSync: (path: string, options: { withFileTypes: true }) => import("node:fs").Dirent[]; -}; - -type OAuthCredentialsTestApi = { - clearCredentialsCache: () => void; - setFs: (overrides?: Partial) => void; -}; - -type OAuthSettingsTestApi = { - setFs: (overrides?: Partial) => void; -}; - type VertexAdcTestApi = { reset: () => void; }; @@ -32,22 +10,6 @@ function requireTestApi(key: string): unknown { return api; } -export function clearGoogleOAuthCredentialsCache(): void { - ( - requireTestApi("openclaw.google.oauthCredentialsTestApi") as OAuthCredentialsTestApi - ).clearCredentialsCache(); -} - -export function setGoogleOAuthCredentialsFs(overrides?: Partial): void { - (requireTestApi("openclaw.google.oauthCredentialsTestApi") as OAuthCredentialsTestApi).setFs( - overrides, - ); -} - -export function setGoogleOAuthSettingsFs(overrides?: Partial): void { - (requireTestApi("openclaw.google.oauthSettingsTestApi") as OAuthSettingsTestApi).setFs(overrides); -} - export function resetGoogleVertexAdcState(): void { (requireTestApi("openclaw.google.vertexAdcTestApi") as VertexAdcTestApi).reset(); } diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 88ff7d28b5e9..1126fdfc7ced 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -14,7 +14,7 @@ import { } from "openclaw/plugin-sdk/plugin-test-runtime"; import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import googlePlugin from "./index.js"; import googleProviderDiscovery from "./provider-discovery.js"; @@ -27,12 +27,6 @@ const googleProviderPlugin = { }, }; -const refreshGeminiCliOAuthTokenMock = vi.hoisted(() => vi.fn()); - -vi.mock("./oauth.runtime.js", () => ({ - refreshGeminiCliOAuthToken: refreshGeminiCliOAuthTokenMock, -})); - describe("google provider plugin hooks", () => { it("owns replay policy and reasoning mode for the direct Gemini provider", async () => { const { providers } = await registerProviderPlugin({ @@ -129,7 +123,7 @@ describe("google provider plugin hooks", () => { ).toBe("tagged"); }); - it("keeps the Gemini CLI runtime without offering new OAuth setup", async () => { + it("keeps the Gemini CLI runtime without OpenClaw-owned OAuth surfaces", async () => { const { providers } = await registerProviderPlugin({ plugin: googleProviderPlugin, id: "google", @@ -141,7 +135,9 @@ describe("google provider plugin hooks", () => { expect(cliProvider.auth).toEqual([]); expect(cliProvider.envVars).toEqual([]); expect(cliProvider.wizard).toBeUndefined(); - expect(cliProvider.refreshOAuth).toBeTypeOf("function"); + expect(cliProvider.refreshOAuth).toBeUndefined(); + expect(cliProvider.resolveUsageAuth).toBeUndefined(); + expect(cliProvider.fetchUsageSnapshot).toBeUndefined(); }); it("keeps google-antigravity hook aliases on tagged reasoning mode", async () => { @@ -425,40 +421,4 @@ describe("google provider plugin hooks", () => { expect(bridge.setMediaTimestamp(20)).toBeUndefined(); expect(bridge.sendUserMessage?.("hello")).toBeUndefined(); }); - - it("refreshes Gemini CLI OAuth through the provider-owned refresh hook", async () => { - refreshGeminiCliOAuthTokenMock.mockResolvedValueOnce({ - type: "oauth", - provider: "google-gemini-cli", - access: "fresh-access", - refresh: "fresh-refresh", - expires: Date.now() + 60_000, - email: "user@example.com", - projectId: "project-1", - }); - - const { providers } = await registerProviderPlugin({ - plugin: googleProviderPlugin, - id: "google", - name: "Google Provider", - }); - const provider = requireRegisteredProvider(providers, "google-gemini-cli"); - const credential = { - type: "oauth" as const, - provider: "google-gemini-cli", - access: "stale-access", - refresh: "stale-refresh", - expires: Date.now() - 60_000, - email: "user@example.com", - projectId: "project-1", - }; - - await expect(provider.refreshOAuth?.(credential)).resolves.toMatchObject({ - access: "fresh-access", - refresh: "fresh-refresh", - email: "user@example.com", - projectId: "project-1", - }); - expect(refreshGeminiCliOAuthTokenMock).toHaveBeenCalledWith(credential); - }); }); diff --git a/extensions/google/oauth-token-shared.test.ts b/extensions/google/oauth-token-shared.test.ts index a92fb88999bc..7a32864bf6e0 100644 --- a/extensions/google/oauth-token-shared.test.ts +++ b/extensions/google/oauth-token-shared.test.ts @@ -1,10 +1,6 @@ // Google tests cover oauth token shared plugin behavior. import { describe, expect, it } from "vitest"; -import { - formatGoogleOauthApiKey, - parseGoogleOauthApiKey, - parseGoogleUsageToken, -} from "./oauth-token-shared.js"; +import { formatGoogleOauthApiKey, parseGoogleOauthApiKey } from "./oauth-token-shared.js"; describe("google oauth token helpers", () => { it("formats oauth credentials with project-aware payloads", () => { @@ -21,10 +17,6 @@ describe("google oauth token helpers", () => { expect(formatGoogleOauthApiKey({ type: "token", access: "token-123" })).toBe(""); }); - it("parses project-aware oauth payloads for usage auth", () => { - expect(parseGoogleUsageToken(JSON.stringify({ token: "usage-token" }))).toBe("usage-token"); - }); - it("parses structured oauth payload fields", () => { expect( parseGoogleOauthApiKey(JSON.stringify({ token: "usage-token", projectId: "proj-1" })), @@ -33,8 +25,4 @@ describe("google oauth token helpers", () => { projectId: "proj-1", }); }); - - it("falls back to the raw token when the payload is not JSON", () => { - expect(parseGoogleUsageToken("raw-token")).toBe("raw-token"); - }); }); diff --git a/extensions/google/oauth-token-shared.ts b/extensions/google/oauth-token-shared.ts index ffa025d5664b..5ec8c8767ad0 100644 --- a/extensions/google/oauth-token-shared.ts +++ b/extensions/google/oauth-token-shared.ts @@ -31,13 +31,3 @@ export function formatGoogleOauthApiKey(cred: GoogleOauthApiKeyCredential): stri projectId: cred.projectId, }); } - -export function parseGoogleUsageToken(apiKey: string): string { - const parsed = parseGoogleOauthApiKey(apiKey); - if (parsed?.token) { - return parsed.token; - } - - // Keep the raw token when the stored credential is not a project-aware JSON payload. - return apiKey; -} diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts deleted file mode 100644 index 611af1d66bfe..000000000000 --- a/extensions/google/oauth.credentials.ts +++ /dev/null @@ -1,378 +0,0 @@ -// Google plugin module implements oauth.credentials behavior. -import { existsSync, readdirSync, realpathSync } from "node:fs"; -import type { Dirent } from "node:fs"; -import { delimiter, dirname, join } from "node:path"; -import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; -import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; - -type CredentialFs = { - existsSync: (path: Parameters[0]) => ReturnType; - readFileSync: (path: string, encoding: "utf8") => string; - realpathSync: (path: Parameters[0]) => string; - readdirSync: ( - path: Parameters[0], - options: { withFileTypes: true }, - ) => Dirent[]; -}; - -const defaultFs: CredentialFs = { - existsSync, - readFileSync: (path) => - readSecretFileSync(path, "Gemini CLI OAuth credentials", { - maxBytes: 1024 * 1024, - rejectHardlinks: false, - }), - realpathSync, - readdirSync, -}; - -const OAUTH_CREDENTIALS_TEST_API_KEY = Symbol.for("openclaw.google.oauthCredentialsTestApi"); - -let credentialFs: CredentialFs = defaultFs; -const GEMINI_CLI_TREE_SEARCH_DEPTH = 10; - -type GeminiCliCredentialExtractDiagnostics = { - searchedPaths: string[]; - recursiveSearchRoots: string[]; - parseFailures: string[]; - readErrors: string[]; -}; - -function resolveEnv(keys: string[]): string | undefined { - for (const key of keys) { - const value = process.env[key]?.trim(); - if (value) { - return value; - } - } - return undefined; -} - -let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; -let geminiCliCredentialExtractError: string | null = null; - -function clearCredentialsCache(): void { - cachedGeminiCliCredentials = null; - geminiCliCredentialExtractError = null; -} - -function setOAuthCredentialsFsForTest(overrides?: Partial): void { - credentialFs = overrides ? { ...defaultFs, ...overrides } : defaultFs; -} - -function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { - if (cachedGeminiCliCredentials) { - return cachedGeminiCliCredentials; - } - - geminiCliCredentialExtractError = null; - const diagnostics: GeminiCliCredentialExtractDiagnostics = { - searchedPaths: [], - recursiveSearchRoots: [], - parseFailures: [], - readErrors: [], - }; - - try { - const geminiPath = findInPath("gemini"); - if (!geminiPath) { - geminiCliCredentialExtractError = - "Gemini CLI binary was not found in PATH during OAuth credential extraction."; - return null; - } - - const resolvedPath = credentialFs.realpathSync(geminiPath); - const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); - - for (const geminiCliDir of geminiCliDirs) { - const directCredentials = readGeminiCliCredentialsFromKnownPaths(geminiCliDir, diagnostics); - if (directCredentials) { - cachedGeminiCliCredentials = directCredentials; - return directCredentials; - } - - const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir, diagnostics); - if (bundledCredentials) { - cachedGeminiCliCredentials = bundledCredentials; - return bundledCredentials; - } - - diagnostics.recursiveSearchRoots.push(geminiCliDir); - const discoveredCredentials = findGeminiCliCredentialsInTree( - geminiCliDir, - GEMINI_CLI_TREE_SEARCH_DEPTH, - diagnostics, - ); - if (discoveredCredentials) { - cachedGeminiCliCredentials = discoveredCredentials; - return discoveredCredentials; - } - } - geminiCliCredentialExtractError = formatGeminiCliCredentialExtractError({ - geminiPath, - resolvedPath, - diagnostics, - }); - } catch (error) { - geminiCliCredentialExtractError = `Unexpected error while extracting Gemini CLI OAuth credentials: ${formatError(error)}`; - } - return null; -} - -function formatGeminiCliCredentialExtractError({ - geminiPath, - resolvedPath, - diagnostics, -}: { - geminiPath: string; - resolvedPath: string; - diagnostics: GeminiCliCredentialExtractDiagnostics; -}): string { - const prefix = [ - "Found Gemini CLI in PATH, but could not extract OAuth credentials.", - `geminiPath=${geminiPath}`, - `resolvedPath=${resolvedPath}`, - ]; - - if (diagnostics.parseFailures.length > 0) { - return [ - ...prefix, - "Candidate credential files did not contain a parseable OAuth client id/secret.", - `candidates=${diagnostics.parseFailures.join(", ")}`, - ].join(" "); - } - - if (diagnostics.readErrors.length > 0) { - return [ - ...prefix, - "Unexpected errors occurred while reading candidate credential files/directories.", - `errors=${diagnostics.readErrors.join(", ")}`, - ].join(" "); - } - - return [ - ...prefix, - "Could not locate oauth2.js or bundled credential source.", - `searched=${diagnostics.searchedPaths.join(", ") || "(none)"}`, - `recursiveSearchRoots=${diagnostics.recursiveSearchRoots.join(", ") || "(none)"}`, - `recursiveSearchDepth=${GEMINI_CLI_TREE_SEARCH_DEPTH}`, - ].join(" "); -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] { - const binDir = dirname(geminiPath); - const candidates = [ - dirname(dirname(resolvedPath)), - join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"), - join(binDir, "node_modules", "@google", "gemini-cli"), - join(dirname(binDir), "node_modules", "@google", "gemini-cli"), - join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"), - ]; - - const deduped: string[] = []; - const seen = new Set(); - for (const candidate of candidates) { - for (const searchDir of resolveGeminiCliSearchDirs(candidate)) { - const key = - process.platform === "win32" - ? lowercasePreservingWhitespace(searchDir.replace(/\\/g, "/")) - : searchDir; - if (seen.has(key)) { - continue; - } - seen.add(key); - deduped.push(searchDir); - } - } - return deduped; -} - -function resolveGeminiCliSearchDirs(candidate: string): string[] { - const searchDirs = [ - candidate, - join(candidate, "node_modules", "@google", "gemini-cli"), - join(candidate, "lib", "node_modules", "@google", "gemini-cli"), - ]; - return searchDirs.filter(looksLikeGeminiCliDir); -} - -function looksLikeGeminiCliDir(candidate: string): boolean { - return ( - credentialFs.existsSync(join(candidate, "package.json")) || - credentialFs.existsSync(join(candidate, "node_modules", "@google", "gemini-cli-core")) - ); -} - -function findInPath(name: string): string | null { - const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; - for (const dir of (process.env.PATH ?? "").split(delimiter)) { - for (const ext of exts) { - const path = join(dir, name + ext); - if (credentialFs.existsSync(path)) { - return path; - } - } - } - return null; -} - -function readGeminiCliCredentialsFile( - path: string, - diagnostics: GeminiCliCredentialExtractDiagnostics, -): { clientId: string; clientSecret: string } | null { - try { - const credentials = parseGeminiCliCredentials(credentialFs.readFileSync(path, "utf8")); - if (!credentials) { - diagnostics.parseFailures.push(path); - } - return credentials; - } catch (error) { - diagnostics.readErrors.push(`${path}: ${formatError(error)}`); - return null; - } -} - -function parseGeminiCliCredentials( - content: string, -): { clientId: string; clientSecret: string } | null { - const clientId = - content.match(/OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/)?.[1] ?? - content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/)?.[1]; - const clientSecret = - content.match(/OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/)?.[1] ?? - content.match(/(GOCSPX-[A-Za-z0-9_-]+)/)?.[1]; - if (!clientId || !clientSecret) { - return null; - } - return { clientId, clientSecret }; -} - -function readGeminiCliCredentialsFromKnownPaths( - geminiCliDir: string, - diagnostics: GeminiCliCredentialExtractDiagnostics, -): { clientId: string; clientSecret: string } | null { - const searchPaths = [ - join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "src", - "code_assist", - "oauth2.js", - ), - join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "code_assist", - "oauth2.js", - ), - ]; - diagnostics.searchedPaths.push(...searchPaths); - - for (const path of searchPaths) { - if (!credentialFs.existsSync(path)) { - continue; - } - const credentials = readGeminiCliCredentialsFile(path, diagnostics); - if (credentials) { - return credentials; - } - } - - return null; -} - -function readGeminiCliCredentialsFromBundle( - geminiCliDir: string, - diagnostics: GeminiCliCredentialExtractDiagnostics, -): { clientId: string; clientSecret: string } | null { - const bundleDir = join(geminiCliDir, "bundle"); - if (!credentialFs.existsSync(bundleDir)) { - return null; - } - - try { - for (const entry of credentialFs.readdirSync(bundleDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".js")) { - continue; - } - const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name), diagnostics); - if (credentials) { - return credentials; - } - } - } catch (error) { - diagnostics.readErrors.push(`${bundleDir}: ${formatError(error)}`); - // Preserve the read error for diagnostics and fall back to the recursive search. - } - - return null; -} - -function findGeminiCliCredentialsInTree( - dir: string, - depth: number, - diagnostics: GeminiCliCredentialExtractDiagnostics, -): { clientId: string; clientSecret: string } | null { - if (depth <= 0) { - return null; - } - try { - for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isFile() && entry.name === "oauth2.js") { - const credentials = readGeminiCliCredentialsFile(path, diagnostics); - if (credentials) { - return credentials; - } - continue; - } - if (entry.isDirectory() && !entry.name.startsWith(".")) { - const found = findGeminiCliCredentialsInTree(path, depth - 1, diagnostics); - if (found) { - return found; - } - } - } - } catch (error) { - diagnostics.readErrors.push(`${dir}: ${formatError(error)}`); - } - return null; -} - -export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { - const envClientId = resolveEnv(CLIENT_ID_KEYS); - const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); - if (envClientId) { - return { clientId: envClientId, clientSecret: envClientSecret }; - } - - const extracted = extractGeminiCliCredentials(); - if (extracted) { - return extracted; - } - - const detail = geminiCliCredentialExtractError - ? ` Details: ${geminiCliCredentialExtractError}` - : ""; - throw new Error( - `Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.${detail}`, - ); -} - -if (process.env.VITEST) { - (globalThis as Record)[OAUTH_CREDENTIALS_TEST_API_KEY] = { - clearCredentialsCache, - setFs: setOAuthCredentialsFsForTest, - }; -} diff --git a/extensions/google/oauth.flow.ts b/extensions/google/oauth.flow.ts deleted file mode 100644 index d9579d3cd689..000000000000 --- a/extensions/google/oauth.flow.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Google plugin module implements oauth.flow behavior. -import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth"; -import { - generateOAuthState, - parseOAuthCallbackInput, - waitForLocalOAuthCallback, -} from "openclaw/plugin-sdk/provider-auth-runtime"; -import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env"; -import { resolveOAuthClientConfig } from "./oauth.credentials.js"; -import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js"; - -export { generateOAuthState }; - -export function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -export function generatePkce(): { verifier: string; challenge: string } { - return generateHexPkceVerifierChallenge(); -} - -export function buildAuthUrl(challenge: string, state: string): string { - const { clientId } = resolveOAuthClientConfig(); - const params = new URLSearchParams({ - client_id: clientId, - response_type: "code", - redirect_uri: REDIRECT_URI, - scope: SCOPES.join(" "), - code_challenge: challenge, - code_challenge_method: "S256", - state, - access_type: "offline", - prompt: "consent", - }); - return `${AUTH_URL}?${params.toString()}`; -} - -export function parseCallbackInput( - input: string, -): { code: string; state: string } | { error: string } { - return parseOAuthCallbackInput(input, { - missingState: "Missing 'state' parameter. Paste the full URL.", - invalidInput: "Paste the full redirect URL, not just the code.", - }); -} - -export async function waitForLocalCallback(params: { - expectedState: string; - timeoutMs: number; - onProgress?: (message: string) => void; - signal?: AbortSignal; -}): Promise<{ code: string; state: string }> { - return await waitForLocalOAuthCallback({ - expectedState: params.expectedState, - timeoutMs: params.timeoutMs, - port: 8085, - callbackPath: "/oauth2callback", - redirectUri: REDIRECT_URI, - successTitle: "Gemini CLI OAuth complete", - progressMessage: `Waiting for OAuth callback on ${REDIRECT_URI}…`, - onProgress: params.onProgress, - ...(params.signal ? { signal: params.signal } : {}), - }); -} diff --git a/extensions/google/oauth.http.proxy.test.ts b/extensions/google/oauth.http.proxy.test.ts deleted file mode 100644 index db95cdb77d37..000000000000 --- a/extensions/google/oauth.http.proxy.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Google tests cover oauth.http proxy-mode selection for the Gemini CLI OAuth -// token-exchange/identity calls (issue openclaw#46184). -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TOKEN_URL } from "./oauth.shared.js"; - -const fetchWithSsrFGuardMock = vi.fn(); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/ssrf-runtime", - ); - return { - ...actual, - fetchWithSsrFGuard: (params: unknown) => fetchWithSsrFGuardMock(params), - }; -}); - -const { fetchWithTimeout } = await import("./oauth.http.js"); - -const PROXY_ENV_KEYS = [ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "NO_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - "no_proxy", -] as const; - -const savedEnv = new Map(); - -type ProxyEnvOverrides = { - HTTP_PROXY?: string; - HTTPS_PROXY?: string; - ALL_PROXY?: string; - NO_PROXY?: string; -}; - -function setProxyEnv(values: ProxyEnvOverrides): void { - for (const key of PROXY_ENV_KEYS) { - delete process.env[key]; - } - if (values.HTTP_PROXY !== undefined) { - process.env.HTTP_PROXY = values.HTTP_PROXY; - } - if (values.HTTPS_PROXY !== undefined) { - process.env.HTTPS_PROXY = values.HTTPS_PROXY; - } - if (values.ALL_PROXY !== undefined) { - process.env.ALL_PROXY = values.ALL_PROXY; - } - if (values.NO_PROXY !== undefined) { - process.env.NO_PROXY = values.NO_PROXY; - } -} - -function lastGuardedOptions(): Record { - const call = fetchWithSsrFGuardMock.mock.calls.at(-1)?.[0]; - if (!call || typeof call !== "object") { - throw new Error("Expected fetchWithSsrFGuard to be called"); - } - return call as Record; -} - -describe("oauth.http fetchWithTimeout proxy selection", () => { - beforeEach(() => { - for (const key of PROXY_ENV_KEYS) { - savedEnv.set(key, process.env[key]); - } - fetchWithSsrFGuardMock.mockReset(); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response("{}", { status: 200 }), - finalUrl: TOKEN_URL, - release: async () => {}, - }); - }); - - afterEach(() => { - for (const [key, value] of savedEnv) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - savedEnv.clear(); - }); - - it("routes the Google token exchange through the env proxy when configured", async () => { - setProxyEnv({ HTTPS_PROXY: "http://127.0.0.1:7897", HTTP_PROXY: "http://127.0.0.1:7897" }); - - await fetchWithTimeout(TOKEN_URL, { method: "POST", body: "grant_type=refresh_token" }); - - expect(lastGuardedOptions().mode).toBe("trusted_env_proxy"); - }); - - it("keeps the strict default when no proxy is configured", async () => { - setProxyEnv({}); - - await fetchWithTimeout(TOKEN_URL, { method: "POST" }); - - expect(lastGuardedOptions().mode).toBeUndefined(); - }); - - it("keeps the strict default when NO_PROXY bypasses the target host", async () => { - setProxyEnv({ HTTPS_PROXY: "http://127.0.0.1:7897", NO_PROXY: "googleapis.com" }); - - await fetchWithTimeout(TOKEN_URL, { method: "POST" }); - - expect(lastGuardedOptions().mode).toBeUndefined(); - }); - - it("keeps the strict default for ALL_PROXY-only environments", async () => { - setProxyEnv({ ALL_PROXY: "http://127.0.0.1:7897" }); - - await fetchWithTimeout(TOKEN_URL, { method: "POST" }); - - expect(lastGuardedOptions().mode).toBeUndefined(); - }); -}); diff --git a/extensions/google/oauth.http.test.ts b/extensions/google/oauth.http.test.ts deleted file mode 100644 index 6126da179930..000000000000 --- a/extensions/google/oauth.http.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Google tests cover oauth.http body-byte-cap for the Gemini CLI OAuth -// token-exchange/identity calls. -import http from "node:http"; -import type { AddressInfo } from "node:net"; -import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TOKEN_URL } from "./oauth.shared.js"; - -const fetchWithSsrFGuardMock = vi.fn(); -const releaseMock = vi.fn(async () => undefined); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/ssrf-runtime", - ); - return { - ...actual, - fetchWithSsrFGuard: (params: unknown) => fetchWithSsrFGuardMock(params), - }; -}); - -const { fetchWithTimeout } = await import("./oauth.http.js"); - -describe("oauth.http fetchWithTimeout body byte cap", () => { - beforeEach(() => { - fetchWithSsrFGuardMock.mockReset(); - releaseMock.mockClear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("caps oversized response body at 16 MiB with labeled overflow error", async () => { - // Build a Response with a body that exceeds the 16 MiB cap. - // 1 MiB chunks × 18 chunks = 18 MiB queued; the bounded reader reads - // up to the 16 MiB cap (16 chunks = 16777216 bytes) and one extra - // chunk before throwing on overflow, so the labeled `size` is the - // cap plus the trailing chunk: 16777216 + 1048576 = 17825792 bytes. - const CHUNK = 1024 * 1024; - let sent = 0; - const body = new ReadableStream({ - pull(controller) { - if (sent < 18) { - controller.enqueue(new Uint8Array(CHUNK)); - sent++; - } else { - controller.close(); - } - }, - }); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(body, { - status: 200, - headers: { "content-type": "application/json" }, - }), - finalUrl: TOKEN_URL, - release: releaseMock, - }); - - await expect(fetchWithTimeout(TOKEN_URL, { method: "POST" })).rejects.toThrow( - /google HTTP fetch: body exceeds 16777216 bytes \(got 17825792\)/, - ); - expect(releaseMock).toHaveBeenCalledOnce(); - }); - - it("returns a Response for normal-size bodies", async () => { - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response('{"access_token":"abc","expires_in":3600}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - finalUrl: TOKEN_URL, - release: releaseMock, - }); - - const res = await fetchWithTimeout(TOKEN_URL, { method: "POST" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ access_token: "abc", expires_in: 3600 }); - expect(releaseMock).toHaveBeenCalledOnce(); - }); - - it("passes caller cancellation to the guarded fetch timeout composer", async () => { - const controller = new AbortController(); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response("{}"), - finalUrl: TOKEN_URL, - release: releaseMock, - }); - - await fetchWithTimeout(TOKEN_URL, { method: "POST", signal: controller.signal }); - - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ signal: controller.signal }), - ); - }); -}); - -// Real-wire loopback proof. These tests bypass `fetchWithSsrFGuard` (which -// blocks 127.0.0.1 by design) and exercise `readResponseWithLimit` directly -// against a real `http.createServer` listener — the same helper that -// `fetchWithTimeout` calls inside its try/finally block. Captured vitest -// output for these two tests is the ClawSweeper "real behavior proof" required -// before merge. -describe("oauth.http bounded-read real wire proof (loopback http.createServer)", () => { - it("caps an oversized body streamed chunked over real wire", async () => { - const CHUNK = 1024 * 1024; - const MAX = 16 * 1024 * 1024; - const TOTAL = 18 * 1024 * 1024; - const server = http.createServer((req, res) => { - res.writeHead(200, { "content-type": "application/octet-stream" }); - let sent = 0; - const tick = setInterval(() => { - if (sent < 18) { - res.write(Buffer.alloc(CHUNK)); - sent++; - } else { - clearInterval(tick); - res.end(); - } - }, 1); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const port = (server.address() as AddressInfo).port; - - let captured: Error | undefined; - try { - const response = await fetch(`http://127.0.0.1:${port}/`); - // Wire framing merges TCP packets, so the exact reported size varies by - // runtime. The stable invariant is that the cap fires after MAX. - try { - await readResponseWithLimit(response, MAX, { - onOverflow: ({ size, maxBytes }) => - new Error(`real wire: body exceeds ${maxBytes} bytes (got ${size})`), - }); - } catch (err) { - captured = err as Error; - } - expect(captured).toBeInstanceOf(Error); - const match = captured!.message.match(/real wire: body exceeds \d+ bytes \(got (\d+)\)/); - expect(match).not.toBeNull(); - const got = Number(match![1]); - expect(got).toBeGreaterThan(MAX); - // Print to vitest stdout for PR-body real behavior proof capture. - console.log( - `[oauth.http loopback proof] oversized path: cap=${MAX} reported=${got} server_total=${TOTAL}`, - ); - } finally { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } - }); - - it("returns a Buffer for normal-size responses on real wire", async () => { - const bodyText = '{"access_token":"loopback","expires_in":3600}'; - const server = http.createServer((req, res) => { - res.writeHead(200, { "content-type": "application/json" }); - res.end(bodyText); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const port = (server.address() as AddressInfo).port; - - try { - const response = await fetch(`http://127.0.0.1:${port}/`); - const body = await readResponseWithLimit(response, 16 * 1024 * 1024, { - onOverflow: ({ size, maxBytes }) => - new Error(`real wire: body exceeds ${maxBytes} bytes (got ${size})`), - }); - expect(body.byteLength).toBe(Buffer.byteLength(bodyText, "utf8")); - expect(new TextDecoder("utf-8").decode(body)).toBe(bodyText); - console.log( - `[oauth.http loopback proof] normal path: cap=16777216 returned=${body.byteLength} body=${JSON.stringify(new TextDecoder("utf-8").decode(body))}`, - ); - } finally { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } - }); -}); diff --git a/extensions/google/oauth.http.ts b/extensions/google/oauth.http.ts deleted file mode 100644 index c5c9dd7ce5ed..000000000000 --- a/extensions/google/oauth.http.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Google plugin module implements oauth.http behavior. -import { - shouldUseEnvHttpProxyForUrl, - withTrustedEnvProxyGuardedFetchMode, -} from "openclaw/plugin-sdk/fetch-runtime"; -import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { DEFAULT_FETCH_TIMEOUT_MS } from "./oauth.shared.js"; - -const GOOGLE_OAUTH_BODY_MAX_BYTES = 16 * 1024 * 1024; - -export async function fetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, -): Promise { - // The guard composes its timeout with this top-level signal. Passing only - // init.signal would be overwritten when timeoutMs creates the effective signal. - const guardedOptions = { url, init, timeoutMs, signal: init.signal ?? undefined }; - const { response, release } = await fetchWithSsrFGuard( - shouldUseEnvHttpProxyForUrl(url) - ? withTrustedEnvProxyGuardedFetchMode(guardedOptions) - : guardedOptions, - ); - try { - // 16 MiB cap. A hostile or broken Google OAuth endpoint (or any - // accounts.google.com mirror / enterprise proxy) cannot force the - // runtime to buffer an unbounded body before the caller sees it. - // Complements #97587, which caps at the call site — this is the - // shared entry-point cap. - const body = await readResponseWithLimit(response, GOOGLE_OAUTH_BODY_MAX_BYTES, { - onOverflow: ({ size, maxBytes }) => - new Error(`google HTTP fetch: body exceeds ${maxBytes} bytes (got ${size})`), - }); - // `readResponseWithLimit` returns a `Buffer` (Node Uint8Array view). The - // global `Response` constructor accepts `BufferSource` (Uint8Array / - // ArrayBuffer) as a body; cast through `BodyInit` because `Buffer.buffer` - // is typed as `ArrayBufferLike` (could be `ArrayBuffer` or - // `SharedArrayBuffer`), but the helper always returns a regular `Buffer` - // backed by an `ArrayBuffer` with no shared-memory paths. The same - // wrap-shape is used by the googlechat google-auth helper at - // extensions/googlechat/src/google-auth.runtime.ts:454. - const bodyBytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); - return new Response(bodyBytes as unknown as BodyInit, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } finally { - await release(); - } -} diff --git a/extensions/google/oauth.local-login.test.ts b/extensions/google/oauth.local-login.test.ts deleted file mode 100644 index 2c305c28d0b5..000000000000 --- a/extensions/google/oauth.local-login.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Google tests cover oauth.local login plugin behavior. -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth?state=state-123"; - -const exchangeCodeForTokensMock = vi.hoisted(() => - vi.fn(async () => ({ - access: "access-token", - refresh: "refresh-token", - expires: 123, - })), -); -const waitForLocalCallbackMock = vi.hoisted(() => - vi.fn(async () => ({ code: "oauth-code", state: "state-123" })), -); - -vi.mock("./oauth.flow.js", () => ({ - buildAuthUrl: () => AUTH_URL, - generateOAuthState: () => "state-123", - generatePkce: () => ({ challenge: "pkce-challenge", verifier: "pkce-verifier" }), - parseCallbackInput: vi.fn(), - shouldUseManualOAuthFlow: (isRemote: boolean) => isRemote, - waitForLocalCallback: waitForLocalCallbackMock, -})); - -vi.mock("./oauth.token.js", () => ({ - exchangeCodeForTokens: exchangeCodeForTokensMock, -})); - -describe("loginGeminiCliOAuth local browser flow", () => { - beforeEach(() => { - exchangeCodeForTokensMock.mockClear(); - waitForLocalCallbackMock.mockClear(); - }); - - it("prints the auth URL before attempting best-effort browser launch", async () => { - const events: string[] = []; - const { loginGeminiCliOAuth } = await import("./oauth.js"); - const signal = new AbortController().signal; - const openUrl = vi.fn(async () => { - events.push("open"); - }); - const log = vi.fn((message: string) => { - events.push(`log:${message}`); - }); - - const result = await loginGeminiCliOAuth({ - isRemote: false, - openUrl, - log, - note: async () => {}, - prompt: async () => "", - progress: { update: () => {}, stop: () => {} }, - signal, - }); - - expect(result).toEqual({ - access: "access-token", - refresh: "refresh-token", - expires: 123, - }); - expect(log).toHaveBeenCalledWith(expect.stringContaining(AUTH_URL)); - expect(openUrl).toHaveBeenCalledWith(AUTH_URL); - expect(events.findIndex((event) => event.startsWith("log:"))).toBeLessThan( - events.indexOf("open"), - ); - expect(waitForLocalCallbackMock).toHaveBeenCalledWith( - expect.objectContaining({ expectedState: "state-123" }), - ); - expect(exchangeCodeForTokensMock).toHaveBeenCalledWith("oauth-code", "pkce-verifier", signal); - }); -}); diff --git a/extensions/google/oauth.project.ts b/extensions/google/oauth.project.ts deleted file mode 100644 index 4f655ab80da8..000000000000 --- a/extensions/google/oauth.project.ts +++ /dev/null @@ -1,254 +0,0 @@ -// Google plugin module implements oauth.project behavior. -import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; -import { fetchWithTimeout } from "./oauth.http.js"; -import { - CODE_ASSIST_ENDPOINT_PROD, - LOAD_CODE_ASSIST_ENDPOINTS, - TIER_FREE, - TIER_LEGACY, - TIER_STANDARD, - USERINFO_URL, -} from "./oauth.shared.js"; - -const LOAD_CODE_ASSIST_METADATA = { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", -} as const; - -async function getUserEmail( - accessToken: string, - signal?: AbortSignal, -): Promise { - try { - const response = await fetchWithTimeout(USERINFO_URL, { - headers: { Authorization: `Bearer ${accessToken}` }, - ...(signal ? { signal } : {}), - }); - if (response.ok) { - const data = await readProviderJsonResponse<{ email?: string }>(response, "google.userinfo"); - return data.email; - } - } catch { - signal?.throwIfAborted(); - // ignore - } - return undefined; -} - -function isVpcScAffected(payload: unknown): boolean { - if (!payload || typeof payload !== "object") { - return false; - } - const error = (payload as { error?: unknown }).error; - if (!error || typeof error !== "object") { - return false; - } - const details = (error as { details?: unknown[] }).details; - if (!Array.isArray(details)) { - return false; - } - return details.some( - (item) => - typeof item === "object" && - item && - (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED", - ); -} - -function getDefaultTier( - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, -): { id?: string } | undefined { - if (!allowedTiers?.length) { - return { id: TIER_LEGACY }; - } - return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; -} - -async function pollOperation( - endpoint: string, - operationName: string, - headers: Record, - signal?: AbortSignal, -): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { - for (let attempt = 0; attempt < 24; attempt += 1) { - await sleepWithAbort(5000, signal); - const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { - headers, - ...(signal ? { signal } : {}), - }); - if (!response.ok) { - continue; - } - const data = await readProviderJsonResponse<{ - done?: boolean; - response?: { cloudaicompanionProject?: { id?: string } }; - }>(response, "google.poll-operation"); - if (data.done) { - return data; - } - } - throw new Error("Operation polling timeout"); -} - -export async function resolveGoogleOAuthIdentity( - accessToken: string, - signal?: AbortSignal, -): Promise<{ - email?: string; - projectId?: string; -}> { - const email = await getUserEmail(accessToken, signal); - const projectId = await discoverProject(accessToken, signal); - return { email, projectId }; -} - -export async function resolveGooglePersonalOAuthIdentity( - accessToken: string, - signal?: AbortSignal, -): Promise<{ - email?: string; - projectId?: string; -}> { - return { email: await getUserEmail(accessToken, signal) }; -} - -async function discoverProject(accessToken: string, signal?: AbortSignal): Promise { - const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": `gl-node/${process.versions.node}`, - "Client-Metadata": JSON.stringify(LOAD_CODE_ASSIST_METADATA), - }; - - const loadBody = { - ...(envProject ? { cloudaicompanionProject: envProject } : {}), - metadata: { - ...LOAD_CODE_ASSIST_METADATA, - ...(envProject ? { duetProject: envProject } : {}), - }, - }; - - let data: { - currentTier?: { id?: string }; - cloudaicompanionProject?: string | { id?: string }; - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; - } = {}; - let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; - let loadError: Error | undefined; - for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify(loadBody), - ...(signal ? { signal } : {}), - }); - - if (!response.ok) { - const errorPayload = await readProviderJsonResponse( - response, - "google.load-code-assist", - ).catch(() => null); - if (isVpcScAffected(errorPayload)) { - data = { currentTier: { id: TIER_STANDARD } }; - activeEndpoint = endpoint; - loadError = undefined; - break; - } - loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); - continue; - } - - data = await readProviderJsonResponse(response, "google.load-code-assist"); - activeEndpoint = endpoint; - loadError = undefined; - break; - } catch (err) { - signal?.throwIfAborted(); - loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); - } - } - - const hasLoadCodeAssistData = - Boolean(data.currentTier) || - Boolean(data.cloudaicompanionProject) || - Boolean(data.allowedTiers?.length); - if (!hasLoadCodeAssistData && loadError) { - if (envProject) { - return envProject; - } - throw loadError; - } - - if (data.currentTier) { - const project = data.cloudaicompanionProject; - if (typeof project === "string" && project) { - return project; - } - if (typeof project === "object" && project?.id) { - return project.id; - } - if (envProject) { - return envProject; - } - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const tier = getDefaultTier(data.allowedTiers); - const tierId = tier?.id || TIER_FREE; - if (tierId !== TIER_FREE && !envProject) { - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const onboardBody: Record = { - tierId, - metadata: { - ...LOAD_CODE_ASSIST_METADATA, - }, - }; - if (tierId !== TIER_FREE && envProject) { - onboardBody.cloudaicompanionProject = envProject; - (onboardBody.metadata as Record).duetProject = envProject; - } - - const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify(onboardBody), - ...(signal ? { signal } : {}), - }); - - if (!onboardResponse.ok) { - throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); - } - - let lro = await readProviderJsonResponse<{ - done?: boolean; - name?: string; - response?: { cloudaicompanionProject?: { id?: string } }; - }>(onboardResponse, "google.onboard-user"); - - if (!lro.done && lro.name) { - lro = await pollOperation(activeEndpoint, lro.name, headers, signal); - } - - const projectId = lro.response?.cloudaicompanionProject?.id; - if (projectId) { - return projectId; - } - if (envProject) { - return envProject; - } - - throw new Error( - "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", - ); -} diff --git a/extensions/google/oauth.runtime.ts b/extensions/google/oauth.runtime.ts deleted file mode 100644 index c6477e72b7e0..000000000000 --- a/extensions/google/oauth.runtime.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Google plugin module implements oauth behavior. -export { loginGeminiCliOAuth, refreshGeminiCliOAuthToken } from "./oauth.js"; diff --git a/extensions/google/oauth.settings.ts b/extensions/google/oauth.settings.ts deleted file mode 100644 index ef68b4484317..000000000000 --- a/extensions/google/oauth.settings.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Google plugin module implements oauth.settings behavior. -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; - -type OAuthSettingsFs = { - existsSync: (path: Parameters[0]) => ReturnType; - readFileSync: (path: Parameters[0], encoding: "utf8") => string; - homedir: typeof homedir; -}; - -const defaultFs: OAuthSettingsFs = { - existsSync, - readFileSync, - homedir, -}; - -const OAUTH_SETTINGS_TEST_API_KEY = Symbol.for("openclaw.google.oauthSettingsTestApi"); - -let oauthSettingsFs: OAuthSettingsFs = defaultFs; - -type GeminiCliAuthSettings = { - security?: { - auth?: { - selectedType?: unknown; - enforcedType?: unknown; - }; - }; - selectedAuthType?: unknown; - enforcedAuthType?: unknown; -}; - -function readSettingsFile(): GeminiCliAuthSettings | null { - const settingsPath = join(oauthSettingsFs.homedir(), ".gemini", "settings.json"); - if (!oauthSettingsFs.existsSync(settingsPath)) { - return null; - } - try { - const parsed = JSON.parse(oauthSettingsFs.readFileSync(settingsPath, "utf8")) as unknown; - return isRecord(parsed) ? (parsed as GeminiCliAuthSettings) : null; - } catch { - return null; - } -} - -function setOAuthSettingsFsForTest(overrides?: Partial): void { - oauthSettingsFs = overrides ? { ...defaultFs, ...overrides } : defaultFs; -} - -function resolveGeminiCliSelectedAuthType(): string | undefined { - const settings = readSettingsFile(); - if (settings) { - const security = isRecord(settings.security) ? settings.security : undefined; - const auth = isRecord(security?.auth) ? security.auth : undefined; - const selectedAuthType = - normalizeOptionalString(auth?.selectedType) ?? - normalizeOptionalString(auth?.enforcedType) ?? - normalizeOptionalString(settings.selectedAuthType) ?? - normalizeOptionalString(settings.enforcedAuthType); - if (selectedAuthType) { - return selectedAuthType; - } - } - - if (process.env.GOOGLE_GENAI_USE_GCA === "true") { - return "oauth-personal"; - } - - return undefined; -} - -export function isGeminiCliPersonalOAuth(): boolean { - return resolveGeminiCliSelectedAuthType() === "oauth-personal"; -} - -if (process.env.VITEST) { - (globalThis as Record)[OAUTH_SETTINGS_TEST_API_KEY] = { - setFs: setOAuthSettingsFsForTest, - }; -} diff --git a/extensions/google/oauth.shared.ts b/extensions/google/oauth.shared.ts deleted file mode 100644 index 156c342358cc..000000000000 --- a/extensions/google/oauth.shared.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Google plugin module implements oauth.shared behavior. -export const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; -export const CLIENT_SECRET_KEYS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", -]; -export const REDIRECT_URI = "http://localhost:8085/oauth2callback"; -export const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -export const TOKEN_URL = "https://oauth2.googleapis.com/token"; -export const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; -export const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; -const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; -const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; -export const LOAD_CODE_ASSIST_ENDPOINTS = [ - CODE_ASSIST_ENDPOINT_PROD, - CODE_ASSIST_ENDPOINT_DAILY, - CODE_ASSIST_ENDPOINT_AUTOPUSH, -]; -export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; -export const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", -]; - -export const TIER_FREE = "free-tier"; -export const TIER_LEGACY = "legacy-tier"; -export const TIER_STANDARD = "standard-tier"; - -export type GeminiCliOAuthCredentials = { - access: string; - refresh: string; - expires: number; - email?: string; - projectId?: string; -}; - -export type GeminiCliOAuthContext = { - isRemote: boolean; - openUrl: (url: string) => Promise; - log: (msg: string) => void; - note: (message: string, title?: string) => Promise; - prompt: (message: string) => Promise; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; - signal?: AbortSignal; -}; diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts deleted file mode 100644 index 769bf752f150..000000000000 --- a/extensions/google/oauth.test.ts +++ /dev/null @@ -1,1244 +0,0 @@ -// Google tests cover oauth plugin behavior. -import { join, parse } from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - clearGoogleOAuthCredentialsCache, - setGoogleOAuthCredentialsFs, - setGoogleOAuthSettingsFs, -} from "./google-oauth.test-support.js"; - -vi.mock("openclaw/plugin-sdk/runtime-env", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/runtime-env", - ); - return { - ...actual, - isWSL2Sync: () => false, - }; -}); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/ssrf-runtime", - ); - return { - ...actual, - fetchWithSsrFGuard: async (params: { - url: string; - init?: RequestInit; - fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; - }) => { - const fetchImpl = params.fetchImpl ?? globalThis.fetch; - const response = await fetchImpl(params.url, params.init); - return { - response, - finalUrl: params.url, - release: async () => {}, - }; - }, - }; -}); - -afterAll(() => { - vi.doUnmock("openclaw/plugin-sdk/runtime-env"); - vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime"); - vi.resetModules(); -}); - -const mockExistsSync = vi.fn(); -const mockReadFileSync = vi.fn(); -const mockRealpathSync = vi.fn(); -const mockReaddirSync = vi.fn(); -const mockSettingsExistsSync = vi.fn(); -const mockSettingsReadFileSync = vi.fn(); - -function setGeminiPersonalOAuthSettings(): void { - mockSettingsExistsSync.mockReturnValue(true); - mockSettingsReadFileSync.mockReturnValue( - JSON.stringify({ security: { auth: { selectedType: "oauth-personal" } } }), - ); -} - -function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { - let count = 0; - for (const item of items) { - if (predicate(item)) { - count += 1; - } - } - return count; -} - -describe("isGeminiCliPersonalOAuth", () => { - const ENV_KEYS = ["GOOGLE_GENAI_USE_GCA"] as const; - - let envSnapshot: Partial>; - let isGeminiCliPersonalOAuth: typeof import("./oauth.settings.js").isGeminiCliPersonalOAuth; - - beforeAll(async () => { - ({ isGeminiCliPersonalOAuth } = await import("./oauth.settings.js")); - }); - - beforeEach(() => { - envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); - delete process.env.GOOGLE_GENAI_USE_GCA; - mockSettingsExistsSync.mockReset(); - mockSettingsReadFileSync.mockReset(); - setGoogleOAuthSettingsFs({ - existsSync: (...args) => mockSettingsExistsSync(...args), - readFileSync: (...args) => mockSettingsReadFileSync(...args), - homedir: () => "/mock/home", - }); - }); - - afterEach(() => { - for (const key of ENV_KEYS) { - const value = envSnapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - setGoogleOAuthSettingsFs(); - }); - - it("uses GOOGLE_GENAI_USE_GCA as an oauth-personal fallback when settings are absent", () => { - process.env.GOOGLE_GENAI_USE_GCA = "true"; - mockSettingsExistsSync.mockReturnValue(false); - - expect(isGeminiCliPersonalOAuth()).toBe(true); - }); - - it("prefers settings auth selection over the GOOGLE_GENAI_USE_GCA fallback", () => { - process.env.GOOGLE_GENAI_USE_GCA = "true"; - mockSettingsExistsSync.mockReturnValue(true); - mockSettingsReadFileSync.mockReturnValue( - JSON.stringify({ - security: { - auth: { - selectedType: "oauth-code-assist", - }, - }, - }), - ); - - expect(isGeminiCliPersonalOAuth()).toBe(false); - }); - - it("reads the nested security auth selection from ~/.gemini/settings.json", () => { - setGeminiPersonalOAuthSettings(); - - expect(isGeminiCliPersonalOAuth()).toBe(true); - }); - - it("falls back to legacy top-level selectedAuthType keys", () => { - mockSettingsExistsSync.mockReturnValue(true); - mockSettingsReadFileSync.mockReturnValue( - JSON.stringify({ selectedAuthType: "oauth-personal" }), - ); - - expect(isGeminiCliPersonalOAuth()).toBe(true); - }); -}); - -describe("resolveOAuthClientConfig", () => { - const ENV_KEYS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_ID", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", - ] as const; - const normalizePath = (value: string) => - value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); - const rootDir = parse(process.cwd()).root || "/"; - const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com"; - const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123"; - const FAKE_OAUTH2_CONTENT = ` - const clientId = "${FAKE_CLIENT_ID}"; - const clientSecret = "${FAKE_CLIENT_SECRET}"; - `; - - let originalPath: string | undefined; - let envSnapshot: Partial>; - let resolveOAuthClientConfig: typeof import("./oauth.credentials.js").resolveOAuthClientConfig; - - function resolveExtractedCredentialsOrNull() { - try { - return resolveOAuthClientConfig(); - } catch { - return null; - } - } - - async function installMockFs() { - setGoogleOAuthCredentialsFs({ - existsSync: (...args) => mockExistsSync(...args), - readFileSync: (...args) => mockReadFileSync(...args), - realpathSync: (...args) => mockRealpathSync(...args), - readdirSync: (...args) => mockReaddirSync(...args), - }); - } - - function makeFakeLayout() { - const binDir = join(rootDir, "fake", "bin"); - const geminiPath = join(binDir, "gemini"); - const resolvedPath = join( - rootDir, - "fake", - "lib", - "node_modules", - "@google", - "gemini-cli", - "dist", - "index.js", - ); - const oauth2Path = join( - rootDir, - "fake", - "lib", - "node_modules", - "@google", - "gemini-cli", - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "src", - "code_assist", - "oauth2.js", - ); - - return { binDir, geminiPath, resolvedPath, oauth2Path }; - } - - function installGeminiLayout(params: { - oauth2Exists?: boolean; - oauth2Content?: string; - readdir?: string[]; - }) { - const layout = makeFakeLayout(); - process.env.PATH = layout.binDir; - - // resolveGeminiCliDirs checks package.json to validate candidate directories - const geminiCliDir = join(rootDir, "fake", "lib", "node_modules", "@google", "gemini-cli"); - const packageJsonPath = normalizePath(join(geminiCliDir, "package.json")); - - mockExistsSync.mockImplementation((p: string) => { - const normalized = normalizePath(p); - if (normalized === normalizePath(layout.geminiPath)) { - return true; - } - if (normalized === packageJsonPath) { - return true; - } - if (params.oauth2Exists && normalized === normalizePath(layout.oauth2Path)) { - return true; - } - return false; - }); - mockRealpathSync.mockReturnValue(layout.resolvedPath); - if (params.oauth2Content !== undefined) { - mockReadFileSync.mockReturnValue(params.oauth2Content); - } - if (params.readdir) { - mockReaddirSync.mockReturnValue(params.readdir); - } - - return layout; - } - - function installNpmShimLayout(params: { oauth2Exists?: boolean; oauth2Content?: string }) { - const binDir = join(rootDir, "fake", "npm-bin"); - const geminiPath = join(binDir, "gemini"); - const resolvedPath = geminiPath; - const geminiCliDir = join(binDir, "node_modules", "@google", "gemini-cli"); - const oauth2Path = join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "src", - "code_assist", - "oauth2.js", - ); - const packageJsonPath = normalizePath(join(geminiCliDir, "package.json")); - process.env.PATH = binDir; - - mockExistsSync.mockImplementation((p: string) => { - const normalized = normalizePath(p); - if (normalized === normalizePath(geminiPath)) { - return true; - } - if (normalized === packageJsonPath) { - return true; - } - if (params.oauth2Exists && normalized === normalizePath(oauth2Path)) { - return true; - } - return false; - }); - mockRealpathSync.mockReturnValue(resolvedPath); - if (params.oauth2Content !== undefined) { - mockReadFileSync.mockReturnValue(params.oauth2Content); - } - } - - function installBundledNpmLayout(params: { bundleContent: string }) { - const binDir = join(rootDir, "fake", "npm-bundle-bin"); - const geminiPath = join(binDir, "gemini"); - const resolvedPath = geminiPath; - const geminiCliDir = join(binDir, "node_modules", "@google", "gemini-cli"); - const packageJsonPath = normalizePath(join(geminiCliDir, "package.json")); - const bundleDir = join(geminiCliDir, "bundle"); - const chunkPath = join(bundleDir, "chunk-ABC123.js"); - - process.env.PATH = binDir; - mockExistsSync.mockImplementation((p: string) => { - const normalized = normalizePath(p); - return ( - normalized === normalizePath(geminiPath) || - normalized === packageJsonPath || - normalized === normalizePath(bundleDir) - ); - }); - mockRealpathSync.mockReturnValue(resolvedPath); - mockReaddirSync.mockImplementation((p: string) => { - if (normalizePath(p) === normalizePath(bundleDir)) { - return [dirent("chunk-ABC123.js", false)]; - } - return []; - }); - mockReadFileSync.mockImplementation((p: string) => { - if (normalizePath(p) === normalizePath(chunkPath)) { - return params.bundleContent; - } - throw new Error(`Unexpected read for ${p}`); - }); - } - - function installHomebrewLibexecLayout(params: { oauth2Content: string }) { - const brewPrefix = join(rootDir, "opt", "homebrew"); - const cellarRoot = join(brewPrefix, "Cellar", "gemini-cli", "1.2.3"); - const binDir = join(brewPrefix, "bin"); - const geminiPath = join(binDir, "gemini"); - const resolvedPath = join(cellarRoot, "libexec", "bin", "gemini"); - const geminiCliDir = join( - cellarRoot, - "libexec", - "lib", - "node_modules", - "@google", - "gemini-cli", - ); - const packageJsonPath = normalizePath(join(geminiCliDir, "package.json")); - const oauth2Path = join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "src", - "code_assist", - "oauth2.js", - ); - - process.env.PATH = binDir; - mockExistsSync.mockImplementation((p: string) => { - const normalized = normalizePath(p); - return ( - normalized === normalizePath(geminiPath) || - normalized === packageJsonPath || - normalized === normalizePath(oauth2Path) - ); - }); - mockRealpathSync.mockReturnValue(resolvedPath); - mockReadFileSync.mockImplementation((p: string) => { - if (normalizePath(p) === normalizePath(oauth2Path)) { - return params.oauth2Content; - } - throw new Error(`Unexpected read for ${p}`); - }); - } - - function installWindowsNvmLayoutWithUnrelatedOauth(params: { - oauth2Content: string; - unrelatedOauth2Content: string; - }) { - const nvmRoot = join(rootDir, "fake", "Users", "lobster", "AppData", "Local", "nvm"); - const versionDir = join(nvmRoot, "v24.1.0"); - const geminiPath = join(versionDir, process.platform === "win32" ? "gemini.cmd" : "gemini"); - const resolvedPath = geminiPath; - const geminiCliDir = join(versionDir, "node_modules", "@google", "gemini-cli"); - const packageJsonPath = normalizePath(join(geminiCliDir, "package.json")); - const oauth2Path = join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "src", - "code_assist", - "oauth2.js", - ); - const unrelatedOauth2Path = join( - nvmRoot, - "node_modules", - "discord-api-types", - "payloads", - "v10", - "oauth2.js", - ); - - process.env.PATH = versionDir; - mockExistsSync.mockImplementation((p: string) => { - const normalized = normalizePath(p); - return ( - normalized === normalizePath(geminiPath) || - normalized === packageJsonPath || - normalized === normalizePath(oauth2Path) - ); - }); - mockRealpathSync.mockReturnValue(resolvedPath); - mockReadFileSync.mockImplementation((p: string) => { - const normalized = normalizePath(p); - if (normalized === normalizePath(oauth2Path)) { - return params.oauth2Content; - } - if (normalized === normalizePath(unrelatedOauth2Path)) { - return params.unrelatedOauth2Content; - } - throw new Error(`Unexpected read for ${p}`); - }); - mockReaddirSync.mockImplementation((p: string) => { - const normalized = normalizePath(p); - if (normalized === normalizePath(nvmRoot)) { - return [dirent("node_modules", true)]; - } - if (normalized === normalizePath(join(nvmRoot, "node_modules"))) { - return [dirent("discord-api-types", true)]; - } - if (normalized === normalizePath(join(nvmRoot, "node_modules", "discord-api-types"))) { - return [dirent("payloads", true)]; - } - if ( - normalized === normalizePath(join(nvmRoot, "node_modules", "discord-api-types", "payloads")) - ) { - return [dirent("v10", true)]; - } - if ( - normalized === - normalizePath(join(nvmRoot, "node_modules", "discord-api-types", "payloads", "v10")) - ) { - return [dirent("oauth2.js", false)]; - } - return []; - }); - - return { unrelatedOauth2Path }; - } - - function dirent(name: string, isDirectory: boolean) { - return { - name, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isDirectory: () => isDirectory, - isFIFO: () => false, - isFile: () => !isDirectory, - isSocket: () => false, - isSymbolicLink: () => false, - }; - } - - function expectFakeCliCredentials(result: unknown) { - expect(result).toEqual({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_SECRET, - }); - } - - beforeAll(async () => { - ({ resolveOAuthClientConfig } = await import("./oauth.credentials.js")); - }); - - beforeEach(async () => { - vi.clearAllMocks(); - originalPath = process.env.PATH; - envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); - for (const key of ENV_KEYS) { - delete process.env[key]; - } - await installMockFs(); - }); - - afterEach(async () => { - process.env.PATH = originalPath; - for (const key of ENV_KEYS) { - const value = envSnapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - setGoogleOAuthCredentialsFs(); - }); - - it("returns null when gemini binary is not in PATH", () => { - process.env.PATH = "/nonexistent"; - mockExistsSync.mockReturnValue(false); - - clearGoogleOAuthCredentialsCache(); - expect(resolveExtractedCredentialsOrNull()).toBeNull(); - }); - - it("includes missing binary details when resolving OAuth client config", async () => { - process.env.PATH = "/nonexistent"; - mockExistsSync.mockReturnValue(false); - - clearGoogleOAuthCredentialsCache(); - expect(() => resolveOAuthClientConfig()).toThrow( - /Details: Gemini CLI binary was not found in PATH/, - ); - }); - - it("extracts credentials from oauth2.js in known path", () => { - installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - - clearGoogleOAuthCredentialsCache(); - const result = resolveExtractedCredentialsOrNull(); - - expectFakeCliCredentials(result); - }); - - it("extracts credentials when PATH entry is an npm global shim", () => { - installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - - clearGoogleOAuthCredentialsCache(); - const result = resolveExtractedCredentialsOrNull(); - - expectFakeCliCredentials(result); - }); - - it("extracts credentials from bundled npm installs", () => { - installBundledNpmLayout({ - bundleContent: ` - const OAUTH_CLIENT_ID = "${FAKE_CLIENT_ID}"; - const OAUTH_CLIENT_SECRET = "${FAKE_CLIENT_SECRET}"; - `, - }); - - clearGoogleOAuthCredentialsCache(); - const result = resolveExtractedCredentialsOrNull(); - - expectFakeCliCredentials(result); - }); - - it("extracts credentials from Homebrew libexec installs", () => { - installHomebrewLibexecLayout({ oauth2Content: FAKE_OAUTH2_CONTENT }); - - clearGoogleOAuthCredentialsCache(); - const result = resolveExtractedCredentialsOrNull(); - - expectFakeCliCredentials(result); - }); - - it("returns null when oauth2.js cannot be found", () => { - installGeminiLayout({ oauth2Exists: false, readdir: [] }); - - clearGoogleOAuthCredentialsCache(); - expect(resolveExtractedCredentialsOrNull()).toBeNull(); - }); - - it("includes missing oauth2.js details when resolving OAuth client config", async () => { - installGeminiLayout({ oauth2Exists: false, readdir: [] }); - - clearGoogleOAuthCredentialsCache(); - expect(() => resolveOAuthClientConfig()).toThrow(/Could not locate oauth2\.js/); - expect(() => resolveOAuthClientConfig()).toThrow(/recursiveSearchDepth=10/); - }); - - it("returns null when oauth2.js lacks credentials", () => { - installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" }); - - clearGoogleOAuthCredentialsCache(); - expect(resolveExtractedCredentialsOrNull()).toBeNull(); - }); - - it("includes parse failure details when resolving OAuth client config", async () => { - installGeminiLayout({ - oauth2Exists: true, - oauth2Content: "// no credentials here", - readdir: [], - }); - - clearGoogleOAuthCredentialsCache(); - expect(() => resolveOAuthClientConfig()).toThrow( - /Candidate credential files did not contain a parseable OAuth client id\/secret/, - ); - }); - - it("includes unexpected extraction exception details when resolving OAuth client config", async () => { - installGeminiLayout({ oauth2Exists: true, readdir: [] }); - mockReadFileSync.mockImplementation(() => { - throw new Error("mock read failure"); - }); - - clearGoogleOAuthCredentialsCache(); - expect(() => resolveOAuthClientConfig()).toThrow( - /Unexpected errors occurred while reading candidate credential files\/directories/, - ); - expect(() => resolveOAuthClientConfig()).toThrow(/mock read failure/); - }); - - it("caches credentials after first extraction", () => { - installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - - clearGoogleOAuthCredentialsCache(); - - // First call - const result1 = resolveExtractedCredentialsOrNull(); - expectFakeCliCredentials(result1); - - // Second call should use cache (readFileSync not called again) - const readCount = mockReadFileSync.mock.calls.length; - const result2 = resolveExtractedCredentialsOrNull(); - expect(result2).toEqual(result1); - expect(mockReadFileSync.mock.calls.length).toBe(readCount); - }); - - it("skips unrelated oauth2.js files when gemini resolves inside a Windows nvm root", () => { - const { unrelatedOauth2Path } = installWindowsNvmLayoutWithUnrelatedOauth({ - oauth2Content: FAKE_OAUTH2_CONTENT, - unrelatedOauth2Content: "// unrelated oauth file", - }); - - clearGoogleOAuthCredentialsCache(); - const result = resolveExtractedCredentialsOrNull(); - - expectFakeCliCredentials(result); - expect( - mockReadFileSync.mock.calls.some( - ([path]) => normalizePath(String(path)) === normalizePath(unrelatedOauth2Path), - ), - ).toBe(false); - }); -}); - -describe("loginGeminiCliOAuth", () => { - const TOKEN_URL = "https://oauth2.googleapis.com/token"; - const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; - const LOAD_PROD = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; - const LOAD_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist"; - const LOAD_AUTOPUSH = - "https://autopush-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist"; - - const ENV_KEYS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_ID", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - "GOOGLE_GENAI_USE_GCA", - ] as const; - - const EXPECTED_LOAD_CODE_ASSIST_METADATA = { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - } as const; - const OVERSIZED_OAUTH_RESPONSE_BYTES = 17 * 1024 * 1024; - - function getRequestUrl(input: string | URL | Request): string { - if (typeof input === "string") { - return input; - } - if (input instanceof URL) { - return input.toString(); - } - return input.url; - } - - function getHeaderValue(headers: HeadersInit | undefined, name: string): string | undefined { - if (!headers) { - return undefined; - } - if (headers instanceof Headers) { - return headers.get(name) ?? undefined; - } - if (Array.isArray(headers)) { - return headers.find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; - } - return headers[name]; - } - - function responseJson(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); - } - - function oversizedJsonStringFieldResponse(params: { - prefix: string; - suffix: string; - targetBytes?: number; - }): Response { - const encoder = new TextEncoder(); - const prefix = encoder.encode(params.prefix); - const suffix = encoder.encode(params.suffix); - const chunk = new Uint8Array(64 * 1024).fill(0x61); - const targetBytes = params.targetBytes ?? OVERSIZED_OAUTH_RESPONSE_BYTES; - let sentBytes = 0; - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(prefix); - sentBytes += prefix.byteLength; - }, - pull(controller) { - if (sentBytes >= targetBytes) { - controller.enqueue(suffix); - controller.close(); - return; - } - controller.enqueue(chunk); - sentBytes += chunk.byteLength; - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - - function responseTextBodyWithTextTrap(body: string, status = 500) { - const response = new Response(body, { - status, - headers: { "Content-Type": "text/plain" }, - }); - const text = vi - .spyOn(response, "text") - .mockRejectedValue(new Error("unexpected response.text() call")); - return { response, text }; - } - - function tokenResponse(): Response { - return responseJson({ - access_token: "access-token", - refresh_token: "refresh-token", - expires_in: 3600, - }); - } - - function userInfoResponse(): Response { - return responseJson({ email: "lobster@openclaw.ai" }); - } - - type RecordedFetchRequest = { - url: string; - init?: RequestInit; - }; - - function installGeminiOAuthFetchMock( - handleRequest: (request: RecordedFetchRequest) => Response | undefined, - options: { tokenResponse?: () => Response } = {}, - ) { - const requests: RecordedFetchRequest[] = []; - const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const request = { url: getRequestUrl(input), init }; - requests.push(request); - - if (request.url === TOKEN_URL) { - return (options.tokenResponse ?? tokenResponse)(); - } - if (request.url === USERINFO_URL) { - return userInfoResponse(); - } - - const response = handleRequest(request); - if (response) { - return response; - } - throw new Error(`Unexpected request: ${request.url}`); - }); - vi.stubGlobal("fetch", fetchMock); - return { fetchMock, requests }; - } - - function getFormField(body: RequestInit["body"], name: string): string | null { - if (!(body instanceof URLSearchParams)) { - throw new Error("Expected URLSearchParams body"); - } - return body.get(name); - } - - function parseJsonString(value: unknown, label: string): unknown { - if (typeof value !== "string") { - throw new Error(`Expected ${label} JSON string`); - } - return JSON.parse(value); - } - - function requireString(value: string | null | undefined, label: string): string { - if (!value) { - throw new Error(`Expected ${label}`); - } - return value; - } - - function requireRecordedRequest( - request: RecordedFetchRequest | undefined, - label: string, - ): RecordedFetchRequest { - if (!request) { - throw new Error(`Expected ${label} request`); - } - return request; - } - - type LoginGeminiCliOAuthFn = (options: { - isRemote: boolean; - openUrl: () => Promise; - log: (msg: string) => void; - note: (message?: string, title?: string) => Promise; - prompt: () => Promise; - progress: { update: () => void; stop: () => void }; - }) => Promise<{ projectId?: string }>; - - async function runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth: LoginGeminiCliOAuthFn) { - let authUrl = ""; - const notes: string[] = []; - const result = await loginGeminiCliOAuth({ - isRemote: true, - openUrl: async () => {}, - log: (msg) => { - const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); - if (found?.[0]) { - authUrl = found[0]; - } - }, - note: async (message?: string) => { - if (message) { - notes.push(message); - } - }, - prompt: async () => { - const state = new URL(authUrl).searchParams.get("state"); - return `http://localhost:8085/oauth2callback?code=oauth-code&state=${state}`; - }, - progress: { update: () => {}, stop: () => {} }, - }); - return { result, authUrl, notes }; - } - - async function runProjectDiscoveryExpectingProjectId(projectId: string) { - const { resolveGoogleOAuthIdentity } = await import("./oauth.project.js"); - const result = await resolveGoogleOAuthIdentity("access-token"); - expect(result.projectId).toBe(projectId); - } - - it("propagates cancellation through Gemini identity and project discovery", async () => { - const controller = new AbortController(); - const signals: Array = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - signals.push(init?.signal); - if (url === USERINFO_URL) { - return new Response(JSON.stringify({ email: "test@example.com" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - controller.abort(new Error("setup cancelled")); - throw controller.signal.reason; - }), - ); - - const { resolveGoogleOAuthIdentity } = await import("./oauth.project.js"); - await expect(resolveGoogleOAuthIdentity("access-token", controller.signal)).rejects.toThrow( - "setup cancelled", - ); - expect(signals).toEqual([controller.signal, controller.signal]); - }); - - let envSnapshot: Partial>; - - beforeAll(async () => { - await import("./oauth.settings.js"); - }); - - beforeEach(() => { - envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); - process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_ID = "test-client-id.apps.googleusercontent.com"; - process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; // pragma: allowlist secret - delete process.env.GEMINI_CLI_OAUTH_CLIENT_ID; - delete process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET; - delete process.env.GOOGLE_CLOUD_PROJECT; - delete process.env.GOOGLE_CLOUD_PROJECT_ID; - delete process.env.GOOGLE_GENAI_USE_GCA; - mockSettingsExistsSync.mockReset(); - mockSettingsReadFileSync.mockReset(); - setGoogleOAuthSettingsFs({ - existsSync: (...args) => mockSettingsExistsSync(...args), - readFileSync: (...args) => mockSettingsReadFileSync(...args), - homedir: () => "/mock/home", - }); - mockSettingsExistsSync.mockReturnValue(false); - }); - - afterEach(() => { - for (const key of ENV_KEYS) { - const value = envSnapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - setGoogleOAuthSettingsFs(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("falls back across loadCodeAssist endpoints with aligned headers and metadata", async () => { - const { requests } = installGeminiOAuthFetchMock(({ url }) => { - if (url === LOAD_PROD) { - return responseJson({ error: { message: "temporary failure" } }, 503); - } - if (url === LOAD_DAILY) { - return responseJson({ - currentTier: { id: "standard-tier" }, - cloudaicompanionProject: { id: "daily-project" }, - }); - } - return undefined; - }); - - await runProjectDiscoveryExpectingProjectId("daily-project"); - const loadRequests = requests.filter((request) => - request.url.includes("v1internal:loadCodeAssist"), - ); - expect(loadRequests.map((request) => request.url)).toEqual([LOAD_PROD, LOAD_DAILY]); - - const firstHeaders = loadRequests[0]?.init?.headers; - expect(getHeaderValue(firstHeaders, "X-Goog-Api-Client")).toBe( - `gl-node/${process.versions.node}`, - ); - - const clientMetadata = requireString( - getHeaderValue(firstHeaders, "Client-Metadata"), - "Client-Metadata", - ); - expect(parseJsonString(clientMetadata, "Client-Metadata")).toEqual( - EXPECTED_LOAD_CODE_ASSIST_METADATA, - ); - - const loadBody = loadRequests[0]?.init?.body; - const body = parseJsonString(loadBody, "loadCodeAssist body"); - expect(body).toEqual({ - metadata: EXPECTED_LOAD_CODE_ASSIST_METADATA, - }); - }); - - it("keeps OAuth state separate from the PKCE verifier during manual login", async () => { - const { requests } = installGeminiOAuthFetchMock(({ url }) => { - if (url === LOAD_PROD) { - return responseJson({ - currentTier: { id: "standard-tier" }, - cloudaicompanionProject: { id: "prod-project" }, - }); - } - return undefined; - }); - - const { loginGeminiCliOAuth } = await import("./oauth.js"); - const { authUrl, notes } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); - - expect(notes).toContainEqual(expect.stringContaining(authUrl)); - - const authState = requireString(new URL(authUrl).searchParams.get("state"), "OAuth state"); - - const tokenRequest = requireRecordedRequest( - requests.find((request) => request.url === TOKEN_URL), - "token", - ); - const codeVerifier = requireString( - getFormField(tokenRequest.init?.body, "code_verifier"), - "PKCE code verifier", - ); - expect(codeVerifier).not.toBe(authState); - }); - - it("rejects manual callback input when the returned state does not match", async () => { - const { loginGeminiCliOAuth } = await import("./oauth.js"); - - await expect( - loginGeminiCliOAuth({ - isRemote: true, - openUrl: async () => {}, - log: () => {}, - note: async () => {}, - prompt: async () => - "http://localhost:8085/oauth2callback?code=oauth-code&state=wrong-state", - progress: { update: () => {}, stop: () => {} }, - }), - ).rejects.toThrow("OAuth state mismatch - please try again"); - }); - - it("rejects first login when project discovery fails and no stored identity exists", async () => { - const { requests } = installGeminiOAuthFetchMock(({ url }) => { - if ([LOAD_PROD, LOAD_DAILY, LOAD_AUTOPUSH].includes(url)) { - return responseJson({ error: { message: "unavailable" } }, 503); - } - return undefined; - }); - - const { exchangeCodeForTokens } = await import("./oauth.token.js"); - await expect(exchangeCodeForTokens("oauth-code", "pkce-verifier")).rejects.toThrow( - /loadCodeAssist failed/i, - ); - expect(requests.filter(({ url }) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); - }); - - it.each([ - [ - "exchange", - "x", - async () => - (await import("./oauth.token.js")).exchangeCodeForTokens("oauth-code", "pkce-verifier"), - ], - [ - "refresh", - "y", - async () => - (await import("./oauth.token.js")).refreshTokensForGeminiCli({ refresh: "refresh-token" }), - ], - ])("bounds token %s error bodies without using response.text()", async (_flow, fill, request) => { - const { response, text } = responseTextBodyWithTextTrap(fill.repeat(32 * 1024), 500); - installGeminiOAuthFetchMock(() => undefined, { tokenResponse: () => response }); - - const error = await request().catch((err: unknown) => err); - - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe(`Token exchange failed: ${fill.repeat(8 * 1024)}`); - expect(text).not.toHaveBeenCalled(); - }); - - it("falls back to GOOGLE_CLOUD_PROJECT when all loadCodeAssist endpoints fail", async () => { - process.env.GOOGLE_CLOUD_PROJECT = "env-project"; - - const { requests } = installGeminiOAuthFetchMock(({ url }) => { - if ([LOAD_PROD, LOAD_DAILY, LOAD_AUTOPUSH].includes(url)) { - return responseJson({ error: { message: "unavailable" } }, 503); - } - return undefined; - }); - - await runProjectDiscoveryExpectingProjectId("env-project"); - expect(countMatching(requests, ({ url }) => url.includes("v1internal:loadCodeAssist"))).toBe(3); - expect(countMatching(requests, ({ url }) => url.includes("v1internal:onboardUser"))).toBe(0); - }); - - it("skips loadCodeAssist entirely when Gemini CLI is configured for personal OAuth", async () => { - setGeminiPersonalOAuthSettings(); - - const { requests } = installGeminiOAuthFetchMock(() => undefined); - const { exchangeCodeForTokens } = await import("./oauth.token.js"); - const result = await exchangeCodeForTokens("oauth-code", "pkce-verifier"); - - expect(result.projectId).toBeUndefined(); - expect(requests.map(({ url }) => url)).toEqual([TOKEN_URL, USERINFO_URL]); - }); - - it("refreshes Gemini CLI OAuth tokens without loadCodeAssist in personal OAuth mode", async () => { - setGeminiPersonalOAuthSettings(); - - const { requests } = installGeminiOAuthFetchMock(() => undefined); - const { refreshTokensForGeminiCli } = await import("./oauth.token.js"); - const result = await refreshTokensForGeminiCli({ - refresh: "refresh-token", - email: "lobster@openclaw.ai", - }); - - expect(result).toMatchObject({ - access: "access-token", - refresh: "refresh-token", - email: "lobster@openclaw.ai", - projectId: undefined, - }); - expect(requests.map(({ url }) => url)).toEqual([TOKEN_URL, USERINFO_URL]); - }); - - it("keeps malformed token expiry values out of refreshed Gemini CLI credentials", async () => { - setGeminiPersonalOAuthSettings(); - - const beforeRefresh = Date.now(); - installGeminiOAuthFetchMock(() => undefined, { - tokenResponse: () => - responseJson({ - access_token: "access-token", - expires_in: Number.NaN, - }), - }); - const { refreshTokensForGeminiCli } = await import("./oauth.token.js"); - const result = await refreshTokensForGeminiCli({ - refresh: "refresh-token", - email: "lobster@openclaw.ai", - }); - - expect(Number.isFinite(result.expires)).toBe(true); - expect(result.expires).toBeLessThanOrEqual(beforeRefresh); - }); - - it("keeps invalid clocks out of refreshed Gemini CLI credential expiry", async () => { - setGeminiPersonalOAuthSettings(); - - installGeminiOAuthFetchMock(() => undefined, { - tokenResponse: () => - responseJson({ - access_token: "access-token", - expires_in: 3600, - }), - }); - const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN); - try { - const { refreshTokensForGeminiCli } = await import("./oauth.token.js"); - const result = await refreshTokensForGeminiCli({ - refresh: "refresh-token", - email: "lobster@openclaw.ai", - }); - - expect(result.expires).toBe(0); - } finally { - dateNow.mockRestore(); - } - }); - - it("keeps unsafe token expiry values out of refreshed Gemini CLI credentials", async () => { - setGeminiPersonalOAuthSettings(); - - const beforeRefresh = Date.now(); - installGeminiOAuthFetchMock(() => undefined, { - tokenResponse: () => - responseJson({ - access_token: "access-token", - expires_in: Number.MAX_SAFE_INTEGER, - }), - }); - const { refreshTokensForGeminiCli } = await import("./oauth.token.js"); - const result = await refreshTokensForGeminiCli({ - refresh: "refresh-token", - email: "lobster@openclaw.ai", - }); - - expect(Number.isSafeInteger(result.expires)).toBe(true); - expect(result.expires).toBeLessThanOrEqual(beforeRefresh); - }); - - it("rejects an oversized token exchange response body", async () => { - // End-to-end OAuth path: oversized upstream bodies fail closed before auth - // completes. After #97628 the shared fetchWithTimeout cap fires first; - // readProviderJsonResponse remains the labeled parse boundary afterward. - installGeminiOAuthFetchMock(() => undefined, { - tokenResponse: () => - oversizedJsonStringFieldResponse({ - prefix: '{"access_token":"', - suffix: '","refresh_token":"r","expires_in":3600}', - }), - }); - - const { exchangeCodeForTokens } = await import("./oauth.token.js"); - await expect(exchangeCodeForTokens("oauth-code", "pkce-verifier")).rejects.toThrow( - /google HTTP fetch: body exceeds|google\.token.*exceeds|Content too large/, - ); - }); - - it("rejects an oversized token body at the JSON parse boundary", async () => { - // Defense-in-depth: if fetchWithTimeout already returned a buffered Response, - // readProviderJsonResponse still caps JSON.parse on the OAuth token path. - vi.resetModules(); - const oauthHttp = await import("./oauth.http.js"); - const originalFetchWithTimeout = oauthHttp.fetchWithTimeout; - vi.spyOn(oauthHttp, "fetchWithTimeout").mockImplementation(async (url, init, timeoutMs) => { - if (url === TOKEN_URL) { - return oversizedJsonStringFieldResponse({ - prefix: '{"access_token":"', - suffix: '","refresh_token":"r","expires_in":3600}', - }); - } - return originalFetchWithTimeout(url, init, timeoutMs); - }); - installGeminiOAuthFetchMock(() => undefined); - - const { exchangeCodeForTokens } = await import("./oauth.token.js"); - await expect(exchangeCodeForTokens("oauth-code", "pkce-verifier")).rejects.toThrow( - /google\.token.*exceeds|Content too large/, - ); - }); - - it("rejects an oversized loadCodeAssist success response body", async () => { - // discoverProject loops over all 3 LOAD endpoints; each must return the - // oversized body so that bound errors propagate for the whole loop. - const oversizedResponse = () => - oversizedJsonStringFieldResponse({ - prefix: '{"currentTier":{"id":"standard-tier"},"cloudaicompanionProject":{"id":"', - suffix: '"}}', - }); - installGeminiOAuthFetchMock(({ url }) => { - if (url === LOAD_PROD || url === LOAD_DAILY || url === LOAD_AUTOPUSH) { - return oversizedResponse(); - } - return undefined; - }); - - const { resolveGoogleOAuthIdentity } = await import("./oauth.project.js"); - await expect(resolveGoogleOAuthIdentity("access-token")).rejects.toThrow( - /google HTTP fetch: body exceeds|google\.load-code-assist.*exceeds|Content too large/, - ); - }); - - it("swallows bound error on oversized userinfo body and returns undefined email", async () => { - // getUserEmail catches all errors; an oversized userinfo body should not - // propagate but email must be undefined. After #97628 the fetch cap may - // truncate the upstream body before parse, so the swallowed error can be - // either a labeled size cap or malformed JSON — either proves the bound fired. - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === USERINFO_URL) { - return oversizedJsonStringFieldResponse({ - prefix: '{"email":"', - suffix: '"}', - }); - } - if (url === LOAD_PROD) { - return new Response( - JSON.stringify({ - currentTier: { id: "standard-tier" }, - cloudaicompanionProject: { id: "proj-bound-test" }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - return new Response(JSON.stringify({ error: "not found" }), { status: 503 }); - }), - ); - - const { resolveGoogleOAuthIdentity } = await import("./oauth.project.js"); - const result = await resolveGoogleOAuthIdentity("access-token"); - expect(result.projectId).toBe("proj-bound-test"); - // email is undefined: the bound error was thrown and swallowed by getUserEmail - expect(result.email).toBeUndefined(); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/google/oauth.token.ts b/extensions/google/oauth.token.ts deleted file mode 100644 index 1202e98543bd..000000000000 --- a/extensions/google/oauth.token.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Google plugin module implements oauth.token behavior. -import { - asDateTimestampMs, - resolveExpiresAtMsFromDurationSeconds, -} from "openclaw/plugin-sdk/number-runtime"; -import { - readProviderJsonResponse, - readResponseTextLimited, -} from "openclaw/plugin-sdk/provider-http"; -import { resolveOAuthClientConfig } from "./oauth.credentials.js"; -import { fetchWithTimeout } from "./oauth.http.js"; -import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from "./oauth.project.js"; -import { isGeminiCliPersonalOAuth } from "./oauth.settings.js"; -import { REDIRECT_URI, TOKEN_URL, type GeminiCliOAuthCredentials } from "./oauth.shared.js"; - -const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; -const GOOGLE_OAUTH_TOKEN_ERROR_BODY_LIMIT_BYTES = 8 * 1024; - -async function requestTokenGrant( - body: URLSearchParams, - signal?: AbortSignal, -): Promise<{ - access_token?: string; - refresh_token?: string; - expires_in?: unknown; -}> { - const response = await fetchWithTimeout(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "*/*", - "User-Agent": "google-api-nodejs-client/9.15.1", - }, - body, - ...(signal ? { signal } : {}), - }); - - if (!response.ok) { - const errorText = await readResponseTextLimited( - response, - GOOGLE_OAUTH_TOKEN_ERROR_BODY_LIMIT_BYTES, - ); - throw new Error(`Token exchange failed: ${errorText}`); - } - - return readProviderJsonResponse<{ - access_token?: string; - refresh_token?: string; - expires_in?: unknown; - }>(response, "google.token"); -} - -function resolveExpiredTokenTimestampMs(nowMs: number): number { - return asDateTimestampMs(nowMs - TOKEN_EXPIRY_BUFFER_MS) ?? nowMs; -} - -function resolveTokenExpiresAt(value: unknown): number { - const nowMs = asDateTimestampMs(Date.now()); - if (nowMs === undefined) { - return 0; - } - return ( - resolveExpiresAtMsFromDurationSeconds(value, { nowMs, bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ?? - resolveExpiredTokenTimestampMs(nowMs) - ); -} - -async function buildGeminiCliCredentials(params: { - tokenResponse: { - access_token?: string; - refresh_token?: string; - expires_in?: unknown; - }; - refreshTokenFallback?: string; - existing?: Pick; - allowIdentityFallback?: boolean; - signal?: AbortSignal; -}): Promise { - const accessToken = params.tokenResponse.access_token; - if (!accessToken) { - throw new Error("No access token received. Please try again."); - } - - let identity: { email?: string; projectId?: string } = params.existing ?? {}; - try { - if (!identity.email || !identity.projectId) { - const discovered = await resolveGeminiCliIdentity(accessToken, params.signal); - identity = { - email: identity.email ?? discovered.email, - projectId: identity.projectId ?? discovered.projectId, - }; - } - } catch (error) { - if (!params.allowIdentityFallback || (!params.existing?.email && !params.existing?.projectId)) { - throw error; - } - // If identity discovery is temporarily unavailable during refresh, keep the - // already-stored identity binding instead of failing token renewal. - } - - const expiresAt = resolveTokenExpiresAt(params.tokenResponse.expires_in); - - return { - refresh: params.tokenResponse.refresh_token ?? params.refreshTokenFallback ?? "", - access: accessToken, - expires: expiresAt, - projectId: identity.projectId, - email: identity.email, - }; -} - -async function resolveGeminiCliIdentity( - accessToken: string, - signal?: AbortSignal, -): Promise<{ email?: string; projectId?: string }> { - return isGeminiCliPersonalOAuth() - ? await resolveGooglePersonalOAuthIdentity(accessToken, signal) - : await resolveGoogleOAuthIdentity(accessToken, signal); -} - -export async function exchangeCodeForTokens( - code: string, - verifier: string, - signal?: AbortSignal, -): Promise { - const { clientId, clientSecret } = resolveOAuthClientConfig(); - const body = new URLSearchParams({ - client_id: clientId, - code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }); - if (clientSecret) { - body.set("client_secret", clientSecret); - } - - const refreshed = await buildGeminiCliCredentials({ - tokenResponse: await requestTokenGrant(body, signal), - signal, - }); - if (!refreshed.refresh) { - throw new Error("No refresh token received. Please try again."); - } - return refreshed; -} - -export async function refreshTokensForGeminiCli(credentials: { - refresh: string; - email?: string; - projectId?: string; -}): Promise { - const { clientId, clientSecret } = resolveOAuthClientConfig(); - const body = new URLSearchParams({ - client_id: clientId, - grant_type: "refresh_token", - refresh_token: credentials.refresh, - }); - if (clientSecret) { - body.set("client_secret", clientSecret); - } - - return await buildGeminiCliCredentials({ - tokenResponse: await requestTokenGrant(body), - refreshTokenFallback: credentials.refresh, - existing: { - email: credentials.email, - projectId: credentials.projectId, - }, - allowIdentityFallback: true, - }); -} diff --git a/extensions/google/oauth.ts b/extensions/google/oauth.ts deleted file mode 100644 index 06c5f1a1d498..000000000000 --- a/extensions/google/oauth.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Google plugin module implements oauth behavior. -import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth"; -import { - buildAuthUrl, - generateOAuthState, - generatePkce, - parseCallbackInput, - shouldUseManualOAuthFlow, - waitForLocalCallback, -} from "./oauth.flow.js"; -import type { GeminiCliOAuthContext, GeminiCliOAuthCredentials } from "./oauth.shared.js"; -import { exchangeCodeForTokens, refreshTokensForGeminiCli } from "./oauth.token.js"; - -export async function loginGeminiCliOAuth( - ctx: GeminiCliOAuthContext, -): Promise { - const needsManual = shouldUseManualOAuthFlow(ctx.isRemote); - await ctx.note( - needsManual - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, copy the redirect URL and paste it back here.", - ].join("\n") - : [ - "Browser will open for Google authentication.", - "Sign in with your Google account for Gemini CLI access.", - "The callback will be captured automatically on localhost:8085.", - ].join("\n"), - "Gemini CLI OAuth", - ); - - const { verifier, challenge } = generatePkce(); - const state = generateOAuthState(); - const authUrl = buildAuthUrl(challenge, state); - - if (needsManual) { - return manualFlow(ctx, authUrl, state, verifier); - } - - ctx.progress.update("Complete sign-in in browser..."); - ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`); - try { - await ctx.openUrl(authUrl); - } catch { - // The URL is already visible; browser launch is best-effort. - } - - try { - const { code } = await waitForLocalCallback({ - expectedState: state, - timeoutMs: 5 * 60 * 1000, - onProgress: (msg) => ctx.progress.update(msg), - ...(ctx.signal ? { signal: ctx.signal } : {}), - }); - ctx.progress.update("Exchanging authorization code for tokens..."); - return await exchangeCodeForTokens(code, verifier, ctx.signal); - } catch (err) { - if ( - err instanceof Error && - (err.message.includes("EADDRINUSE") || - err.message.includes("port") || - err.message.includes("listen")) - ) { - ctx.progress.update("Local callback server failed. Switching to manual mode..."); - return manualFlow(ctx, authUrl, state, verifier, err); - } - throw err; - } -} - -async function manualFlow( - ctx: GeminiCliOAuthContext, - authUrl: string, - state: string, - verifier: string, - cause?: Error, -): Promise { - ctx.progress.update("OAuth URL ready"); - ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); - await ctx.openUrl(authUrl); - await ctx.note(`Open this URL in your LOCAL browser:\n\n${authUrl}`, "Gemini CLI OAuth"); - ctx.progress.update("Waiting for you to paste the callback URL..."); - const callbackInput = await ctx.prompt("Paste the redirect URL here: "); - const parsed = parseCallbackInput(callbackInput); - if ("error" in parsed) { - throw new Error(parsed.error, cause ? { cause } : undefined); - } - if (parsed.state !== state) { - throw new Error("OAuth state mismatch - please try again", cause ? { cause } : undefined); - } - ctx.progress.update("Exchanging authorization code for tokens..."); - return exchangeCodeForTokens(parsed.code, verifier, ctx.signal); -} - -export async function refreshGeminiCliOAuthToken( - credentials: Pick, -): Promise { - const refreshed = await refreshTokensForGeminiCli(credentials); - return { - type: "oauth", - provider: "google-gemini-cli", - ...refreshed, - }; -} diff --git a/src/agents/cli-runner/cli-backend-auth-policy.ts b/src/agents/cli-runner/cli-backend-auth-policy.ts index 06caaa66adce..dbae45fd34cd 100644 --- a/src/agents/cli-runner/cli-backend-auth-policy.ts +++ b/src/agents/cli-runner/cli-backend-auth-policy.ts @@ -6,6 +6,8 @@ export type BundledCliBackendAuthPolicy = { /** Disable profile fallback and fail closed when the selected profile cannot materialize. */ strictSelectedProfile: boolean; + /** Owner responsible for refreshing selected OAuth credentials before execution. */ + oauthRefreshOwner: "core" | "cli"; /** Provider whose imported OAuth profiles use identity-verified native passthrough. */ nativePassthroughProviderId?: string; }; @@ -13,9 +15,13 @@ export type BundledCliBackendAuthPolicy = { const BUNDLED_CLI_BACKEND_AUTH_POLICIES = { "claude-cli": { strictSelectedProfile: true, + oauthRefreshOwner: "core", nativePassthroughProviderId: "claude-cli", }, - "google-gemini-cli": { strictSelectedProfile: false }, + "google-gemini-cli": { + strictSelectedProfile: false, + oauthRefreshOwner: "cli", + }, } satisfies Record; export function resolveBundledCliBackendAuthPolicy( diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index df6877b0aa6c..aa1d45ad64fe 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -413,20 +413,16 @@ describe("prepareCliRunContext", () => { ); }); - it("passes raw refreshed OAuth profile fields to profile-owned CLI preparation", async () => { + it("passes expired Gemini CLI OAuth fields to CLI-owned refresh", async () => { const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "google-gemini-cli:user@example.test"; const prepareExecution = vi.fn(async () => ({ env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") }, })); - const resolveApiKeyForProfile = vi.fn(async () => ({ - apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }), - profileId: authProfileId, - profileType: "oauth" as const, - provider: "google-gemini-cli", - email: "user@example.test", - })); + const resolveApiKeyForProfile = vi.fn(async () => { + throw new Error("Gemini CLI OAuth must not enter core refresh"); + }); fs.mkdirSync(agentDir, { recursive: true }); saveAuthProfileStore( { @@ -437,7 +433,7 @@ describe("prepareCliRunContext", () => { provider: "google-gemini-cli", access: "raw-access-token", refresh: "raw-refresh-token", - expires: 1_800_000_000_000, + expires: 1, projectId: "project-1", email: "user@example.test", }, @@ -472,7 +468,7 @@ describe("prepareCliRunContext", () => { config: {}, }); - expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); + expect(resolveApiKeyForProfile).not.toHaveBeenCalled(); expect(prepareExecution).toHaveBeenCalledWith( expect.objectContaining({ authProfileId, @@ -481,7 +477,7 @@ describe("prepareCliRunContext", () => { provider: "google-gemini-cli", access: "raw-access-token", refresh: "raw-refresh-token", - expires: 1_800_000_000_000, + expires: 1, }), }), ); @@ -489,42 +485,28 @@ describe("prepareCliRunContext", () => { expect(context.authBindingSkipsLocalCredential).toBe(true); }); - it("stages the resolved OAuth fallback profile for Gemini CLI preparation", async () => { + it("still materializes selected API keys for Gemini CLI preparation", async () => { const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); - const legacyProfileId = "google-gemini-cli:default"; - const resolvedProfileId = "google-gemini-cli:user@example.test"; + const authProfileId = "google:api-key"; const prepareExecution = vi.fn(async () => ({ env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") }, })); const resolveApiKeyForProfile = vi.fn(async () => ({ - apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }), - profileId: resolvedProfileId, - profileType: "oauth" as const, - provider: "google-gemini-cli", - email: "user@example.test", + apiKey: "resolved-api-key", + profileId: authProfileId, + profileType: "api_key" as const, + provider: "google", })); fs.mkdirSync(agentDir, { recursive: true }); saveAuthProfileStore( { version: 1, profiles: { - [legacyProfileId]: { - type: "oauth", - provider: "google-gemini-cli", - access: "stale-access-token", - refresh: "stale-refresh-token", - expires: 1_700_000_000_000, - email: "legacy@example.test", - }, - [resolvedProfileId]: { - type: "oauth", - provider: "google-gemini-cli", - access: "resolved-access-token", - refresh: "resolved-refresh-token", - expires: 1_800_000_000_000, - projectId: "project-1", - email: "user@example.test", + [authProfileId]: { + type: "api_key", + provider: "google", + key: "stored-api-key", }, }, }, @@ -552,20 +534,18 @@ describe("prepareCliRunContext", () => { sessionKey: "agent:main:main", provider: "google-gemini-cli", model: "gemini-3.1-pro-preview", - authProfileId: legacyProfileId, + authProfileId, config: {}, }); expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); expect(prepareExecution).toHaveBeenCalledWith( expect.objectContaining({ - authProfileId: resolvedProfileId, + authProfileId, authCredential: expect.objectContaining({ - type: "oauth", - provider: "google-gemini-cli", - access: "resolved-access-token", - refresh: "resolved-refresh-token", - expires: 1_800_000_000_000, + type: "api_key", + provider: "google", + key: "resolved-api-key", }), }), ); @@ -578,13 +558,9 @@ describe("prepareCliRunContext", () => { const prepareExecution = vi.fn(async () => ({ env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") }, })); - const resolveApiKeyForProfile = vi.fn(async () => ({ - apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }), - profileId: authProfileId, - profileType: "oauth" as const, - provider: "google-gemini-cli", - email: "user@example.test", - })); + const resolveApiKeyForProfile = vi.fn(async () => { + throw new Error("Gemini CLI OAuth must not enter core refresh"); + }); fs.mkdirSync(agentDir, { recursive: true }); saveAuthProfileStore( { @@ -638,12 +614,7 @@ describe("prepareCliRunContext", () => { } as OpenClawConfig, }); - expect(resolveApiKeyForProfile).toHaveBeenCalledWith( - expect.objectContaining({ - profileId: authProfileId, - agentDir, - }), - ); + expect(resolveApiKeyForProfile).not.toHaveBeenCalled(); expect(prepareExecution).toHaveBeenCalledWith( expect.objectContaining({ authProfileId, @@ -658,88 +629,6 @@ describe("prepareCliRunContext", () => { ); }); - it("stages adopted OAuth credentials for Gemini CLI preparation", async () => { - const { dir } = fixture.session; - const agentDir = path.join(dir, "agents", "main", "agent"); - const authProfileId = "google-gemini-cli:user@example.test"; - const prepareExecution = vi.fn(async () => ({ - env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") }, - })); - const resolveApiKeyForProfile = vi.fn(async () => ({ - apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }), - profileId: authProfileId, - profileType: "oauth" as const, - provider: "google-gemini-cli", - email: "user@example.test", - credential: { - type: "oauth" as const, - provider: "google-gemini-cli", - access: "adopted-access-token", - refresh: "adopted-refresh-token", - expires: 1_900_000_000_000, - projectId: "project-1", - email: "user@example.test", - }, - })); - fs.mkdirSync(agentDir, { recursive: true }); - saveAuthProfileStore( - { - version: 1, - profiles: { - [authProfileId]: { - type: "oauth", - provider: "google-gemini-cli", - access: "stale-access-token", - refresh: "stale-refresh-token", - expires: 1_700_000_000_000, - projectId: "project-1", - email: "user@example.test", - }, - }, - }, - agentDir, - ); - setRawCliBackendForPrepareTest({ - id: "google-gemini-cli", - pluginId: "google", - bundleMcp: false, - authEpochMode: "profile-only", - prepareExecution, - config: { - command: "gemini", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }); - setCliRunnerPrepareTestDeps({ - resolveApiKeyForProfile, - }); - - await fixture.prepare({ - sessionKey: "agent:main:main", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - authProfileId, - config: {}, - }); - - expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - authProfileId, - authCredential: expect.objectContaining({ - type: "oauth", - provider: "google-gemini-cli", - access: "adopted-access-token", - refresh: "adopted-refresh-token", - expires: 1_900_000_000_000, - }), - }), - ); - }); - it("does not expose auth profile credentials to non-bundled prepare hooks", async () => { const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 7f7c5dbcc82d..14f23e23e8db 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -344,13 +344,13 @@ function shouldRefreshAuthProfileForExecution(params: { authProfileId?: string; authCredential?: AuthProfileCredential; }): boolean { - return Boolean( - params.policy && - params.authProfileId && - (params.authCredential?.type === "oauth" || - params.authCredential?.type === "api_key" || - params.authCredential?.type === "token"), - ); + if (!params.policy || !params.authProfileId || !params.authCredential) { + return false; + } + if (params.authCredential.type === "oauth") { + return params.policy.oauthRefreshOwner === "core"; + } + return params.authCredential.type === "api_key" || params.authCredential.type === "token"; } type CliAuthProfileResolutionFailure = From 45c2d5911acb96f4c6e23a3bb4b4d75e85bddfef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 20:36:52 -0700 Subject: [PATCH 14/15] refactor(i18n): remove unused Apple contradiction report (#117182) --- .../workflows/native-app-locale-refresh.yml | 1 - .../apple-translation-contradictions.json | 11069 ---------------- scripts/apple-app-i18n.ts | 22 +- scripts/ci-changed-scope.mjs | 2 +- .../ci-changed-scope.native-i18n.test.ts | 1 - test/scripts/ci-workflow-guards.test.ts | 1 - 6 files changed, 4 insertions(+), 11092 deletions(-) delete mode 100644 apps/.i18n/apple-translation-contradictions.json diff --git a/.github/workflows/native-app-locale-refresh.yml b/.github/workflows/native-app-locale-refresh.yml index 9f6bc1264409..4d688b4f8071 100644 --- a/.github/workflows/native-app-locale-refresh.yml +++ b/.github/workflows/native-app-locale-refresh.yml @@ -307,7 +307,6 @@ jobs: auto-merge: "true" generated-paths: | apps/.i18n/native - apps/.i18n/apple-translation-contradictions.json apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt apps/android/app/src/main/res/values*/assistant.xml apps/android/app/src/main/res/values*/strings.xml diff --git a/apps/.i18n/apple-translation-contradictions.json b/apps/.i18n/apple-translation-contradictions.json deleted file mode 100644 index 2784d90fb093..000000000000 --- a/apps/.i18n/apple-translation-contradictions.json +++ /dev/null @@ -1,11069 +0,0 @@ -{ - "version": 1, - "contradictions": [ - { - "locale": "ja-JP", - "source": "%@ (request ID: %@)", - "translations": [ - "%@(リクエストID: %@)", - "%@(リクエストID:%@)" - ] - }, - { - "locale": "zh-TW", - "source": "%@ (request ID: %@)", - "translations": [ - "%@(要求 ID:%@)", - "%@(請求 ID:%@)" - ] - }, - { - "locale": "ar", - "source": "%@ permission denied", - "translations": [ - "تم رفض إذن %@", - "رُفض إذن %@" - ] - }, - { - "locale": "it", - "source": "%@ permission denied", - "translations": [ - "Autorizzazione %@ negata", - "Autorizzazione per %@ negata" - ] - }, - { - "locale": "nl", - "source": "%@ permission denied", - "translations": [ - "%@-toestemming geweigerd", - "Toestemming voor %@ geweigerd" - ] - }, - { - "locale": "pl", - "source": "%@ permission denied", - "translations": [ - "Odmówiono uprawnienia %@", - "Odmówiono uprawnienia: %@" - ] - }, - { - "locale": "ru", - "source": "%@ permission denied", - "translations": [ - "В разрешении для %@ отказано", - "В разрешении на %@ отказано" - ] - }, - { - "locale": "sv", - "source": "%@ permission denied", - "translations": [ - "Behörighet för %@ nekades", - "Behörigheten för %@ nekades" - ] - }, - { - "locale": "th", - "source": "%@ permission denied", - "translations": [ - "สิทธิ์ %@ ถูกปฏิเสธ", - "สิทธิ์เข้าถึง %@ ถูกปฏิเสธ" - ] - }, - { - "locale": "ar", - "source": "%@ permission not granted", - "translations": [ - "لم يتم منح إذن %@", - "لم يُمنح إذن %@" - ] - }, - { - "locale": "it", - "source": "%@ permission not granted", - "translations": [ - "Autorizzazione %@ non concessa", - "Autorizzazione per %@ non concessa" - ] - }, - { - "locale": "nl", - "source": "%@ permission not granted", - "translations": [ - "%@-toestemming niet verleend", - "Toestemming voor %@ niet verleend" - ] - }, - { - "locale": "pl", - "source": "%@ permission not granted", - "translations": [ - "Nie przyznano uprawnienia %@", - "Nie przyznano uprawnienia: %@" - ] - }, - { - "locale": "ru", - "source": "%@ permission not granted", - "translations": [ - "Разрешение для %@ не предоставлено", - "Разрешение на %@ не предоставлено" - ] - }, - { - "locale": "sv", - "source": "%@ permission not granted", - "translations": [ - "Behörighet för %@ har inte beviljats", - "Behörigheten för %@ har inte beviljats" - ] - }, - { - "locale": "th", - "source": "%@ permission not granted", - "translations": [ - "ยังไม่ได้ให้สิทธิ์ %@", - "ยังไม่ได้ให้สิทธิ์เข้าถึง %@" - ] - }, - { - "locale": "tr", - "source": "%@ permission not granted", - "translations": [ - "%@ izni verilmedi", - "%@ izni verilmemiş" - ] - }, - { - "locale": "it", - "source": "%@ permission restricted", - "translations": [ - "Autorizzazione %@ soggetta a restrizioni", - "Autorizzazione per %@ soggetta a restrizioni" - ] - }, - { - "locale": "nl", - "source": "%@ permission restricted", - "translations": [ - "%@-toestemming beperkt", - "Toestemming voor %@ beperkt" - ] - }, - { - "locale": "pl", - "source": "%@ permission restricted", - "translations": [ - "Ograniczone uprawnienie: %@", - "Uprawnienie %@ jest ograniczone" - ] - }, - { - "locale": "ru", - "source": "%@ permission restricted", - "translations": [ - "Разрешение для %@ ограничено", - "Разрешение на %@ ограничено" - ] - }, - { - "locale": "th", - "source": "%@ permission restricted", - "translations": [ - "สิทธิ์ %@ ถูกจำกัด", - "สิทธิ์เข้าถึง %@ ถูกจำกัด" - ] - }, - { - "locale": "tr", - "source": "%@ permission restricted", - "translations": [ - "%@ izni kısıtlandı", - "%@ izni kısıtlanmış" - ] - }, - { - "locale": "zh-TW", - "source": "%@ permission restricted", - "translations": [ - "%@ 權限受到限制", - "%@ 權限受限制" - ] - }, - { - "locale": "ja-JP", - "source": "%@ tokens", - "translations": [ - "%@ トークン", - "%@トークン" - ] - }, - { - "locale": "ko", - "source": "%@ tokens", - "translations": [ - "%@개 토큰", - "토큰 %@개" - ] - }, - { - "locale": "sv", - "source": "%@ tokens", - "translations": [ - "%@ token", - "%@ tokens" - ] - }, - { - "locale": "tr", - "source": "%@ tokens", - "translations": [ - "%@ jeton", - "%@ token" - ] - }, - { - "locale": "zh-CN", - "source": "%@ tokens", - "translations": [ - "%@ 个 token", - "%@ 个令牌" - ] - }, - { - "locale": "ar", - "source": "Active", - "translations": [ - "نشط", - "نشطة" - ] - }, - { - "locale": "es", - "source": "Active", - "translations": [ - "Activas", - "Activo" - ] - }, - { - "locale": "fr", - "source": "Active", - "translations": [ - "Actif", - "Actives" - ] - }, - { - "locale": "it", - "source": "Active", - "translations": [ - "Attiva", - "Attive", - "Attivo" - ] - }, - { - "locale": "ja-JP", - "source": "Active", - "translations": [ - "アクティブ", - "有効" - ] - }, - { - "locale": "pt-BR", - "source": "Active", - "translations": [ - "Ativas", - "Ativo" - ] - }, - { - "locale": "ru", - "source": "Active", - "translations": [ - "Активно", - "Активные" - ] - }, - { - "locale": "tr", - "source": "Active", - "translations": [ - "Aktif", - "Etkin" - ] - }, - { - "locale": "uk", - "source": "Active", - "translations": [ - "Активно", - "Активні" - ] - }, - { - "locale": "zh-CN", - "source": "Active", - "translations": [ - "活跃", - "进行中" - ] - }, - { - "locale": "zh-TW", - "source": "Active", - "translations": [ - "使用中", - "啟用中", - "進行中" - ] - }, - { - "locale": "fa", - "source": "Address", - "translations": [ - "آدرس", - "نشانی" - ] - }, - { - "locale": "th", - "source": "Agent", - "translations": [ - "Agent", - "เอเจนต์" - ] - }, - { - "locale": "zh-CN", - "source": "Agent", - "translations": [ - "Agent", - "代理" - ] - }, - { - "locale": "zh-TW", - "source": "Agent", - "translations": [ - "Agent", - "代理程式" - ] - }, - { - "locale": "de", - "source": "Agents", - "translations": [ - "Agenten", - "Agents" - ] - }, - { - "locale": "nl", - "source": "Agents", - "translations": [ - "Agenten", - "Agents" - ] - }, - { - "locale": "th", - "source": "Agents", - "translations": [ - "Agents", - "เอเจนต์" - ] - }, - { - "locale": "vi", - "source": "Agents", - "translations": [ - "Tác nhân", - "Tác tử" - ] - }, - { - "locale": "zh-CN", - "source": "Agents", - "translations": [ - "代理", - "智能体" - ] - }, - { - "locale": "zh-TW", - "source": "Agents", - "translations": [ - "代理", - "代理程式" - ] - }, - { - "locale": "es", - "source": "All", - "translations": [ - "Todas", - "Todo" - ] - }, - { - "locale": "fr", - "source": "All", - "translations": [ - "Tous", - "Tout" - ] - }, - { - "locale": "it", - "source": "All", - "translations": [ - "Tutte", - "Tutti" - ] - }, - { - "locale": "ko", - "source": "All", - "translations": [ - "모두", - "전체" - ] - }, - { - "locale": "nl", - "source": "All", - "translations": [ - "Alle", - "Alles" - ] - }, - { - "locale": "pl", - "source": "All", - "translations": [ - "Wszystkie", - "Wszystko" - ] - }, - { - "locale": "pt-BR", - "source": "All", - "translations": [ - "Todos", - "Tudo" - ] - }, - { - "locale": "ar", - "source": "Allow", - "translations": [ - "السماح", - "سماح" - ] - }, - { - "locale": "tr", - "source": "Allow", - "translations": [ - "İzin Ver", - "İzin ver" - ] - }, - { - "locale": "de", - "source": "Allow Always", - "translations": [ - "Immer erlauben", - "Immer zulassen" - ] - }, - { - "locale": "pl", - "source": "Allow Always", - "translations": [ - "Zawsze zezwalaj", - "Zezwól zawsze" - ] - }, - { - "locale": "ru", - "source": "Allow Always", - "translations": [ - "Разрешать всегда", - "Разрешить всегда" - ] - }, - { - "locale": "uk", - "source": "Allow Always", - "translations": [ - "Дозволити завжди", - "Дозволяти завжди" - ] - }, - { - "locale": "zh-TW", - "source": "Allow Always", - "translations": [ - "一律允許", - "永遠允許" - ] - }, - { - "locale": "de", - "source": "Allow Once", - "translations": [ - "Einmal erlauben", - "Einmal zulassen" - ] - }, - { - "locale": "ja-JP", - "source": "Allow Once", - "translations": [ - "1回だけ許可", - "1回のみ許可", - "今回のみ許可" - ] - }, - { - "locale": "ko", - "source": "Allow Once", - "translations": [ - "한 번 허용", - "한 번만 허용" - ] - }, - { - "locale": "nl", - "source": "Allow Once", - "translations": [ - "Eenmalig toestaan", - "Eén keer toestaan" - ] - }, - { - "locale": "pt-BR", - "source": "Allow Once", - "translations": [ - "Permitir Uma Vez", - "Permitir uma vez" - ] - }, - { - "locale": "uk", - "source": "Allow Once", - "translations": [ - "Дозволити один раз", - "Дозволити раз" - ] - }, - { - "locale": "zh-CN", - "source": "Allow Once", - "translations": [ - "仅允许一次", - "允许一次" - ] - }, - { - "locale": "zh-TW", - "source": "Allow Once", - "translations": [ - "僅允許一次", - "允許一次" - ] - }, - { - "locale": "ja-JP", - "source": "Always", - "translations": [ - "常に", - "常に許可" - ] - }, - { - "locale": "th", - "source": "Always", - "translations": [ - "ตลอดเวลา", - "เสมอ" - ] - }, - { - "locale": "zh-TW", - "source": "Always", - "translations": [ - "永遠", - "永遠允許" - ] - }, - { - "locale": "sv", - "source": "Apply", - "translations": [ - "Tillämpa", - "Verkställ" - ] - }, - { - "locale": "hi", - "source": "Approval", - "translations": [ - "अनुमोदन", - "मंज़ूरी" - ] - }, - { - "locale": "uk", - "source": "Approval", - "translations": [ - "Підтвердження", - "Схвалення" - ] - }, - { - "locale": "fa", - "source": "Approval needed", - "translations": [ - "تأیید لازم است", - "نیاز به تأیید" - ] - }, - { - "locale": "fr", - "source": "Approval needed", - "translations": [ - "Approbation nécessaire", - "Approbation requise" - ] - }, - { - "locale": "hi", - "source": "Approval needed", - "translations": [ - "अनुमोदन आवश्यक", - "स्वीकृति आवश्यक" - ] - }, - { - "locale": "nl", - "source": "Approval needed", - "translations": [ - "Goedkeuring nodig", - "Goedkeuring vereist" - ] - }, - { - "locale": "th", - "source": "Approval needed", - "translations": [ - "ต้องการการอนุมัติ", - "ต้องได้รับการอนุมัติ" - ] - }, - { - "locale": "tr", - "source": "Approval needed", - "translations": [ - "Onay gerekiyor", - "Onay gerekli" - ] - }, - { - "locale": "zh-CN", - "source": "Approval needed", - "translations": [ - "需要审批", - "需要批准" - ] - }, - { - "locale": "ko", - "source": "Approval requested", - "translations": [ - "승인 요청됨", - "승인이 요청됨" - ] - }, - { - "locale": "ru", - "source": "Approval requested", - "translations": [ - "Одобрение запрошено", - "Разрешение запрошено" - ] - }, - { - "locale": "zh-TW", - "source": "Approval requested", - "translations": [ - "已要求核准", - "已請求核准" - ] - }, - { - "locale": "hi", - "source": "Approvals", - "translations": [ - "अनुमोदन", - "मंज़ूरियाँ" - ] - }, - { - "locale": "ru", - "source": "Approvals", - "translations": [ - "Одобрения", - "Подтверждения" - ] - }, - { - "locale": "hi", - "source": "Archive", - "translations": [ - "आर्काइव करें", - "संग्रहित करें" - ] - }, - { - "locale": "id", - "source": "Archive", - "translations": [ - "Arsip", - "Arsipkan" - ] - }, - { - "locale": "it", - "source": "Archive", - "translations": [ - "Archivia", - "Archivio" - ] - }, - { - "locale": "ko", - "source": "Archive", - "translations": [ - "보관", - "아카이브" - ] - }, - { - "locale": "nl", - "source": "Archive", - "translations": [ - "Archiveer", - "Archiveren" - ] - }, - { - "locale": "ru", - "source": "Archive", - "translations": [ - "Архив", - "Архивировать" - ] - }, - { - "locale": "sv", - "source": "Archive", - "translations": [ - "Arkiv", - "Arkivera" - ] - }, - { - "locale": "ar", - "source": "Archived", - "translations": [ - "مؤرشف", - "مؤرشفة" - ] - }, - { - "locale": "es", - "source": "Archived", - "translations": [ - "Archivada", - "Archivadas" - ] - }, - { - "locale": "fr", - "source": "Archived", - "translations": [ - "Archivé", - "Archivée" - ] - }, - { - "locale": "it", - "source": "Archived", - "translations": [ - "Archiviata", - "Archiviato" - ] - }, - { - "locale": "pl", - "source": "Archived", - "translations": [ - "Zarchiwizowana", - "Zarchiwizowane" - ] - }, - { - "locale": "th", - "source": "Archived", - "translations": [ - "เก็บถาวรแล้ว", - "เก็บเข้าคลังแล้ว" - ] - }, - { - "locale": "tr", - "source": "Archived", - "translations": [ - "Arşivlendi", - "Arşivlenmiş" - ] - }, - { - "locale": "fr", - "source": "Asking OpenClaw", - "translations": [ - "Demande à OpenClaw", - "Interrogation d’OpenClaw" - ] - }, - { - "locale": "id", - "source": "Asking OpenClaw", - "translations": [ - "Bertanya kepada OpenClaw", - "Meminta OpenClaw" - ] - }, - { - "locale": "ja-JP", - "source": "Asking OpenClaw", - "translations": [ - "OpenClaw に問い合わせ中", - "OpenClawに問い合わせ中" - ] - }, - { - "locale": "ko", - "source": "Asking OpenClaw", - "translations": [ - "OpenClaw에 문의 중", - "OpenClaw에 요청 중" - ] - }, - { - "locale": "pl", - "source": "Asking OpenClaw", - "translations": [ - "Pytanie OpenClaw", - "Pytanie do OpenClaw" - ] - }, - { - "locale": "tr", - "source": "Asking OpenClaw", - "translations": [ - "OpenClaw'a soruluyor", - "OpenClaw’a soruluyor" - ] - }, - { - "locale": "ar", - "source": "Attachment", - "translations": [ - "Attachment", - "مرفق" - ] - }, - { - "locale": "de", - "source": "Attachment", - "translations": [ - "Anhang", - "Attachment" - ] - }, - { - "locale": "es", - "source": "Attachment", - "translations": [ - "Archivo adjunto", - "Attachment" - ] - }, - { - "locale": "fa", - "source": "Attachment", - "translations": [ - "Attachment", - "پیوست" - ] - }, - { - "locale": "fr", - "source": "Attachment", - "translations": [ - "Attachment", - "Pièce jointe" - ] - }, - { - "locale": "hi", - "source": "Attachment", - "translations": [ - "Attachment", - "अटैचमेंट" - ] - }, - { - "locale": "id", - "source": "Attachment", - "translations": [ - "Attachment", - "Lampiran" - ] - }, - { - "locale": "it", - "source": "Attachment", - "translations": [ - "Allegato", - "Attachment" - ] - }, - { - "locale": "ja-JP", - "source": "Attachment", - "translations": [ - "Attachment", - "添付ファイル" - ] - }, - { - "locale": "ko", - "source": "Attachment", - "translations": [ - "Attachment", - "첨부 파일" - ] - }, - { - "locale": "nl", - "source": "Attachment", - "translations": [ - "Attachment", - "Bijlage" - ] - }, - { - "locale": "pl", - "source": "Attachment", - "translations": [ - "Attachment", - "Załącznik" - ] - }, - { - "locale": "pt-BR", - "source": "Attachment", - "translations": [ - "Anexo", - "Attachment" - ] - }, - { - "locale": "ru", - "source": "Attachment", - "translations": [ - "Attachment", - "Вложение" - ] - }, - { - "locale": "sv", - "source": "Attachment", - "translations": [ - "Attachment", - "Bilaga" - ] - }, - { - "locale": "th", - "source": "Attachment", - "translations": [ - "Attachment", - "ไฟล์แนบ" - ] - }, - { - "locale": "tr", - "source": "Attachment", - "translations": [ - "Attachment", - "Ek" - ] - }, - { - "locale": "uk", - "source": "Attachment", - "translations": [ - "Attachment", - "Вкладення" - ] - }, - { - "locale": "vi", - "source": "Attachment", - "translations": [ - "Attachment", - "Tệp đính kèm" - ] - }, - { - "locale": "zh-CN", - "source": "Attachment", - "translations": [ - "Attachment", - "附件" - ] - }, - { - "locale": "zh-TW", - "source": "Attachment", - "translations": [ - "Attachment", - "附件" - ] - }, - { - "locale": "ar", - "source": "Attention", - "translations": [ - "تنبيه", - "يتطلب الانتباه" - ] - }, - { - "locale": "de", - "source": "Attention", - "translations": [ - "Achtung", - "Aufmerksamkeit erforderlich" - ] - }, - { - "locale": "id", - "source": "Attention", - "translations": [ - "Perhatian", - "Perlu perhatian" - ] - }, - { - "locale": "ko", - "source": "Attention", - "translations": [ - "주의", - "주의 필요" - ] - }, - { - "locale": "pl", - "source": "Attention", - "translations": [ - "Uwaga", - "Wymaga uwagi" - ] - }, - { - "locale": "ru", - "source": "Attention", - "translations": [ - "Внимание", - "Требует внимания" - ] - }, - { - "locale": "sv", - "source": "Attention", - "translations": [ - "Kräver uppmärksamhet", - "Åtgärd krävs" - ] - }, - { - "locale": "th", - "source": "Attention", - "translations": [ - "ต้องดำเนินการ", - "ต้องตรวจสอบ" - ] - }, - { - "locale": "uk", - "source": "Attention", - "translations": [ - "Потребує уваги", - "Увага" - ] - }, - { - "locale": "zh-CN", - "source": "Attention", - "translations": [ - "注意", - "需要注意" - ] - }, - { - "locale": "hi", - "source": "Automations", - "translations": [ - "ऑटोमेशन", - "स्वचालन" - ] - }, - { - "locale": "ja-JP", - "source": "Automations", - "translations": [ - "オートメーション", - "自動化" - ] - }, - { - "locale": "sv", - "source": "Automations", - "translations": [ - "Automationer", - "Automatiseringar" - ] - }, - { - "locale": "th", - "source": "Automations", - "translations": [ - "การทำงานอัตโนมัติ", - "ระบบอัตโนมัติ" - ] - }, - { - "locale": "vi", - "source": "Automations", - "translations": [ - "Tác vụ tự động", - "Tự động hóa" - ] - }, - { - "locale": "es", - "source": "Blocked", - "translations": [ - "Bloqueada", - "Bloqueado" - ] - }, - { - "locale": "fa", - "source": "Blocked", - "translations": [ - "مسدود", - "مسدود شده" - ] - }, - { - "locale": "it", - "source": "Blocked", - "translations": [ - "Bloccata", - "Bloccato" - ] - }, - { - "locale": "pl", - "source": "Blocked", - "translations": [ - "Zablokowana", - "Zablokowane" - ] - }, - { - "locale": "pt-BR", - "source": "Blocked", - "translations": [ - "Bloqueada", - "Bloqueado" - ] - }, - { - "locale": "vi", - "source": "Blocked", - "translations": [ - "Bị chặn", - "Đã chặn" - ] - }, - { - "locale": "pl", - "source": "Camera", - "translations": [ - "Aparat", - "Kamera" - ] - }, - { - "locale": "vi", - "source": "Camera", - "translations": [ - "Camera", - "Máy ảnh" - ] - }, - { - "locale": "zh-CN", - "source": "Camera", - "translations": [ - "摄像头", - "相机" - ] - }, - { - "locale": "nl", - "source": "Cancel", - "translations": [ - "Annuleer", - "Annuleren" - ] - }, - { - "locale": "ar", - "source": "Chat", - "translations": [ - "Chat", - "الدردشة", - "دردشة" - ] - }, - { - "locale": "fa", - "source": "Chat", - "translations": [ - "Chat", - "گفت‌وگو" - ] - }, - { - "locale": "fr", - "source": "Chat", - "translations": [ - "Chat", - "Discussion" - ] - }, - { - "locale": "hi", - "source": "Chat", - "translations": [ - "Chat", - "चैट" - ] - }, - { - "locale": "id", - "source": "Chat", - "translations": [ - "Chat", - "Obrolan" - ] - }, - { - "locale": "ja-JP", - "source": "Chat", - "translations": [ - "Chat", - "チャット" - ] - }, - { - "locale": "ko", - "source": "Chat", - "translations": [ - "Chat", - "채팅" - ] - }, - { - "locale": "pl", - "source": "Chat", - "translations": [ - "Chat", - "Czat" - ] - }, - { - "locale": "ru", - "source": "Chat", - "translations": [ - "Chat", - "Чат" - ] - }, - { - "locale": "sv", - "source": "Chat", - "translations": [ - "Chat", - "Chatt" - ] - }, - { - "locale": "th", - "source": "Chat", - "translations": [ - "Chat", - "แชท" - ] - }, - { - "locale": "tr", - "source": "Chat", - "translations": [ - "Chat", - "Sohbet" - ] - }, - { - "locale": "uk", - "source": "Chat", - "translations": [ - "Chat", - "Чат" - ] - }, - { - "locale": "vi", - "source": "Chat", - "translations": [ - "Chat", - "Trò chuyện" - ] - }, - { - "locale": "zh-CN", - "source": "Chat", - "translations": [ - "Chat", - "聊天" - ] - }, - { - "locale": "zh-TW", - "source": "Chat", - "translations": [ - "Chat", - "聊天" - ] - }, - { - "locale": "hi", - "source": "Collapsed", - "translations": [ - "संकुचित", - "संक्षिप्त" - ] - }, - { - "locale": "id", - "source": "Collapsed", - "translations": [ - "Ciut", - "Diciutkan" - ] - }, - { - "locale": "ja-JP", - "source": "Collapsed", - "translations": [ - "折りたたみ", - "折りたたみ済み" - ] - }, - { - "locale": "ko", - "source": "Collapsed", - "translations": [ - "접힘", - "축소됨" - ] - }, - { - "locale": "sv", - "source": "Collapsed", - "translations": [ - "Ihopfälld", - "Komprimerad" - ] - }, - { - "locale": "th", - "source": "Collapsed", - "translations": [ - "ยุบอยู่", - "ยุบแล้ว" - ] - }, - { - "locale": "tr", - "source": "Collapsed", - "translations": [ - "Daraltıldı", - "Daraltılmış" - ] - }, - { - "locale": "vi", - "source": "Collapsed", - "translations": [ - "Thu gọn", - "Đã thu gọn" - ] - }, - { - "locale": "zh-CN", - "source": "Collapsed", - "translations": [ - "已折叠", - "已收起" - ] - }, - { - "locale": "zh-TW", - "source": "Command", - "translations": [ - "命令", - "指令" - ] - }, - { - "locale": "ar", - "source": "Confirmation needed", - "translations": [ - "التأكيد مطلوب", - "يلزم التأكيد" - ] - }, - { - "locale": "fa", - "source": "Confirmation needed", - "translations": [ - "تأیید لازم است", - "نیاز به تأیید" - ] - }, - { - "locale": "th", - "source": "Confirmation needed", - "translations": [ - "ต้องการการยืนยัน", - "ต้องยืนยัน" - ] - }, - { - "locale": "tr", - "source": "Confirmation needed", - "translations": [ - "Onay gerekiyor", - "Onay gerekli" - ] - }, - { - "locale": "fr", - "source": "Connect", - "translations": [ - "Connecter", - "Se connecter" - ] - }, - { - "locale": "id", - "source": "Connect", - "translations": [ - "Hubungkan", - "Sambungkan" - ] - }, - { - "locale": "ru", - "source": "Connect", - "translations": [ - "Подключить", - "Подключиться" - ] - }, - { - "locale": "uk", - "source": "Connect", - "translations": [ - "Підключити", - "Підключитися" - ] - }, - { - "locale": "ar", - "source": "Connect to the gateway.", - "translations": [ - "اتصل بـ Gateway.", - "الاتصال بـ Gateway." - ] - }, - { - "locale": "es", - "source": "Connect to the gateway.", - "translations": [ - "Conéctate al Gateway.", - "Conéctate al gateway." - ] - }, - { - "locale": "fa", - "source": "Connect to the gateway.", - "translations": [ - "به Gateway متصل شوید.", - "به gateway متصل شوید." - ] - }, - { - "locale": "it", - "source": "Connect to the gateway.", - "translations": [ - "Connettiti al Gateway.", - "Connettiti al gateway." - ] - }, - { - "locale": "ja-JP", - "source": "Connect to the gateway.", - "translations": [ - "Gateway に接続してください。", - "Gatewayに接続します。" - ] - }, - { - "locale": "nl", - "source": "Connect to the gateway.", - "translations": [ - "Maak verbinding met de Gateway.", - "Verbind met de gateway." - ] - }, - { - "locale": "sv", - "source": "Connect to the gateway.", - "translations": [ - "Anslut till gateway.", - "Anslut till gatewayen." - ] - }, - { - "locale": "th", - "source": "Connect to the gateway.", - "translations": [ - "เชื่อมต่อกับ Gateway", - "เชื่อมต่อกับ gateway" - ] - }, - { - "locale": "zh-TW", - "source": "Connect to the gateway.", - "translations": [ - "連線至 Gateway。", - "連線至 gateway。" - ] - }, - { - "locale": "ar", - "source": "Connected", - "translations": [ - "Connected", - "متصل" - ] - }, - { - "locale": "de", - "source": "Connected", - "translations": [ - "Connected", - "Verbunden" - ] - }, - { - "locale": "es", - "source": "Connected", - "translations": [ - "Conectado", - "Connected" - ] - }, - { - "locale": "fa", - "source": "Connected", - "translations": [ - "Connected", - "متصل" - ] - }, - { - "locale": "fr", - "source": "Connected", - "translations": [ - "Connected", - "Connecté" - ] - }, - { - "locale": "hi", - "source": "Connected", - "translations": [ - "Connected", - "कनेक्टेड" - ] - }, - { - "locale": "id", - "source": "Connected", - "translations": [ - "Connected", - "Terhubung" - ] - }, - { - "locale": "it", - "source": "Connected", - "translations": [ - "Connected", - "Connesso" - ] - }, - { - "locale": "ja-JP", - "source": "Connected", - "translations": [ - "Connected", - "接続済み" - ] - }, - { - "locale": "ko", - "source": "Connected", - "translations": [ - "Connected", - "연결됨" - ] - }, - { - "locale": "nl", - "source": "Connected", - "translations": [ - "Connected", - "Verbonden" - ] - }, - { - "locale": "pl", - "source": "Connected", - "translations": [ - "Connected", - "Połączono" - ] - }, - { - "locale": "pt-BR", - "source": "Connected", - "translations": [ - "Conectado", - "Connected" - ] - }, - { - "locale": "ru", - "source": "Connected", - "translations": [ - "Connected", - "Подключено" - ] - }, - { - "locale": "sv", - "source": "Connected", - "translations": [ - "Ansluten", - "Connected" - ] - }, - { - "locale": "th", - "source": "Connected", - "translations": [ - "Connected", - "เชื่อมต่อแล้ว" - ] - }, - { - "locale": "tr", - "source": "Connected", - "translations": [ - "Bağlandı", - "Bağlı", - "Connected" - ] - }, - { - "locale": "uk", - "source": "Connected", - "translations": [ - "Connected", - "Підключено" - ] - }, - { - "locale": "vi", - "source": "Connected", - "translations": [ - "Connected", - "Đã kết nối" - ] - }, - { - "locale": "zh-CN", - "source": "Connected", - "translations": [ - "Connected", - "已连接" - ] - }, - { - "locale": "zh-TW", - "source": "Connected", - "translations": [ - "Connected", - "已連接", - "已連線" - ] - }, - { - "locale": "fr", - "source": "Connecting", - "translations": [ - "Connexion", - "Connexion en cours" - ] - }, - { - "locale": "hi", - "source": "Connecting", - "translations": [ - "कनेक्ट किया जा रहा है", - "कनेक्ट हो रहा है" - ] - }, - { - "locale": "it", - "source": "Connecting", - "translations": [ - "Connessione", - "Connessione in corso" - ] - }, - { - "locale": "nl", - "source": "Connecting", - "translations": [ - "Verbinden", - "Verbinding maken" - ] - }, - { - "locale": "zh-TW", - "source": "Connecting", - "translations": [ - "正在連線", - "連線中" - ] - }, - { - "locale": "it", - "source": "Connecting...", - "translations": [ - "Connessione in corso...", - "Connessione..." - ] - }, - { - "locale": "nl", - "source": "Connecting...", - "translations": [ - "Verbinden...", - "Verbinding maken..." - ] - }, - { - "locale": "zh-TW", - "source": "Connecting...", - "translations": [ - "正在連線...", - "連線中..." - ] - }, - { - "locale": "de", - "source": "Connecting…", - "translations": [ - "Verbinden…", - "Verbindung wird hergestellt…" - ] - }, - { - "locale": "it", - "source": "Connecting…", - "translations": [ - "Connessione in corso…", - "Connessione…" - ] - }, - { - "locale": "nl", - "source": "Connecting…", - "translations": [ - "Verbinden…", - "Verbinding maken…" - ] - }, - { - "locale": "uk", - "source": "Connecting…", - "translations": [ - "З'єднання…", - "Підключення…" - ] - }, - { - "locale": "zh-TW", - "source": "Connecting…", - "translations": [ - "正在連線…", - "連接中…", - "連線中…" - ] - }, - { - "locale": "uk", - "source": "Connection", - "translations": [ - "З’єднання", - "Підключення" - ] - }, - { - "locale": "ja-JP", - "source": "Connection security", - "translations": [ - "接続のセキュリティ", - "接続セキュリティ" - ] - }, - { - "locale": "pl", - "source": "Connection security", - "translations": [ - "Bezpieczeństwo połączenia", - "Zabezpieczenia połączenia" - ] - }, - { - "locale": "uk", - "source": "Connection security", - "translations": [ - "Безпека з'єднання", - "Безпека з’єднання" - ] - }, - { - "locale": "ja-JP", - "source": "Continue", - "translations": [ - "続ける", - "続行" - ] - }, - { - "locale": "pl", - "source": "Continue", - "translations": [ - "Dalej", - "Kontynuuj" - ] - }, - { - "locale": "tr", - "source": "Continue", - "translations": [ - "Devam", - "Devam et" - ] - }, - { - "locale": "fa", - "source": "Copy Session Key", - "translations": [ - "کپی کلید جلسه", - "کپی کلید نشست" - ] - }, - { - "locale": "nl", - "source": "Copy Session Key", - "translations": [ - "Kopieer sessiesleutel", - "Sessiesleutel kopiëren" - ] - }, - { - "locale": "ru", - "source": "Copy Session Key", - "translations": [ - "Скопировать ключ сеанса", - "Скопировать ключ сессии" - ] - }, - { - "locale": "uk", - "source": "Copy Session Key", - "translations": [ - "Копіювати ключ сеансу", - "Копіювати ключ сесії" - ] - }, - { - "locale": "hi", - "source": "Create", - "translations": [ - "बनाएँ", - "बनाएं" - ] - }, - { - "locale": "zh-CN", - "source": "Cron", - "translations": [ - "Cron", - "定时任务" - ] - }, - { - "locale": "id", - "source": "Cron expression", - "translations": [ - "Ekspresi Cron", - "Ekspresi cron" - ] - }, - { - "locale": "ru", - "source": "Cron expression", - "translations": [ - "Cron-выражение", - "Выражение cron" - ] - }, - { - "locale": "uk", - "source": "Cron expression", - "translations": [ - "Cron-вираз", - "Вираз Cron" - ] - }, - { - "locale": "ar", - "source": "Default", - "translations": [ - "Default", - "افتراضي", - "الافتراضي" - ] - }, - { - "locale": "de", - "source": "Default", - "translations": [ - "Default", - "Standard" - ] - }, - { - "locale": "es", - "source": "Default", - "translations": [ - "Default", - "Predeterminado" - ] - }, - { - "locale": "fa", - "source": "Default", - "translations": [ - "Default", - "پیش‌فرض" - ] - }, - { - "locale": "fr", - "source": "Default", - "translations": [ - "Default", - "Par défaut" - ] - }, - { - "locale": "hi", - "source": "Default", - "translations": [ - "Default", - "डिफ़ॉल्ट" - ] - }, - { - "locale": "it", - "source": "Default", - "translations": [ - "Default", - "Predefinito" - ] - }, - { - "locale": "ja-JP", - "source": "Default", - "translations": [ - "Default", - "デフォルト" - ] - }, - { - "locale": "ko", - "source": "Default", - "translations": [ - "Default", - "기본값" - ] - }, - { - "locale": "nl", - "source": "Default", - "translations": [ - "Default", - "Standaard" - ] - }, - { - "locale": "pl", - "source": "Default", - "translations": [ - "Default", - "Domyślne", - "Domyślnie" - ] - }, - { - "locale": "pt-BR", - "source": "Default", - "translations": [ - "Default", - "Padrão" - ] - }, - { - "locale": "ru", - "source": "Default", - "translations": [ - "Default", - "По умолчанию" - ] - }, - { - "locale": "sv", - "source": "Default", - "translations": [ - "Default", - "Standard" - ] - }, - { - "locale": "th", - "source": "Default", - "translations": [ - "Default", - "ค่าเริ่มต้น" - ] - }, - { - "locale": "tr", - "source": "Default", - "translations": [ - "Default", - "Varsayılan" - ] - }, - { - "locale": "uk", - "source": "Default", - "translations": [ - "Default", - "За замовчуванням" - ] - }, - { - "locale": "vi", - "source": "Default", - "translations": [ - "Default", - "Mặc định" - ] - }, - { - "locale": "zh-CN", - "source": "Default", - "translations": [ - "Default", - "默认" - ] - }, - { - "locale": "zh-TW", - "source": "Default", - "translations": [ - "Default", - "預設" - ] - }, - { - "locale": "fa", - "source": "Default (inherited)", - "translations": [ - "پیش‌فرض (ارث‌بری‌شده)", - "پیش‌فرض (به‌ارث‌رسیده)" - ] - }, - { - "locale": "pl", - "source": "Default (inherited)", - "translations": [ - "Domyślne (dziedziczone)", - "Domyślne (odziedziczone)" - ] - }, - { - "locale": "th", - "source": "Default (inherited)", - "translations": [ - "ค่าเริ่มต้น (สืบทอด)", - "ค่าเริ่มต้น (สืบทอดมา)" - ] - }, - { - "locale": "ko", - "source": "Default Agent", - "translations": [ - "기본 Agent", - "기본 에이전트" - ] - }, - { - "locale": "th", - "source": "Default Agent", - "translations": [ - "Agent เริ่มต้น", - "เอเจนต์เริ่มต้น" - ] - }, - { - "locale": "tr", - "source": "Default Agent", - "translations": [ - "Varsayılan Agent", - "Varsayılan Ajan" - ] - }, - { - "locale": "uk", - "source": "Default Agent", - "translations": [ - "Агент за замовчуванням", - "Стандартний агент" - ] - }, - { - "locale": "vi", - "source": "Default Agent", - "translations": [ - "Agent mặc định", - "Tác nhân mặc định" - ] - }, - { - "locale": "zh-TW", - "source": "Default Agent", - "translations": [ - "預設 Agent", - "預設代理" - ] - }, - { - "locale": "de", - "source": "Default agent", - "translations": [ - "Standard-Agent", - "Standardagent" - ] - }, - { - "locale": "ja-JP", - "source": "Default agent", - "translations": [ - "デフォルトのエージェント", - "デフォルトエージェント" - ] - }, - { - "locale": "th", - "source": "Default agent", - "translations": [ - "Agent เริ่มต้น", - "เอเจนต์เริ่มต้น" - ] - }, - { - "locale": "zh-CN", - "source": "Default agent", - "translations": [ - "默认 Agent", - "默认代理" - ] - }, - { - "locale": "zh-TW", - "source": "Default agent", - "translations": [ - "預設 agent", - "預設代理程式" - ] - }, - { - "locale": "hi", - "source": "Delete", - "translations": [ - "मिटाएँ", - "हटाएँ" - ] - }, - { - "locale": "nl", - "source": "Delete", - "translations": [ - "Verwijder", - "Verwijderen" - ] - }, - { - "locale": "sv", - "source": "Delete", - "translations": [ - "Radera", - "Ta bort" - ] - }, - { - "locale": "sv", - "source": "Delete Group", - "translations": [ - "Radera grupp", - "Ta bort grupp" - ] - }, - { - "locale": "zh-CN", - "source": "Delete Group", - "translations": [ - "删除分组", - "删除群组" - ] - }, - { - "locale": "fr", - "source": "Delete “%@”?", - "translations": [ - "Supprimer « %@ » ?", - "Supprimer « %@ » ?" - ] - }, - { - "locale": "sv", - "source": "Delete “%@”?", - "translations": [ - "Radera ”%@”?", - "Ta bort ”%@”?" - ] - }, - { - "locale": "it", - "source": "Deny", - "translations": [ - "Nega", - "Rifiuta" - ] - }, - { - "locale": "ru", - "source": "Deny", - "translations": [ - "Запретить", - "Отклонить" - ] - }, - { - "locale": "ru", - "source": "Details", - "translations": [ - "Подробности", - "Сведения" - ] - }, - { - "locale": "uk", - "source": "Details", - "translations": [ - "Відомості", - "Деталі", - "Докладно" - ] - }, - { - "locale": "zh-CN", - "source": "Details", - "translations": [ - "详情", - "详细信息" - ] - }, - { - "locale": "zh-TW", - "source": "Details", - "translations": [ - "詳細資料", - "詳細資訊" - ] - }, - { - "locale": "de", - "source": "Diagnose", - "translations": [ - "Diagnose", - "Diagnostizieren" - ] - }, - { - "locale": "de", - "source": "Diagnostics", - "translations": [ - "Diagnose", - "Diagnosen" - ] - }, - { - "locale": "es", - "source": "Diagnostics", - "translations": [ - "Diagnóstico", - "Diagnósticos" - ] - }, - { - "locale": "fa", - "source": "Diagnostics", - "translations": [ - "عیب‌یابی", - "عیب‌یابی‌ها" - ] - }, - { - "locale": "de", - "source": "Discovered", - "translations": [ - "Entdeckt", - "Erkannt" - ] - }, - { - "locale": "fr", - "source": "Discovered", - "translations": [ - "Découvert", - "Détecté" - ] - }, - { - "locale": "ru", - "source": "Discovered", - "translations": [ - "Обнаружен", - "Обнаружено" - ] - }, - { - "locale": "tr", - "source": "Discovered", - "translations": [ - "Bulundu", - "Keşfedildi" - ] - }, - { - "locale": "zh-TW", - "source": "Discovered", - "translations": [ - "已探索", - "已發現" - ] - }, - { - "locale": "es", - "source": "Discovery", - "translations": [ - "Descubrimiento", - "Detección" - ] - }, - { - "locale": "hi", - "source": "Discovery", - "translations": [ - "खोज", - "डिस्कवरी" - ] - }, - { - "locale": "sv", - "source": "Discovery", - "translations": [ - "Identifiering", - "Upptäckt" - ] - }, - { - "locale": "th", - "source": "Discovery", - "translations": [ - "การค้นพบ", - "การค้นหา" - ] - }, - { - "locale": "ar", - "source": "Discovery Logs", - "translations": [ - "سجلات Discovery", - "سجلات الاكتشاف" - ] - }, - { - "locale": "de", - "source": "Discovery Logs", - "translations": [ - "Discovery Logs", - "Erkennungsprotokolle" - ] - }, - { - "locale": "es", - "source": "Discovery Logs", - "translations": [ - "Registros de Discovery", - "Registros de descubrimiento" - ] - }, - { - "locale": "fa", - "source": "Discovery Logs", - "translations": [ - "گزارش‌های Discovery", - "گزارش‌های کشف" - ] - }, - { - "locale": "hi", - "source": "Discovery Logs", - "translations": [ - "Discovery लॉग", - "डिस्कवरी लॉग्स" - ] - }, - { - "locale": "id", - "source": "Discovery Logs", - "translations": [ - "Log Discovery", - "Log Penemuan" - ] - }, - { - "locale": "it", - "source": "Discovery Logs", - "translations": [ - "Log di Discovery", - "Log di rilevamento" - ] - }, - { - "locale": "ja-JP", - "source": "Discovery Logs", - "translations": [ - "Discovery Logs", - "検出ログ" - ] - }, - { - "locale": "ko", - "source": "Discovery Logs", - "translations": [ - "Discovery 로그", - "검색 로그" - ] - }, - { - "locale": "nl", - "source": "Discovery Logs", - "translations": [ - "Discovery-logboeken", - "Ontdekkingslogboeken" - ] - }, - { - "locale": "pl", - "source": "Discovery Logs", - "translations": [ - "Discovery Logs", - "Dzienniki wykrywania" - ] - }, - { - "locale": "ru", - "source": "Discovery Logs", - "translations": [ - "Журналы Discovery", - "Журналы обнаружения" - ] - }, - { - "locale": "sv", - "source": "Discovery Logs", - "translations": [ - "Discovery-loggar", - "Identifieringsloggar" - ] - }, - { - "locale": "th", - "source": "Discovery Logs", - "translations": [ - "บันทึก Discovery", - "บันทึกการค้นพบ" - ] - }, - { - "locale": "tr", - "source": "Discovery Logs", - "translations": [ - "Discovery Günlükleri", - "Keşif Günlükleri" - ] - }, - { - "locale": "uk", - "source": "Discovery Logs", - "translations": [ - "Журнали Discovery", - "Журнали виявлення" - ] - }, - { - "locale": "vi", - "source": "Discovery Logs", - "translations": [ - "Nhật ký Discovery", - "Nhật ký khám phá" - ] - }, - { - "locale": "zh-TW", - "source": "Discovery Logs", - "translations": [ - "Discovery Logs", - "探索記錄" - ] - }, - { - "locale": "ar", - "source": "Dismiss", - "translations": [ - "إغلاق", - "تجاهل" - ] - }, - { - "locale": "de", - "source": "Dismiss", - "translations": [ - "Schließen", - "Verwerfen" - ] - }, - { - "locale": "fa", - "source": "Dismiss", - "translations": [ - "بستن", - "رد کردن" - ] - }, - { - "locale": "ja-JP", - "source": "Dismiss", - "translations": [ - "却下", - "閉じる" - ] - }, - { - "locale": "pt-BR", - "source": "Dismiss", - "translations": [ - "Dispensar", - "Ignorar" - ] - }, - { - "locale": "ru", - "source": "Dismiss", - "translations": [ - "Закрыть", - "Отклонить" - ] - }, - { - "locale": "sv", - "source": "Dismiss", - "translations": [ - "Avfärda", - "Avvisa" - ] - }, - { - "locale": "uk", - "source": "Dismiss", - "translations": [ - "Відхилити", - "Закрити" - ] - }, - { - "locale": "vi", - "source": "Dismiss", - "translations": [ - "Bỏ qua", - "Đóng" - ] - }, - { - "locale": "zh-CN", - "source": "Dismiss", - "translations": [ - "关闭", - "忽略" - ] - }, - { - "locale": "de", - "source": "Docs", - "translations": [ - "Docs", - "Dokumentation" - ] - }, - { - "locale": "fa", - "source": "Docs", - "translations": [ - "اسناد", - "مستندات" - ] - }, - { - "locale": "hi", - "source": "Docs", - "translations": [ - "डॉक्स", - "दस्तावेज़" - ] - }, - { - "locale": "id", - "source": "Docs", - "translations": [ - "Dokumen", - "Dokumentasi" - ] - }, - { - "locale": "it", - "source": "Docs", - "translations": [ - "Documentazione", - "Documenti" - ] - }, - { - "locale": "nl", - "source": "Docs", - "translations": [ - "Documentatie", - "Documenten" - ] - }, - { - "locale": "pt-BR", - "source": "Docs", - "translations": [ - "Documentação", - "Documentos" - ] - }, - { - "locale": "ru", - "source": "Docs", - "translations": [ - "Документация", - "Документы" - ] - }, - { - "locale": "uk", - "source": "Docs", - "translations": [ - "Документація", - "Документи" - ] - }, - { - "locale": "ar", - "source": "Done", - "translations": [ - "تم", - "مكتمل" - ] - }, - { - "locale": "es", - "source": "Done", - "translations": [ - "Completado", - "Hecho", - "Listo" - ] - }, - { - "locale": "hi", - "source": "Done", - "translations": [ - "पूर्ण", - "हो गया" - ] - }, - { - "locale": "it", - "source": "Done", - "translations": [ - "Completato", - "Fatto", - "Fine" - ] - }, - { - "locale": "nl", - "source": "Done", - "translations": [ - "Gereed", - "Klaar" - ] - }, - { - "locale": "sv", - "source": "Done", - "translations": [ - "Klar", - "Klart" - ] - }, - { - "locale": "tr", - "source": "Done", - "translations": [ - "Bitti", - "Tamamlandı" - ] - }, - { - "locale": "vi", - "source": "Done", - "translations": [ - "Hoàn tất", - "Xong" - ] - }, - { - "locale": "zh-CN", - "source": "Done", - "translations": [ - "完成", - "已完成" - ] - }, - { - "locale": "ar", - "source": "Dreaming", - "translations": [ - "Dreaming", - "الحلم", - "يحلم" - ] - }, - { - "locale": "de", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Träumen" - ] - }, - { - "locale": "es", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Soñando" - ] - }, - { - "locale": "fa", - "source": "Dreaming", - "translations": [ - "در حال رؤیاپردازی", - "رؤیاپردازی" - ] - }, - { - "locale": "fr", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Rêve", - "Rêverie" - ] - }, - { - "locale": "hi", - "source": "Dreaming", - "translations": [ - "Dreaming", - "ड्रीमिंग", - "सपना देख रहा है", - "स्वप्न देख रहा है" - ] - }, - { - "locale": "id", - "source": "Dreaming", - "translations": [ - "Bermimpi", - "Dreaming" - ] - }, - { - "locale": "it", - "source": "Dreaming", - "translations": [ - "In elaborazione", - "Sognando", - "Sogno" - ] - }, - { - "locale": "ja-JP", - "source": "Dreaming", - "translations": [ - "Dreaming", - "ドリーミング" - ] - }, - { - "locale": "ko", - "source": "Dreaming", - "translations": [ - "Dreaming", - "구상 중", - "꿈꾸는 중" - ] - }, - { - "locale": "nl", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Dromen" - ] - }, - { - "locale": "pl", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Śnienie" - ] - }, - { - "locale": "pt-BR", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Sonhando" - ] - }, - { - "locale": "ru", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Сновидение" - ] - }, - { - "locale": "sv", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Drömmer" - ] - }, - { - "locale": "th", - "source": "Dreaming", - "translations": [ - "Dreaming", - "กำลังฝัน" - ] - }, - { - "locale": "tr", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Düş kuruyor", - "Düşünüyor", - "Hayal Kuruyor" - ] - }, - { - "locale": "uk", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Сновидіння" - ] - }, - { - "locale": "vi", - "source": "Dreaming", - "translations": [ - "Dreaming", - "Đang mơ" - ] - }, - { - "locale": "zh-CN", - "source": "Dreaming", - "translations": [ - "Dreaming", - "梦境", - "梦境中" - ] - }, - { - "locale": "zh-TW", - "source": "Dreaming", - "translations": [ - "Dreaming", - "正在構想" - ] - }, - { - "locale": "pl", - "source": "Empty", - "translations": [ - "Puste", - "Pusto" - ] - }, - { - "locale": "zh-CN", - "source": "Empty", - "translations": [ - "为空", - "空" - ] - }, - { - "locale": "es", - "source": "Enable", - "translations": [ - "Activar", - "Habilitar" - ] - }, - { - "locale": "fa", - "source": "Enable", - "translations": [ - "فعال‌سازی", - "فعال‌کردن" - ] - }, - { - "locale": "ja-JP", - "source": "Enable", - "translations": [ - "有効にする", - "有効化" - ] - }, - { - "locale": "es", - "source": "Enabled", - "translations": [ - "Activado", - "Habilitado" - ] - }, - { - "locale": "ko", - "source": "Enabled", - "translations": [ - "사용 설정됨", - "활성화됨" - ] - }, - { - "locale": "pt-BR", - "source": "Enabled", - "translations": [ - "Ativado", - "Habilitado" - ] - }, - { - "locale": "id", - "source": "Expanded", - "translations": [ - "Buka", - "Diperluas" - ] - }, - { - "locale": "sv", - "source": "Expanded", - "translations": [ - "Expanderad", - "Utfälld" - ] - }, - { - "locale": "th", - "source": "Expanded", - "translations": [ - "ขยายอยู่", - "ขยายแล้ว" - ] - }, - { - "locale": "vi", - "source": "Expanded", - "translations": [ - "Mở rộng", - "Đã mở rộng" - ] - }, - { - "locale": "fa", - "source": "Export Transcript", - "translations": [ - "خروجی گرفتن از رونوشت", - "خروجی گرفتن رونوشت" - ] - }, - { - "locale": "hi", - "source": "Export Transcript", - "translations": [ - "ट्रांसक्रिप्ट निर्यात करें", - "प्रतिलेख निर्यात करें" - ] - }, - { - "locale": "ko", - "source": "Export Transcript", - "translations": [ - "기록 내보내기", - "대화 기록 내보내기" - ] - }, - { - "locale": "ru", - "source": "Export Transcript", - "translations": [ - "Экспорт транскрипта", - "Экспортировать стенограмму" - ] - }, - { - "locale": "sv", - "source": "Export Transcript", - "translations": [ - "Exportera transkript", - "Exportera transkription" - ] - }, - { - "locale": "th", - "source": "Export Transcript", - "translations": [ - "ส่งออกบันทึกการสนทนา", - "ส่งออกสำเนาบทสนทนา" - ] - }, - { - "locale": "tr", - "source": "Export Transcript", - "translations": [ - "Dökümü Dışa Aktar", - "Dökümü dışa aktar" - ] - }, - { - "locale": "uk", - "source": "Export Transcript", - "translations": [ - "Експортувати стенограму", - "Експортувати транскрипт" - ] - }, - { - "locale": "vi", - "source": "Export Transcript", - "translations": [ - "Xuất Bản ghi", - "Xuất bản ghi cuộc trò chuyện" - ] - }, - { - "locale": "zh-CN", - "source": "Export Transcript", - "translations": [ - "导出对话记录", - "导出转录" - ] - }, - { - "locale": "zh-TW", - "source": "Export Transcript", - "translations": [ - "匯出對話紀錄", - "匯出逐字稿" - ] - }, - { - "locale": "ar", - "source": "Failed", - "translations": [ - "فشل", - "فشلت" - ] - }, - { - "locale": "es", - "source": "Failed", - "translations": [ - "Fallida", - "Fallido", - "Falló" - ] - }, - { - "locale": "fr", - "source": "Failed", - "translations": [ - "Échec", - "Échoué" - ] - }, - { - "locale": "ja-JP", - "source": "Failed", - "translations": [ - "失敗", - "失敗しました" - ] - }, - { - "locale": "ko", - "source": "Failed", - "translations": [ - "실패", - "실패함" - ] - }, - { - "locale": "pl", - "source": "Failed", - "translations": [ - "Niepowodzenie", - "Nieudane" - ] - }, - { - "locale": "ru", - "source": "Failed", - "translations": [ - "Не выполнено", - "Ошибка" - ] - }, - { - "locale": "tr", - "source": "Failed", - "translations": [ - "Başarısız", - "Başarısız oldu" - ] - }, - { - "locale": "uk", - "source": "Failed", - "translations": [ - "Не вдалося", - "Не виконано" - ] - }, - { - "locale": "ko", - "source": "Fast", - "translations": [ - "빠르게", - "빠름" - ] - }, - { - "locale": "pl", - "source": "Fast", - "translations": [ - "Szybkie", - "Szybko" - ] - }, - { - "locale": "ar", - "source": "Forget Gateway", - "translations": [ - "نسيان Gateway", - "نسيان الـ Gateway" - ] - }, - { - "locale": "de", - "source": "Forget Gateway", - "translations": [ - "Gateway entfernen", - "Gateway vergessen" - ] - }, - { - "locale": "ja-JP", - "source": "Forget Gateway", - "translations": [ - "Gateway を削除", - "Gatewayを削除" - ] - }, - { - "locale": "ko", - "source": "Forget Gateway", - "translations": [ - "Gateway 삭제", - "Gateway 지우기" - ] - }, - { - "locale": "ar", - "source": "Fork", - "translations": [ - "تشعيب", - "تفريع", - "تفرّع" - ] - }, - { - "locale": "de", - "source": "Fork", - "translations": [ - "Abzweigen", - "Fork" - ] - }, - { - "locale": "fa", - "source": "Fork", - "translations": [ - "Fork", - "انشعاب" - ] - }, - { - "locale": "fr", - "source": "Fork", - "translations": [ - "Bifurquer", - "Dupliquer" - ] - }, - { - "locale": "hi", - "source": "Fork", - "translations": [ - "Fork", - "फ़ोर्क करें", - "फोर्क" - ] - }, - { - "locale": "id", - "source": "Fork", - "translations": [ - "Buat Cabang", - "Cabangkan", - "Fork" - ] - }, - { - "locale": "it", - "source": "Fork", - "translations": [ - "Crea fork", - "Dirama", - "Duplica", - "Fork" - ] - }, - { - "locale": "nl", - "source": "Fork", - "translations": [ - "Afsplitsen", - "Fork", - "Splits af" - ] - }, - { - "locale": "pl", - "source": "Fork", - "translations": [ - "Fork", - "Rozgałęź", - "Utwórz odgałęzienie" - ] - }, - { - "locale": "pt-BR", - "source": "Fork", - "translations": [ - "Bifurcar", - "Ramificar" - ] - }, - { - "locale": "ru", - "source": "Fork", - "translations": [ - "Ответвить", - "Создать ответвление", - "Форк" - ] - }, - { - "locale": "th", - "source": "Fork", - "translations": [ - "แยกสาขา", - "แยกเซสชัน", - "แยกแขนง" - ] - }, - { - "locale": "tr", - "source": "Fork", - "translations": [ - "Dallandır", - "Çatalla" - ] - }, - { - "locale": "uk", - "source": "Fork", - "translations": [ - "Відгалузити", - "Створити відгалуження", - "Форк" - ] - }, - { - "locale": "vi", - "source": "Fork", - "translations": [ - "Phân nhánh", - "Tạo nhánh" - ] - }, - { - "locale": "zh-CN", - "source": "Fork", - "translations": [ - "分叉", - "分支", - "创建分支" - ] - }, - { - "locale": "zh-TW", - "source": "Fork", - "translations": [ - "分支", - "建立分支" - ] - }, - { - "locale": "ja-JP", - "source": "Full", - "translations": [ - "フル", - "最大" - ] - }, - { - "locale": "pl", - "source": "Full", - "translations": [ - "Pełna", - "Pełne", - "Pełny" - ] - }, - { - "locale": "ru", - "source": "Full", - "translations": [ - "Полная", - "Полное", - "Полный" - ] - }, - { - "locale": "th", - "source": "Full", - "translations": [ - "เต็ม", - "เต็มรูปแบบ" - ] - }, - { - "locale": "uk", - "source": "Full", - "translations": [ - "Повна", - "Повне", - "Повний" - ] - }, - { - "locale": "zh-CN", - "source": "Full", - "translations": [ - "完全", - "完整" - ] - }, - { - "locale": "de", - "source": "Gateway Auth Token", - "translations": [ - "Gateway Auth Token", - "Gateway-Auth-Token" - ] - }, - { - "locale": "it", - "source": "Gateway Auth Token", - "translations": [ - "Token di autenticazione Gateway", - "Token di autenticazione del Gateway" - ] - }, - { - "locale": "ja-JP", - "source": "Gateway Auth Token", - "translations": [ - "Gateway Auth Token", - "Gateway認証トークン" - ] - }, - { - "locale": "nl", - "source": "Gateway Auth Token", - "translations": [ - "Gateway Auth Token", - "Gateway-verificatietoken" - ] - }, - { - "locale": "th", - "source": "Gateway Auth Token", - "translations": [ - "โทเค็นการตรวจสอบสิทธิ์ Gateway", - "โทเค็นยืนยันตัวตน Gateway" - ] - }, - { - "locale": "tr", - "source": "Gateway Auth Token", - "translations": [ - "Gateway Kimlik Doğrulama Token'ı", - "Gateway kimlik doğrulama belirteci" - ] - }, - { - "locale": "vi", - "source": "Gateway Auth Token", - "translations": [ - "Mã xác thực Gateway", - "Token xác thực Gateway" - ] - }, - { - "locale": "zh-CN", - "source": "Gateway Auth Token", - "translations": [ - "Gateway Auth Token", - "Gateway 身份验证令牌" - ] - }, - { - "locale": "ar", - "source": "Gateway Default", - "translations": [ - "Gateway الافتراضي", - "إعداد Gateway الافتراضي" - ] - }, - { - "locale": "es", - "source": "Gateway Default", - "translations": [ - "Gateway predeterminado", - "Predeterminado de Gateway" - ] - }, - { - "locale": "fr", - "source": "Gateway Default", - "translations": [ - "Gateway par défaut", - "Valeur par défaut du Gateway" - ] - }, - { - "locale": "nl", - "source": "Gateway Default", - "translations": [ - "Gateway Default", - "Standaard Gateway" - ] - }, - { - "locale": "pl", - "source": "Gateway Default", - "translations": [ - "Domyślne Gateway", - "Domyślne ustawienie Gateway" - ] - }, - { - "locale": "vi", - "source": "Gateway Default", - "translations": [ - "Mặc định Gateway", - "Mặc định của Gateway" - ] - }, - { - "locale": "zh-CN", - "source": "Gateway Default", - "translations": [ - "Gateway 默认", - "Gateway 默认值" - ] - }, - { - "locale": "fa", - "source": "Gateway Password", - "translations": [ - "رمز عبور Gateway", - "گذرواژه Gateway" - ] - }, - { - "locale": "it", - "source": "Gateway Password", - "translations": [ - "Password Gateway", - "Password del Gateway" - ] - }, - { - "locale": "ja-JP", - "source": "Gateway Password", - "translations": [ - "Gateway Password", - "Gatewayパスワード" - ] - }, - { - "locale": "tr", - "source": "Gateway Password", - "translations": [ - "Gateway Parolası", - "Gateway parolası" - ] - }, - { - "locale": "ar", - "source": "Gateway connected", - "translations": [ - "Gateway متصل", - "تم الاتصال بـ Gateway" - ] - }, - { - "locale": "tr", - "source": "Gateway connected", - "translations": [ - "Gateway bağlandı", - "Gateway bağlı" - ] - }, - { - "locale": "vi", - "source": "Gateway connected", - "translations": [ - "Gateway đã kết nối", - "Đã kết nối Gateway" - ] - }, - { - "locale": "ar", - "source": "Gateway default", - "translations": [ - "Gateway الافتراضي", - "الإعداد الافتراضي لـ Gateway" - ] - }, - { - "locale": "es", - "source": "Gateway default", - "translations": [ - "Predeterminado de Gateway", - "Valor predeterminado de Gateway" - ] - }, - { - "locale": "fa", - "source": "Gateway default", - "translations": [ - "Gateway پیش‌فرض", - "پیش‌فرض Gateway" - ] - }, - { - "locale": "ja-JP", - "source": "Gateway default", - "translations": [ - "Gateway デフォルト", - "Gatewayのデフォルト" - ] - }, - { - "locale": "nl", - "source": "Gateway default", - "translations": [ - "Gateway standaard", - "Standaardinstelling van Gateway" - ] - }, - { - "locale": "pl", - "source": "Gateway default", - "translations": [ - "Domyślna Gateway", - "Domyślne ustawienie Gateway" - ] - }, - { - "locale": "ru", - "source": "Gateway default", - "translations": [ - "Gateway по умолчанию", - "По умолчанию для Gateway" - ] - }, - { - "locale": "sv", - "source": "Gateway default", - "translations": [ - "Gateway-standard", - "Standard för Gateway" - ] - }, - { - "locale": "th", - "source": "Gateway default", - "translations": [ - "Gateway เริ่มต้น", - "ค่าเริ่มต้นของ Gateway" - ] - }, - { - "locale": "uk", - "source": "Gateway default", - "translations": [ - "Gateway за замовчуванням", - "Стандартний Gateway" - ] - }, - { - "locale": "vi", - "source": "Gateway default", - "translations": [ - "Gateway mặc định", - "Mặc định của Gateway" - ] - }, - { - "locale": "zh-TW", - "source": "Gateway default", - "translations": [ - "Gateway 預設", - "Gateway 預設值" - ] - }, - { - "locale": "hi", - "source": "Gateway offline", - "translations": [ - "Gateway ऑफ़लाइन", - "Gateway ऑफ़लाइन है" - ] - }, - { - "locale": "ja-JP", - "source": "Gateway offline", - "translations": [ - "Gateway がオフラインです", - "Gateway オフライン", - "Gatewayはオフラインです" - ] - }, - { - "locale": "pl", - "source": "Gateway offline", - "translations": [ - "Gateway jest offline", - "Gateway offline" - ] - }, - { - "locale": "ru", - "source": "Gateway offline", - "translations": [ - "Gateway не в сети", - "Gateway офлайн" - ] - }, - { - "locale": "sv", - "source": "Gateway offline", - "translations": [ - "Gateway offline", - "Gateway är offline" - ] - }, - { - "locale": "uk", - "source": "Gateway offline", - "translations": [ - "Gateway не в мережі", - "Gateway офлайн" - ] - }, - { - "locale": "ar", - "source": "Gateway online", - "translations": [ - "Gateway متصل", - "Gateway متصل بالإنترنت" - ] - }, - { - "locale": "ja-JP", - "source": "Gateway online", - "translations": [ - "Gateway オンライン", - "Gatewayオンライン" - ] - }, - { - "locale": "uk", - "source": "Gateway online", - "translations": [ - "Gateway онлайн", - "Gateway у мережі" - ] - }, - { - "locale": "vi", - "source": "Gateway online", - "translations": [ - "Gateway trực tuyến", - "Gateway đang trực tuyến" - ] - }, - { - "locale": "ar", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "لا تزال استعادة Gateway بعد إعادة التشغيل جارية.", - "لا تزال عملية الاسترداد بعد إعادة تشغيل Gateway جارية." - ] - }, - { - "locale": "de", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "Die Wiederherstellung nach dem Neustart des Gateway läuft noch.", - "Die Wiederherstellung nach dem Neustart des Gateways läuft noch." - ] - }, - { - "locale": "hi", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "Gateway को पुनः आरंभ करने के बाद रिकवरी अभी भी जारी है।", - "Gateway रीस्टार्ट रिकवरी अभी भी जारी है।" - ] - }, - { - "locale": "ja-JP", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "Gatewayの再起動後の復旧はまだ進行中です。", - "Gatewayの再起動後の復旧処理がまだ進行中です。" - ] - }, - { - "locale": "nl", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "Het herstel na de herstart van de Gateway is nog bezig.", - "Het herstel na het opnieuw opstarten van de Gateway is nog bezig." - ] - }, - { - "locale": "pl", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "Odzyskiwanie po ponownym uruchomieniu Gateway nadal trwa.", - "Przywracanie po ponownym uruchomieniu Gateway nadal trwa." - ] - }, - { - "locale": "pt-BR", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "A recuperação após a reinicialização do Gateway ainda está em andamento.", - "A recuperação da reinicialização do Gateway ainda está em andamento." - ] - }, - { - "locale": "ru", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "Восстановление после перезапуска Gateway всё ещё выполняется.", - "Восстановление после перезапуска Gateway ещё продолжается." - ] - }, - { - "locale": "th", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "การกู้คืนหลังจากรีสตาร์ท Gateway ยังคงดำเนินการอยู่", - "การกู้คืนหลังรีสตาร์ท Gateway ยังคงดำเนินอยู่" - ] - }, - { - "locale": "tr", - "source": "Gateway restart recovery is still in progress.", - "translations": [ - "Gateway yeniden başlatma kurtarma işlemi hâlâ devam ediyor.", - "Gateway'i yeniden başlatma kurtarma işlemi hâlâ devam ediyor." - ] - }, - { - "locale": "es", - "source": "Gateway settings", - "translations": [ - "Configuración de Gateway", - "Configuración del Gateway" - ] - }, - { - "locale": "fr", - "source": "Gateway settings", - "translations": [ - "Paramètres Gateway", - "Paramètres de Gateway", - "Paramètres du Gateway" - ] - }, - { - "locale": "ja-JP", - "source": "Gateway settings", - "translations": [ - "Gateway 設定", - "Gateway設定" - ] - }, - { - "locale": "ar", - "source": "Get Info…", - "translations": [ - "إحضار المعلومات…", - "الحصول على معلومات…" - ] - }, - { - "locale": "hi", - "source": "Get Info…", - "translations": [ - "जानकारी पाएँ…", - "जानकारी प्राप्त करें…" - ] - }, - { - "locale": "pl", - "source": "Get Info…", - "translations": [ - "Informacje…", - "Pokaż informacje…" - ] - }, - { - "locale": "pt-BR", - "source": "Get Info…", - "translations": [ - "Obter Informações…", - "Obter informações…" - ] - }, - { - "locale": "uk", - "source": "Get Info…", - "translations": [ - "Отримати інформацію…", - "Переглянути інформацію…" - ] - }, - { - "locale": "zh-CN", - "source": "Group name", - "translations": [ - "分组名称", - "组名称", - "群组名称" - ] - }, - { - "locale": "fa", - "source": "Home Network", - "translations": [ - "شبکه خانگی", - "شبکهٔ خانگی" - ] - }, - { - "locale": "zh-TW", - "source": "Home Network", - "translations": [ - "家庭網路", - "家用網路" - ] - }, - { - "locale": "hi", - "source": "Host", - "translations": [ - "Host", - "होस्ट" - ] - }, - { - "locale": "tr", - "source": "Host", - "translations": [ - "Ana Bilgisayar", - "Ana Makine", - "Ana makine", - "Host" - ] - }, - { - "locale": "fr", - "source": "Inspect", - "translations": [ - "Examiner", - "Inspecter" - ] - }, - { - "locale": "hi", - "source": "Inspect", - "translations": [ - "जाँच करें", - "निरीक्षण करें" - ] - }, - { - "locale": "ja-JP", - "source": "Inspect", - "translations": [ - "検査", - "確認" - ] - }, - { - "locale": "nl", - "source": "Inspect", - "translations": [ - "Bekijken", - "Inspecteren" - ] - }, - { - "locale": "ru", - "source": "Inspect", - "translations": [ - "Проверить", - "Просмотреть" - ] - }, - { - "locale": "zh-CN", - "source": "Inspect", - "translations": [ - "查看", - "检查" - ] - }, - { - "locale": "zh-TW", - "source": "Inspect", - "translations": [ - "檢查", - "檢視" - ] - }, - { - "locale": "ar", - "source": "Installed", - "translations": [ - "المثبتة", - "مثبّت" - ] - }, - { - "locale": "es", - "source": "Installed", - "translations": [ - "Instaladas", - "Instalado" - ] - }, - { - "locale": "fr", - "source": "Installed", - "translations": [ - "Installé", - "Installés" - ] - }, - { - "locale": "hi", - "source": "Installed", - "translations": [ - "इंस्टॉल किए गए", - "इंस्टॉल किया गया" - ] - }, - { - "locale": "id", - "source": "Installed", - "translations": [ - "Terinstal", - "Terpasang" - ] - }, - { - "locale": "it", - "source": "Installed", - "translations": [ - "Installate", - "Installato" - ] - }, - { - "locale": "pl", - "source": "Installed", - "translations": [ - "Zainstalowane", - "Zainstalowano" - ] - }, - { - "locale": "pt-BR", - "source": "Installed", - "translations": [ - "Instaladas", - "Instalado" - ] - }, - { - "locale": "ru", - "source": "Installed", - "translations": [ - "Установленные", - "Установлено" - ] - }, - { - "locale": "sv", - "source": "Installed", - "translations": [ - "Installerad", - "Installerade" - ] - }, - { - "locale": "tr", - "source": "Installed", - "translations": [ - "Yüklendi", - "Yüklü" - ] - }, - { - "locale": "uk", - "source": "Installed", - "translations": [ - "Встановлено", - "Установлені" - ] - }, - { - "locale": "hi", - "source": "Instances", - "translations": [ - "इंस्टेंस", - "इंस्टैंसेस" - ] - }, - { - "locale": "nl", - "source": "Instances", - "translations": [ - "Instances", - "Instanties" - ] - }, - { - "locale": "th", - "source": "Instances", - "translations": [ - "Instances", - "อินสแตนซ์" - ] - }, - { - "locale": "zh-TW", - "source": "Instances", - "translations": [ - "執行個體", - "實例" - ] - }, - { - "locale": "ar", - "source": "Interval", - "translations": [ - "الفاصل الزمني", - "فاصل زمني" - ] - }, - { - "locale": "ja-JP", - "source": "Limited", - "translations": [ - "制限あり", - "制限付き" - ] - }, - { - "locale": "pl", - "source": "Limited", - "translations": [ - "Ograniczone", - "Ograniczony" - ] - }, - { - "locale": "ru", - "source": "Limited", - "translations": [ - "Ограниченный", - "Ограниченный доступ", - "Ограничено" - ] - }, - { - "locale": "sv", - "source": "Limited", - "translations": [ - "Begränsad", - "Begränsat" - ] - }, - { - "locale": "uk", - "source": "Limited", - "translations": [ - "Обмежений", - "Обмежено" - ] - }, - { - "locale": "vi", - "source": "Limited", - "translations": [ - "Giới hạn", - "Hạn chế" - ] - }, - { - "locale": "zh-TW", - "source": "Limited", - "translations": [ - "受限", - "有限" - ] - }, - { - "locale": "ar", - "source": "List, add, and complete reminders.", - "translations": [ - "اسرد التذكيرات وأضِفها وأكملها.", - "عرض التذكيرات وإضافتها وإكمالها." - ] - }, - { - "locale": "de", - "source": "List, add, and complete reminders.", - "translations": [ - "Erinnerungen auflisten, hinzufügen und abschließen.", - "Erinnerungen auflisten, hinzufügen und als erledigt markieren." - ] - }, - { - "locale": "es", - "source": "List, add, and complete reminders.", - "translations": [ - "Consulta, añade y completa recordatorios.", - "Enumera, añade y completa recordatorios." - ] - }, - { - "locale": "fa", - "source": "List, add, and complete reminders.", - "translations": [ - "فهرست‌کردن، افزودن و تکمیل یادآورها.", - "یادآورها را فهرست، اضافه و کامل کنید." - ] - }, - { - "locale": "fr", - "source": "List, add, and complete reminders.", - "translations": [ - "Lister, ajouter et terminer des rappels.", - "Répertoriez, ajoutez et terminez des rappels." - ] - }, - { - "locale": "hi", - "source": "List, add, and complete reminders.", - "translations": [ - "रिमाइंडर की सूची देखें, जोड़ें और पूरा करें।", - "रिमाइंडर सूचीबद्ध करें, जोड़ें और पूर्ण करें।" - ] - }, - { - "locale": "id", - "source": "List, add, and complete reminders.", - "translations": [ - "Cantumkan, tambahkan, dan selesaikan pengingat.", - "Lihat, tambahkan, dan selesaikan pengingat." - ] - }, - { - "locale": "it", - "source": "List, add, and complete reminders.", - "translations": [ - "Elenca, aggiungi e completa i promemoria.", - "Elenca, aggiungi e completa promemoria." - ] - }, - { - "locale": "ja-JP", - "source": "List, add, and complete reminders.", - "translations": [ - "リマインダーの一覧表示、追加、完了を行います。", - "リマインダーを一覧表示、追加、完了します。" - ] - }, - { - "locale": "ko", - "source": "List, add, and complete reminders.", - "translations": [ - "미리 알림을 나열하고, 추가하고, 완료 처리합니다.", - "미리 알림을 조회하고 추가하거나 완료합니다." - ] - }, - { - "locale": "nl", - "source": "List, add, and complete reminders.", - "translations": [ - "Bekijk, voeg toe en voltooi herinneringen.", - "Geef herinneringen weer, voeg ze toe en voltooi ze." - ] - }, - { - "locale": "pl", - "source": "List, add, and complete reminders.", - "translations": [ - "Wyświetlaj listę przypomnień, dodawaj je i oznaczaj jako wykonane.", - "Wyświetlanie, dodawanie i oznaczanie przypomnień jako ukończone." - ] - }, - { - "locale": "ru", - "source": "List, add, and complete reminders.", - "translations": [ - "Просматривайте список напоминаний, добавляйте и отмечайте их как выполненные.", - "Просмотр, добавление и выполнение напоминаний." - ] - }, - { - "locale": "sv", - "source": "List, add, and complete reminders.", - "translations": [ - "Lista, lägg till och slutför påminnelser.", - "Visa, lägg till och slutför påminnelser." - ] - }, - { - "locale": "th", - "source": "List, add, and complete reminders.", - "translations": [ - "แสดงรายการ เพิ่ม และทำรายการเตือนความจำให้เสร็จ", - "แสดงรายการ เพิ่ม และทำรายการเตือนความจำให้เสร็จสิ้น" - ] - }, - { - "locale": "uk", - "source": "List, add, and complete reminders.", - "translations": [ - "Перегляд, додавання та виконання нагадувань.", - "Переглядайте список, додавайте й позначайте нагадування як виконані." - ] - }, - { - "locale": "vi", - "source": "List, add, and complete reminders.", - "translations": [ - "Liệt kê, thêm và hoàn thành lời nhắc.", - "Liệt kê, thêm và hoàn tất lời nhắc." - ] - }, - { - "locale": "zh-TW", - "source": "List, add, and complete reminders.", - "translations": [ - "列出、新增並完成提醒事項。", - "列出、新增及完成提醒事項。" - ] - }, - { - "locale": "fa", - "source": "Listening", - "translations": [ - "در حال گوش دادن", - "در حال گوش‌دادن" - ] - }, - { - "locale": "hi", - "source": "Listening", - "translations": [ - "सुन रहा है", - "सुना जा रहा है" - ] - }, - { - "locale": "ja-JP", - "source": "Listening", - "translations": [ - "リスニング中", - "聞き取り中" - ] - }, - { - "locale": "nl", - "source": "Listening", - "translations": [ - "Luisteren", - "Luistert" - ] - }, - { - "locale": "pl", - "source": "Listening", - "translations": [ - "Nasłuchiwanie", - "Słuchanie" - ] - }, - { - "locale": "uk", - "source": "Listening", - "translations": [ - "Прослуховування", - "Слухання", - "Слухаю" - ] - }, - { - "locale": "ar", - "source": "Loading", - "translations": [ - "Loading", - "جارٍ التحميل" - ] - }, - { - "locale": "de", - "source": "Loading", - "translations": [ - "Loading", - "Wird geladen" - ] - }, - { - "locale": "es", - "source": "Loading", - "translations": [ - "Cargando", - "Loading" - ] - }, - { - "locale": "fa", - "source": "Loading", - "translations": [ - "Loading", - "در حال بارگذاری" - ] - }, - { - "locale": "fr", - "source": "Loading", - "translations": [ - "Chargement", - "Loading" - ] - }, - { - "locale": "hi", - "source": "Loading", - "translations": [ - "Loading", - "लोड हो रहा है" - ] - }, - { - "locale": "id", - "source": "Loading", - "translations": [ - "Loading", - "Memuat" - ] - }, - { - "locale": "it", - "source": "Loading", - "translations": [ - "Caricamento", - "Loading" - ] - }, - { - "locale": "ja-JP", - "source": "Loading", - "translations": [ - "Loading", - "読み込み中" - ] - }, - { - "locale": "ko", - "source": "Loading", - "translations": [ - "Loading", - "로드 중", - "불러오는 중" - ] - }, - { - "locale": "nl", - "source": "Loading", - "translations": [ - "Laden", - "Loading" - ] - }, - { - "locale": "pl", - "source": "Loading", - "translations": [ - "Loading", - "Wczytywanie", - "Ładowanie" - ] - }, - { - "locale": "pt-BR", - "source": "Loading", - "translations": [ - "Carregando", - "Loading" - ] - }, - { - "locale": "ru", - "source": "Loading", - "translations": [ - "Loading", - "Загрузка" - ] - }, - { - "locale": "sv", - "source": "Loading", - "translations": [ - "Laddar", - "Loading", - "Läser in" - ] - }, - { - "locale": "th", - "source": "Loading", - "translations": [ - "Loading", - "กำลังโหลด" - ] - }, - { - "locale": "tr", - "source": "Loading", - "translations": [ - "Loading", - "Yükleniyor" - ] - }, - { - "locale": "uk", - "source": "Loading", - "translations": [ - "Loading", - "Завантаження" - ] - }, - { - "locale": "vi", - "source": "Loading", - "translations": [ - "Loading", - "Đang tải" - ] - }, - { - "locale": "zh-CN", - "source": "Loading", - "translations": [ - "Loading", - "加载中", - "正在加载" - ] - }, - { - "locale": "zh-TW", - "source": "Loading", - "translations": [ - "Loading", - "載入中" - ] - }, - { - "locale": "de", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "Sitzungen werden geladen" - ] - }, - { - "locale": "es", - "source": "Loading sessions", - "translations": [ - "Cargando sesiones", - "Loading sessions" - ] - }, - { - "locale": "fa", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "در حال بارگذاری جلسه‌ها" - ] - }, - { - "locale": "fr", - "source": "Loading sessions", - "translations": [ - "Chargement des sessions", - "Loading sessions" - ] - }, - { - "locale": "hi", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "सत्र लोड हो रहे हैं" - ] - }, - { - "locale": "id", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "Memuat sesi" - ] - }, - { - "locale": "it", - "source": "Loading sessions", - "translations": [ - "Caricamento delle sessioni", - "Caricamento sessioni" - ] - }, - { - "locale": "ja-JP", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "セッションを読み込み中" - ] - }, - { - "locale": "ko", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "세션 불러오는 중" - ] - }, - { - "locale": "nl", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "Sessies laden" - ] - }, - { - "locale": "pl", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "Wczytywanie sesji" - ] - }, - { - "locale": "pt-BR", - "source": "Loading sessions", - "translations": [ - "Carregando sessões", - "Loading sessions" - ] - }, - { - "locale": "ru", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "Загрузка сеансов" - ] - }, - { - "locale": "sv", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "Läser in sessioner" - ] - }, - { - "locale": "th", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "กำลังโหลดเซสชัน" - ] - }, - { - "locale": "uk", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "Завантаження сеансів" - ] - }, - { - "locale": "vi", - "source": "Loading sessions", - "translations": [ - "Đang tải các phiên", - "Đang tải phiên" - ] - }, - { - "locale": "zh-TW", - "source": "Loading sessions", - "translations": [ - "Loading sessions", - "正在載入工作階段" - ] - }, - { - "locale": "fr", - "source": "Location", - "translations": [ - "Localisation", - "Position" - ] - }, - { - "locale": "ru", - "source": "Location", - "translations": [ - "Геопозиция", - "Местоположение" - ] - }, - { - "locale": "uk", - "source": "Location", - "translations": [ - "Геодані", - "Місцезнаходження" - ] - }, - { - "locale": "ar", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "خدمات الموقع متوقفة في إعدادات iOS.", - "خدمات الموقع مُوقفة في إعدادات iOS." - ] - }, - { - "locale": "de", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Die Ortungsdienste sind in den iOS-Einstellungen deaktiviert.", - "Ortungsdienste sind in den iOS-Einstellungen ausgeschaltet." - ] - }, - { - "locale": "es", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Los Servicios de ubicación están desactivados en Ajustes de iOS.", - "Los servicios de ubicación están desactivados en los ajustes de iOS." - ] - }, - { - "locale": "fa", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "خدمات مکان در تنظیمات iOS خاموش است.", - "سرویس‌های موقعیت مکانی در تنظیمات iOS خاموش هستند." - ] - }, - { - "locale": "fr", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Les services de localisation sont désactivés dans les Réglages iOS.", - "Les services de localisation sont désactivés dans les réglages iOS." - ] - }, - { - "locale": "hi", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "iOS सेटिंग्स में Location Services बंद हैं।", - "iOS सेटिंग्स में स्थान सेवाएँ बंद हैं।" - ] - }, - { - "locale": "it", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "I Servizi di localizzazione sono disattivati nelle Impostazioni di iOS.", - "I servizi di localizzazione sono disattivati nelle Impostazioni di iOS." - ] - }, - { - "locale": "ja-JP", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "iOS の設定で位置情報サービスがオフになっています。", - "iOS 設定で位置情報サービスがオフになっています。" - ] - }, - { - "locale": "pl", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Usługi lokalizacji są wyłączone w Ustawieniach iOS.", - "Usługi lokalizacji są wyłączone w ustawieniach iOS." - ] - }, - { - "locale": "pt-BR", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Os Serviços de Localização estão desativados nas Configurações do iOS.", - "Os Serviços de Localização estão desativados nos Ajustes do iOS." - ] - }, - { - "locale": "ru", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Службы геолокации выключены в настройках iOS.", - "Службы геолокации отключены в настройках iOS." - ] - }, - { - "locale": "th", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Location Services ถูกปิดอยู่ในการตั้งค่า iOS", - "บริการหาตำแหน่งที่ตั้งถูกปิดอยู่ในการตั้งค่า iOS" - ] - }, - { - "locale": "tr", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Konum Servisleri iOS Ayarları'nda kapalı.", - "iOS Ayarları'nda Konum Servisleri kapalı." - ] - }, - { - "locale": "vi", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "Dịch vụ Vị trí đang tắt trong Cài đặt iOS.", - "Dịch vụ định vị đang tắt trong Cài đặt iOS." - ] - }, - { - "locale": "zh-CN", - "source": "Location Services are off in iOS Settings.", - "translations": [ - "iOS 设置中的定位服务已关闭。", - "位置服务已在 iOS 设置中关闭。" - ] - }, - { - "locale": "fa", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "دسترسی به موقعیت مکانی در تنظیمات iOS رد شده است.", - "مجوز موقعیت مکانی در تنظیمات iOS رد شده است." - ] - }, - { - "locale": "fr", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "L'autorisation de localisation est refusée dans les Réglages iOS.", - "L’autorisation de localisation est refusée dans les réglages iOS." - ] - }, - { - "locale": "hi", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "iOS Settings में लोकेशन अनुमति अस्वीकार की गई है।", - "iOS सेटिंग्स में स्थान अनुमति अस्वीकृत है।" - ] - }, - { - "locale": "it", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "L'autorizzazione alla posizione è negata nelle Impostazioni di iOS.", - "L’autorizzazione alla posizione è negata nelle Impostazioni di iOS." - ] - }, - { - "locale": "ja-JP", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "iOS 設定で位置情報の権限が拒否されています。", - "iOS 設定で位置情報の許可が拒否されています。" - ] - }, - { - "locale": "pl", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "Uprawnienie do lokalizacji jest odrzucone w Ustawieniach iOS.", - "Uprawnienie do lokalizacji jest odrzucone w ustawieniach iOS." - ] - }, - { - "locale": "pt-BR", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "A permissão de localização foi negada nas Configurações do iOS.", - "A permissão de localização foi negada nos Ajustes do iOS." - ] - }, - { - "locale": "ru", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "Доступ к геопозиции запрещен в настройках iOS.", - "Доступ к местоположению запрещён в настройках iOS." - ] - }, - { - "locale": "sv", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "Platsbehörighet nekas i iOS-inställningarna.", - "Platsbehörighet är nekad i iOS-inställningarna." - ] - }, - { - "locale": "th", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "การอนุญาตตำแหน่งที่ตั้งถูกปฏิเสธในการตั้งค่า iOS", - "การอนุญาตให้เข้าถึงตำแหน่งที่ตั้งถูกปฏิเสธในการตั้งค่า iOS" - ] - }, - { - "locale": "tr", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "Konum izni iOS Ayarları'nda reddedildi.", - "Konum izni iOS Ayarları'nda reddedilmiş." - ] - }, - { - "locale": "uk", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "Дозвіл на геолокацію заборонено в налаштуваннях iOS.", - "Доступ до геолокації заборонено в налаштуваннях iOS." - ] - }, - { - "locale": "zh-CN", - "source": "Location permission is denied in iOS Settings.", - "translations": [ - "iOS 设置中已拒绝位置权限。", - "位置权限已在 iOS 设置中被拒绝。" - ] - }, - { - "locale": "fa", - "source": "Location permission is restricted on this device.", - "translations": [ - "دسترسی به موقعیت مکانی در این دستگاه محدود شده است.", - "مجوز موقعیت مکانی در این دستگاه محدود شده است." - ] - }, - { - "locale": "fr", - "source": "Location permission is restricted on this device.", - "translations": [ - "L'autorisation de localisation est restreinte sur cet appareil.", - "L’autorisation de localisation est restreinte sur cet appareil." - ] - }, - { - "locale": "hi", - "source": "Location permission is restricted on this device.", - "translations": [ - "इस डिवाइस पर लोकेशन अनुमति प्रतिबंधित है।", - "इस डिवाइस पर स्थान अनुमति प्रतिबंधित है।" - ] - }, - { - "locale": "it", - "source": "Location permission is restricted on this device.", - "translations": [ - "L'autorizzazione alla posizione è limitata su questo dispositivo.", - "L’autorizzazione alla posizione è limitata su questo dispositivo." - ] - }, - { - "locale": "ja-JP", - "source": "Location permission is restricted on this device.", - "translations": [ - "このデバイスでは位置情報の権限が制限されています。", - "このデバイスでは位置情報の許可が制限されています。" - ] - }, - { - "locale": "ru", - "source": "Location permission is restricted on this device.", - "translations": [ - "Доступ к геопозиции ограничен на этом устройстве.", - "Доступ к местоположению ограничен на этом устройстве." - ] - }, - { - "locale": "th", - "source": "Location permission is restricted on this device.", - "translations": [ - "การอนุญาตตำแหน่งที่ตั้งถูกจำกัดบนอุปกรณ์นี้", - "การอนุญาตให้เข้าถึงตำแหน่งที่ตั้งถูกจำกัดบนอุปกรณ์นี้" - ] - }, - { - "locale": "tr", - "source": "Location permission is restricted on this device.", - "translations": [ - "Bu cihazda konum izni kısıtlı.", - "Konum izni bu cihazda kısıtlanmış." - ] - }, - { - "locale": "uk", - "source": "Location permission is restricted on this device.", - "translations": [ - "Дозвіл на геолокацію обмежено на цьому пристрої.", - "Доступ до геолокації обмежено на цьому пристрої." - ] - }, - { - "locale": "zh-CN", - "source": "Location permission is restricted on this device.", - "translations": [ - "此设备上的位置权限受到限制。", - "此设备上的位置权限受限。" - ] - }, - { - "locale": "ru", - "source": "Manage", - "translations": [ - "Управление", - "Управлять" - ] - }, - { - "locale": "ar", - "source": "Mark Read", - "translations": [ - "تحديد كمقروء", - "وضع علامة كمقروء" - ] - }, - { - "locale": "hi", - "source": "Mark Read", - "translations": [ - "पठित चिह्नित करें", - "पढ़ा हुआ चिह्नित करें" - ] - }, - { - "locale": "it", - "source": "Mark Read", - "translations": [ - "Segna come letta", - "Segna come letto" - ] - }, - { - "locale": "nl", - "source": "Mark Read", - "translations": [ - "Markeer als gelezen", - "Markeren als gelezen" - ] - }, - { - "locale": "uk", - "source": "Mark Read", - "translations": [ - "Позначити прочитаним", - "Позначити як прочитане" - ] - }, - { - "locale": "vi", - "source": "Mark Read", - "translations": [ - "Đánh dấu là đã đọc", - "Đánh dấu đã đọc" - ] - }, - { - "locale": "ar", - "source": "Message", - "translations": [ - "Message", - "الرسالة" - ] - }, - { - "locale": "de", - "source": "Message", - "translations": [ - "Message", - "Nachricht" - ] - }, - { - "locale": "es", - "source": "Message", - "translations": [ - "Mensaje", - "Message" - ] - }, - { - "locale": "fa", - "source": "Message", - "translations": [ - "Message", - "پیام" - ] - }, - { - "locale": "hi", - "source": "Message", - "translations": [ - "Message", - "संदेश" - ] - }, - { - "locale": "id", - "source": "Message", - "translations": [ - "Message", - "Pesan" - ] - }, - { - "locale": "it", - "source": "Message", - "translations": [ - "Message", - "Messaggio" - ] - }, - { - "locale": "ja-JP", - "source": "Message", - "translations": [ - "Message", - "メッセージ" - ] - }, - { - "locale": "ko", - "source": "Message", - "translations": [ - "Message", - "메시지" - ] - }, - { - "locale": "nl", - "source": "Message", - "translations": [ - "Bericht", - "Message" - ] - }, - { - "locale": "pl", - "source": "Message", - "translations": [ - "Message", - "Wiadomość" - ] - }, - { - "locale": "pt-BR", - "source": "Message", - "translations": [ - "Mensagem", - "Message" - ] - }, - { - "locale": "ru", - "source": "Message", - "translations": [ - "Message", - "Сообщение" - ] - }, - { - "locale": "sv", - "source": "Message", - "translations": [ - "Meddelande", - "Message" - ] - }, - { - "locale": "th", - "source": "Message", - "translations": [ - "Message", - "ข้อความ" - ] - }, - { - "locale": "tr", - "source": "Message", - "translations": [ - "Mesaj", - "Message" - ] - }, - { - "locale": "uk", - "source": "Message", - "translations": [ - "Message", - "Повідомлення" - ] - }, - { - "locale": "vi", - "source": "Message", - "translations": [ - "Message", - "Tin nhắn" - ] - }, - { - "locale": "zh-CN", - "source": "Message", - "translations": [ - "Message", - "消息" - ] - }, - { - "locale": "zh-TW", - "source": "Message", - "translations": [ - "Message", - "訊息" - ] - }, - { - "locale": "id", - "source": "Missing %@", - "translations": [ - "%@ tidak ada", - "%@ tidak ditemukan" - ] - }, - { - "locale": "ja-JP", - "source": "Missing %@", - "translations": [ - "%@ が見つかりません", - "%@がありません" - ] - }, - { - "locale": "th", - "source": "Missing %@", - "translations": [ - "ไม่พบ %@", - "ไม่มี %@" - ] - }, - { - "locale": "fa", - "source": "Missing: %@", - "translations": [ - "مفقود: %@", - "مورد مفقود: %@" - ] - }, - { - "locale": "it", - "source": "Missing: %@", - "translations": [ - "Manca: %@", - "Mancante: %@" - ] - }, - { - "locale": "pl", - "source": "Missing: %@", - "translations": [ - "Brak: %@", - "Brakuje: %@" - ] - }, - { - "locale": "th", - "source": "Missing: %@", - "translations": [ - "ขาด: %@", - "ขาดหาย: %@" - ] - }, - { - "locale": "ru", - "source": "Name", - "translations": [ - "Имя", - "Название" - ] - }, - { - "locale": "ar", - "source": "Needs attention", - "translations": [ - "يتطلب الانتباه", - "يحتاج إلى انتباه" - ] - }, - { - "locale": "de", - "source": "Needs attention", - "translations": [ - "Aufmerksamkeit erforderlich", - "Erfordert Aufmerksamkeit" - ] - }, - { - "locale": "es", - "source": "Needs attention", - "translations": [ - "Necesita atención", - "Requiere atención" - ] - }, - { - "locale": "fa", - "source": "Needs attention", - "translations": [ - "نیاز به توجه دارد", - "نیازمند توجه" - ] - }, - { - "locale": "fr", - "source": "Needs attention", - "translations": [ - "Nécessite une attention", - "Nécessite votre attention" - ] - }, - { - "locale": "ja-JP", - "source": "Needs attention", - "translations": [ - "対応が必要", - "対応が必要です" - ] - }, - { - "locale": "nl", - "source": "Needs attention", - "translations": [ - "Aandacht vereist", - "Vereist aandacht" - ] - }, - { - "locale": "pt-BR", - "source": "Needs attention", - "translations": [ - "Precisa de atenção", - "Requer atenção" - ] - }, - { - "locale": "sv", - "source": "Needs attention", - "translations": [ - "Kräver uppmärksamhet", - "Kräver åtgärd" - ] - }, - { - "locale": "th", - "source": "Needs attention", - "translations": [ - "ต้องการการดูแล", - "ต้องการความสนใจ", - "ต้องตรวจสอบ" - ] - }, - { - "locale": "tr", - "source": "Needs attention", - "translations": [ - "Dikkat gerekiyor", - "Dikkat gerektiriyor" - ] - }, - { - "locale": "zh-TW", - "source": "Needs attention", - "translations": [ - "需要注意", - "需要處理" - ] - }, - { - "locale": "zh-CN", - "source": "New Group", - "translations": [ - "新建组", - "新建群组" - ] - }, - { - "locale": "zh-CN", - "source": "New Group…", - "translations": [ - "新建分组…", - "新建组…" - ] - }, - { - "locale": "ar", - "source": "New Thread", - "translations": [ - "سلسلة محادثة جديدة", - "محادثة جديدة" - ] - }, - { - "locale": "fr", - "source": "New Thread", - "translations": [ - "Nouveau fil", - "Nouveau fil de discussion" - ] - }, - { - "locale": "ja-JP", - "source": "New Thread", - "translations": [ - "新しいスレッド", - "新規スレッド" - ] - }, - { - "locale": "nl", - "source": "New Thread", - "translations": [ - "Nieuw gesprek", - "Nieuwe thread" - ] - }, - { - "locale": "tr", - "source": "New Thread", - "translations": [ - "Yeni İleti Dizisi", - "Yeni İş Parçacığı" - ] - }, - { - "locale": "zh-CN", - "source": "New Thread", - "translations": [ - "新建对话", - "新建话题" - ] - }, - { - "locale": "ar", - "source": "No approvals waiting", - "translations": [ - "لا توجد موافقات بانتظار المراجعة", - "لا توجد موافقات قيد الانتظار" - ] - }, - { - "locale": "de", - "source": "No approvals waiting", - "translations": [ - "Keine Genehmigungen ausstehend", - "Keine ausstehenden Genehmigungen" - ] - }, - { - "locale": "es", - "source": "No approvals waiting", - "translations": [ - "No hay aprobaciones en espera", - "No hay aprobaciones pendientes" - ] - }, - { - "locale": "hi", - "source": "No approvals waiting", - "translations": [ - "कोई अनुमोदन प्रतीक्षा में नहीं", - "कोई मंज़ूरी प्रतीक्षा में नहीं है" - ] - }, - { - "locale": "ja-JP", - "source": "No approvals waiting", - "translations": [ - "待機中の承認はありません", - "承認待ちはありません" - ] - }, - { - "locale": "nl", - "source": "No approvals waiting", - "translations": [ - "Geen goedkeuringen in afwachting", - "Geen goedkeuringen wachtend" - ] - }, - { - "locale": "pt-BR", - "source": "No approvals waiting", - "translations": [ - "Nenhuma aprovação aguardando", - "Nenhuma aprovação pendente" - ] - }, - { - "locale": "ru", - "source": "No approvals waiting", - "translations": [ - "Нет ожидающих одобрений", - "Нет ожидающих подтверждений" - ] - }, - { - "locale": "uk", - "source": "No approvals waiting", - "translations": [ - "Немає схвалень в очікуванні", - "Немає схвалень, що очікують" - ] - }, - { - "locale": "zh-CN", - "source": "No approvals waiting", - "translations": [ - "没有待处理的审批", - "没有等待中的审批" - ] - }, - { - "locale": "zh-TW", - "source": "No approvals waiting", - "translations": [ - "沒有待核准項目", - "沒有待處理的核准" - ] - }, - { - "locale": "hi", - "source": "No automations yet", - "translations": [ - "अभी कोई स्वचालन नहीं है", - "अभी तक कोई ऑटोमेशन नहीं है" - ] - }, - { - "locale": "ja-JP", - "source": "No automations yet", - "translations": [ - "オートメーションはまだありません", - "自動化はまだありません" - ] - }, - { - "locale": "sv", - "source": "No automations yet", - "translations": [ - "Inga automationer ännu", - "Inga automatiseringar ännu" - ] - }, - { - "locale": "th", - "source": "No automations yet", - "translations": [ - "ยังไม่มีการทำงานอัตโนมัติ", - "ยังไม่มีระบบอัตโนมัติ" - ] - }, - { - "locale": "vi", - "source": "No automations yet", - "translations": [ - "Chưa có tác vụ tự động", - "Chưa có tác vụ tự động hóa" - ] - }, - { - "locale": "zh-CN", - "source": "No automations yet", - "translations": [ - "暂无自动化", - "暂无自动化任务" - ] - }, - { - "locale": "ar", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "لا توجد رسائل دردشة بعد" - ] - }, - { - "locale": "de", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "Noch keine Chatnachrichten" - ] - }, - { - "locale": "es", - "source": "No chat messages yet", - "translations": [ - "Aún no hay mensajes de chat", - "No chat messages yet" - ] - }, - { - "locale": "fa", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "هنوز هیچ پیام گفت‌وگویی وجود ندارد" - ] - }, - { - "locale": "fr", - "source": "No chat messages yet", - "translations": [ - "Aucun message de chat pour le moment", - "No chat messages yet" - ] - }, - { - "locale": "hi", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "अभी तक कोई चैट संदेश नहीं" - ] - }, - { - "locale": "id", - "source": "No chat messages yet", - "translations": [ - "Belum ada pesan chat", - "No chat messages yet" - ] - }, - { - "locale": "it", - "source": "No chat messages yet", - "translations": [ - "Nessun messaggio di chat", - "No chat messages yet" - ] - }, - { - "locale": "ja-JP", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "チャットメッセージはまだありません" - ] - }, - { - "locale": "ko", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "아직 채팅 메시지가 없습니다" - ] - }, - { - "locale": "nl", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "Nog geen chatberichten" - ] - }, - { - "locale": "pl", - "source": "No chat messages yet", - "translations": [ - "Brak wiadomości na czacie", - "No chat messages yet" - ] - }, - { - "locale": "pt-BR", - "source": "No chat messages yet", - "translations": [ - "Ainda não há mensagens no chat", - "No chat messages yet" - ] - }, - { - "locale": "ru", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "Сообщений в чате пока нет" - ] - }, - { - "locale": "sv", - "source": "No chat messages yet", - "translations": [ - "Inga chattmeddelanden än", - "No chat messages yet" - ] - }, - { - "locale": "th", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "ยังไม่มีข้อความแชท" - ] - }, - { - "locale": "tr", - "source": "No chat messages yet", - "translations": [ - "Henüz sohbet mesajı yok", - "No chat messages yet" - ] - }, - { - "locale": "uk", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "Повідомлень у чаті ще немає" - ] - }, - { - "locale": "vi", - "source": "No chat messages yet", - "translations": [ - "Chưa có tin nhắn trò chuyện nào", - "No chat messages yet" - ] - }, - { - "locale": "zh-CN", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "暂无聊天消息" - ] - }, - { - "locale": "zh-TW", - "source": "No chat messages yet", - "translations": [ - "No chat messages yet", - "尚無聊天訊息" - ] - }, - { - "locale": "de", - "source": "No recent sessions", - "translations": [ - "Keine kürzlich verwendeten Sitzungen", - "Keine letzten Sitzungen", - "No recent sessions" - ] - }, - { - "locale": "es", - "source": "No recent sessions", - "translations": [ - "No hay sesiones recientes", - "No recent sessions" - ] - }, - { - "locale": "fa", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "جلسه اخیری وجود ندارد", - "نشست اخیری وجود ندارد" - ] - }, - { - "locale": "fr", - "source": "No recent sessions", - "translations": [ - "Aucune session récente", - "No recent sessions" - ] - }, - { - "locale": "hi", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "कोई हालिया सत्र नहीं" - ] - }, - { - "locale": "id", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "Tidak ada sesi terbaru" - ] - }, - { - "locale": "ja-JP", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "最近のセッションはありません" - ] - }, - { - "locale": "ko", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "최근 세션 없음" - ] - }, - { - "locale": "nl", - "source": "No recent sessions", - "translations": [ - "Geen recente sessies", - "No recent sessions" - ] - }, - { - "locale": "pl", - "source": "No recent sessions", - "translations": [ - "Brak ostatnich sesji", - "No recent sessions" - ] - }, - { - "locale": "pt-BR", - "source": "No recent sessions", - "translations": [ - "Nenhuma sessão recente", - "No recent sessions" - ] - }, - { - "locale": "ru", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "Нет недавних сеансов" - ] - }, - { - "locale": "sv", - "source": "No recent sessions", - "translations": [ - "Inga senaste sessioner", - "No recent sessions" - ] - }, - { - "locale": "th", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "ไม่มีเซสชันล่าสุด" - ] - }, - { - "locale": "tr", - "source": "No recent sessions", - "translations": [ - "Son oturum yok", - "Yakın zamanda oturum yok" - ] - }, - { - "locale": "uk", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "Немає недавніх сеансів", - "Немає нещодавніх сеансів" - ] - }, - { - "locale": "zh-TW", - "source": "No recent sessions", - "translations": [ - "No recent sessions", - "沒有最近的工作階段" - ] - }, - { - "locale": "ar", - "source": "No skills found", - "translations": [ - "لم يتم العثور على Skills", - "لم يتم العثور على مهارات" - ] - }, - { - "locale": "fa", - "source": "No skills found", - "translations": [ - "هیچ Skillsی پیدا نشد", - "هیچ Skillی یافت نشد" - ] - }, - { - "locale": "hi", - "source": "No skills found", - "translations": [ - "कोई Skill नहीं मिला", - "कोई skills नहीं मिलीं" - ] - }, - { - "locale": "id", - "source": "No skills found", - "translations": [ - "Tidak ada skill yang ditemukan", - "Tidak ada skills ditemukan" - ] - }, - { - "locale": "ko", - "source": "No skills found", - "translations": [ - "Skills를 찾을 수 없음", - "스킬을 찾을 수 없음" - ] - }, - { - "locale": "nl", - "source": "No skills found", - "translations": [ - "Geen Skills gevonden", - "Geen skills gevonden" - ] - }, - { - "locale": "pl", - "source": "No skills found", - "translations": [ - "Nie znaleziono Skills", - "Nie znaleziono umiejętności" - ] - }, - { - "locale": "pt-BR", - "source": "No skills found", - "translations": [ - "Nenhuma Skill encontrada", - "Nenhuma skill encontrada" - ] - }, - { - "locale": "ru", - "source": "No skills found", - "translations": [ - "Skills не найдены", - "Навыки не найдены" - ] - }, - { - "locale": "sv", - "source": "No skills found", - "translations": [ - "Inga Skills hittades", - "Inga färdigheter hittades" - ] - }, - { - "locale": "zh-CN", - "source": "No skills found", - "translations": [ - "未找到 Skills", - "未找到技能" - ] - }, - { - "locale": "nl", - "source": "Node", - "translations": [ - "Knooppunt", - "Node" - ] - }, - { - "locale": "th", - "source": "Not Now", - "translations": [ - "ยังไม่ใช่ตอนนี้", - "ไม่ใช่ตอนนี้" - ] - }, - { - "locale": "fa", - "source": "Not active", - "translations": [ - "غیرفعال", - "فعال نیست" - ] - }, - { - "locale": "ko", - "source": "Not active", - "translations": [ - "비활성", - "활성 상태 아님" - ] - }, - { - "locale": "pl", - "source": "Not active", - "translations": [ - "Nieaktywne", - "Nieaktywny" - ] - }, - { - "locale": "uk", - "source": "Not active", - "translations": [ - "Неактивний", - "Неактивно" - ] - }, - { - "locale": "zh-CN", - "source": "Not active", - "translations": [ - "未启用", - "未激活" - ] - }, - { - "locale": "es", - "source": "Not selected", - "translations": [ - "No seleccionada", - "No seleccionado" - ] - }, - { - "locale": "sv", - "source": "Not selected", - "translations": [ - "Inte vald", - "Inte valt" - ] - }, - { - "locale": "de", - "source": "Notifications", - "translations": [ - "Benachrichtigungen", - "Mitteilungen" - ] - }, - { - "locale": "sv", - "source": "Notifications", - "translations": [ - "Aviseringar", - "Notiser" - ] - }, - { - "locale": "de", - "source": "Notifications are off", - "translations": [ - "Benachrichtigungen sind deaktiviert", - "Mitteilungen sind deaktiviert" - ] - }, - { - "locale": "ja-JP", - "source": "Notifications are off", - "translations": [ - "通知がオフです", - "通知はオフです" - ] - }, - { - "locale": "ar", - "source": "OK", - "translations": [ - "حسنًا", - "موافق" - ] - }, - { - "locale": "ru", - "source": "OK", - "translations": [ - "OK", - "ОК" - ] - }, - { - "locale": "zh-CN", - "source": "OK", - "translations": [ - "好", - "确定" - ] - }, - { - "locale": "zh-TW", - "source": "OK", - "translations": [ - "好", - "確定" - ] - }, - { - "locale": "ar", - "source": "Off", - "translations": [ - "إيقاف", - "متوقف" - ] - }, - { - "locale": "es", - "source": "Off", - "translations": [ - "Desactivada", - "Desactivado" - ] - }, - { - "locale": "id", - "source": "Off", - "translations": [ - "Mati", - "Nonaktif" - ] - }, - { - "locale": "it", - "source": "Off", - "translations": [ - "Disattivata", - "Disattivato", - "Disattivo" - ] - }, - { - "locale": "ko", - "source": "Off", - "translations": [ - "꺼짐", - "끄기", - "끔" - ] - }, - { - "locale": "pl", - "source": "Off", - "translations": [ - "Wył.", - "Wyłączone" - ] - }, - { - "locale": "pt-BR", - "source": "Off", - "translations": [ - "Desativada", - "Desativado", - "Desligado" - ] - }, - { - "locale": "ru", - "source": "Off", - "translations": [ - "Выкл.", - "Выключено" - ] - }, - { - "locale": "uk", - "source": "Off", - "translations": [ - "Вимк.", - "Вимкнено" - ] - }, - { - "locale": "zh-TW", - "source": "Off", - "translations": [ - "關", - "關閉" - ] - }, - { - "locale": "ar", - "source": "Offline", - "translations": [ - "Offline", - "غير متصل" - ] - }, - { - "locale": "es", - "source": "Offline", - "translations": [ - "Offline", - "Sin conexión" - ] - }, - { - "locale": "fa", - "source": "Offline", - "translations": [ - "Offline", - "آفلاین" - ] - }, - { - "locale": "fr", - "source": "Offline", - "translations": [ - "Hors ligne", - "Offline" - ] - }, - { - "locale": "hi", - "source": "Offline", - "translations": [ - "Offline", - "ऑफ़लाइन" - ] - }, - { - "locale": "id", - "source": "Offline", - "translations": [ - "Luring", - "Offline" - ] - }, - { - "locale": "ja-JP", - "source": "Offline", - "translations": [ - "Offline", - "オフライン" - ] - }, - { - "locale": "ko", - "source": "Offline", - "translations": [ - "Offline", - "오프라인" - ] - }, - { - "locale": "pt-BR", - "source": "Offline", - "translations": [ - "Off-line", - "Offline" - ] - }, - { - "locale": "ru", - "source": "Offline", - "translations": [ - "Offline", - "Не в сети", - "Офлайн" - ] - }, - { - "locale": "th", - "source": "Offline", - "translations": [ - "Offline", - "ออฟไลน์" - ] - }, - { - "locale": "tr", - "source": "Offline", - "translations": [ - "Offline", - "Çevrimdışı" - ] - }, - { - "locale": "uk", - "source": "Offline", - "translations": [ - "Offline", - "Офлайн" - ] - }, - { - "locale": "vi", - "source": "Offline", - "translations": [ - "Ngoại tuyến", - "Offline" - ] - }, - { - "locale": "zh-CN", - "source": "Offline", - "translations": [ - "Offline", - "离线" - ] - }, - { - "locale": "zh-TW", - "source": "Offline", - "translations": [ - "Offline", - "離線" - ] - }, - { - "locale": "it", - "source": "On", - "translations": [ - "Attivato", - "Attivo" - ] - }, - { - "locale": "ko", - "source": "On", - "translations": [ - "켜기", - "켜짐", - "켬" - ] - }, - { - "locale": "pl", - "source": "On", - "translations": [ - "Wł.", - "Włączone" - ] - }, - { - "locale": "pt-BR", - "source": "On", - "translations": [ - "Ativado", - "Ligado" - ] - }, - { - "locale": "uk", - "source": "On", - "translations": [ - "Увімк.", - "Увімкнено" - ] - }, - { - "locale": "zh-TW", - "source": "On", - "translations": [ - "開", - "開啟" - ] - }, - { - "locale": "ja-JP", - "source": "One time", - "translations": [ - "1回のみ", - "1回限り" - ] - }, - { - "locale": "zh-CN", - "source": "One time", - "translations": [ - "一次", - "一次性" - ] - }, - { - "locale": "ar", - "source": "Online", - "translations": [ - "متصل", - "متصل بالإنترنت" - ] - }, - { - "locale": "pt-BR", - "source": "Online", - "translations": [ - "On-line", - "Online" - ] - }, - { - "locale": "ru", - "source": "Online", - "translations": [ - "В сети", - "Онлайн" - ] - }, - { - "locale": "zh-TW", - "source": "Online", - "translations": [ - "在線上", - "線上" - ] - }, - { - "locale": "hi", - "source": "Open Chat", - "translations": [ - "Chat खोलें", - "चैट खोलें" - ] - }, - { - "locale": "id", - "source": "Open Chat", - "translations": [ - "Buka Chat", - "Buka Obrolan" - ] - }, - { - "locale": "it", - "source": "Open Chat", - "translations": [ - "Apri Chat", - "Apri chat" - ] - }, - { - "locale": "pt-BR", - "source": "Open Chat", - "translations": [ - "Abrir Chat", - "Abrir chat" - ] - }, - { - "locale": "th", - "source": "Open Chat", - "translations": [ - "เปิดแชต", - "เปิดแชท" - ] - }, - { - "locale": "tr", - "source": "Open Chat", - "translations": [ - "Chat'i Aç", - "Sohbeti Aç" - ] - }, - { - "locale": "es", - "source": "Open Settings", - "translations": [ - "Abrir Ajustes", - "Abrir configuración" - ] - }, - { - "locale": "fr", - "source": "Open Settings", - "translations": [ - "Ouvrir Réglages", - "Ouvrir les paramètres" - ] - }, - { - "locale": "hi", - "source": "Open Settings", - "translations": [ - "Settings खोलें", - "सेटिंग्स खोलें" - ] - }, - { - "locale": "it", - "source": "Open Settings", - "translations": [ - "Apri Impostazioni", - "Apri impostazioni" - ] - }, - { - "locale": "nl", - "source": "Open Settings", - "translations": [ - "Instellingen openen", - "Open Instellingen" - ] - }, - { - "locale": "pl", - "source": "Open Settings", - "translations": [ - "Otwórz Ustawienia", - "Otwórz ustawienia" - ] - }, - { - "locale": "pt-BR", - "source": "Open Settings", - "translations": [ - "Abrir Ajustes", - "Abrir configurações" - ] - }, - { - "locale": "ru", - "source": "Open Settings", - "translations": [ - "Открыть Настройки", - "Открыть настройки" - ] - }, - { - "locale": "sv", - "source": "Open Settings", - "translations": [ - "Öppna Inställningar", - "Öppna inställningar" - ] - }, - { - "locale": "uk", - "source": "Open Settings", - "translations": [ - "Відкрити Параметри", - "Відкрити налаштування" - ] - }, - { - "locale": "vi", - "source": "Open Settings", - "translations": [ - "Mở Cài đặt", - "Mở cài đặt" - ] - }, - { - "locale": "zh-TW", - "source": "Open Settings", - "translations": [ - "開啟「設定」", - "開啟設定" - ] - }, - { - "locale": "ar", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "لا يستطيع OpenClaw تحديد إذن الموقع الحالي في iOS.", - "يتعذّر على OpenClaw تحديد إذن الموقع الحالي في iOS." - ] - }, - { - "locale": "fr", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw ne peut pas déterminer l'autorisation de localisation iOS actuelle.", - "OpenClaw ne peut pas déterminer l’autorisation de localisation iOS actuelle." - ] - }, - { - "locale": "hi", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw वर्तमान iOS लोकेशन अनुमति निर्धारित नहीं कर सकता।", - "OpenClaw वर्तमान iOS स्थान अनुमति निर्धारित नहीं कर सकता।" - ] - }, - { - "locale": "it", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw non può determinare l’autorizzazione alla posizione iOS attuale.", - "OpenClaw non riesce a determinare l'attuale autorizzazione alla posizione di iOS." - ] - }, - { - "locale": "ja-JP", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw は現在の iOS 位置情報の権限を確認できません。", - "OpenClaw は現在の iOS 位置情報許可を判別できません。" - ] - }, - { - "locale": "ru", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw не может определить текущее разрешение iOS на доступ к местоположению.", - "OpenClaw не удается определить текущее разрешение iOS на доступ к геопозиции." - ] - }, - { - "locale": "th", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw ไม่สามารถระบุการอนุญาตตำแหน่งที่ตั้งของ iOS ในปัจจุบันได้", - "OpenClaw ไม่สามารถระบุการอนุญาตตำแหน่งที่ตั้งปัจจุบันของ iOS ได้" - ] - }, - { - "locale": "tr", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw geçerli iOS konum iznini belirleyemiyor.", - "OpenClaw mevcut iOS konum iznini belirleyemiyor." - ] - }, - { - "locale": "uk", - "source": "OpenClaw cannot determine the current iOS location permission.", - "translations": [ - "OpenClaw не може визначити поточний дозвіл iOS на геолокацію.", - "OpenClaw не може визначити поточний дозвіл iOS на доступ до геолокації." - ] - }, - { - "locale": "ko", - "source": "OpenClaw unavailable", - "translations": [ - "OpenClaw를 사용할 수 없습니다", - "OpenClaw을 사용할 수 없습니다" - ] - }, - { - "locale": "ar", - "source": "Opens Settings / Gateway", - "translations": [ - "يفتح Settings / Gateway", - "يفتح الإعدادات / Gateway" - ] - }, - { - "locale": "fa", - "source": "Opens Settings / Gateway", - "translations": [ - "Settings / Gateway را باز می‌کند", - "تنظیمات / Gateway را باز می‌کند" - ] - }, - { - "locale": "id", - "source": "Opens Settings / Gateway", - "translations": [ - "Membuka Pengaturan / Gateway", - "Membuka Settings / Gateway" - ] - }, - { - "locale": "ja-JP", - "source": "Opens Settings / Gateway", - "translations": [ - "設定 / Gateway を開きます", - "設定 / Gateway を開く", - "設定 / Gatewayを開く" - ] - }, - { - "locale": "ko", - "source": "Opens Settings / Gateway", - "translations": [ - "Settings / Gateway 열기", - "설정 / Gateway 열기", - "설정 / Gateway를 엽니다" - ] - }, - { - "locale": "pt-BR", - "source": "Opens Settings / Gateway", - "translations": [ - "Abre Ajustes / Gateway", - "Abre Configurações / Gateway" - ] - }, - { - "locale": "ru", - "source": "Opens Settings / Gateway", - "translations": [ - "Открывает Settings / Gateway", - "Открывает «Настройки / Gateway»", - "Открывает Настройки / Gateway" - ] - }, - { - "locale": "th", - "source": "Opens Settings / Gateway", - "translations": [ - "เปิด Settings / Gateway", - "เปิดการตั้งค่า / Gateway" - ] - }, - { - "locale": "tr", - "source": "Opens Settings / Gateway", - "translations": [ - "Ayarlar / Gateway bölümünü açar", - "Ayarlar / Gateway'i açar", - "Ayarlar / Gateway’i açar" - ] - }, - { - "locale": "uk", - "source": "Opens Settings / Gateway", - "translations": [ - "Відкриває Settings / Gateway", - "Відкриває Налаштування / Gateway" - ] - }, - { - "locale": "vi", - "source": "Opens Settings / Gateway", - "translations": [ - "Mở Cài đặt / Gateway", - "Mở Settings / Gateway" - ] - }, - { - "locale": "zh-CN", - "source": "Opens Settings / Gateway", - "translations": [ - "打开“设置”/ Gateway", - "打开“设置”/“Gateway”", - "打开设置 / Gateway" - ] - }, - { - "locale": "zh-TW", - "source": "Opens Settings / Gateway", - "translations": [ - "開啟 Settings / Gateway", - "開啟「設定」/ Gateway", - "開啟設定 / Gateway" - ] - }, - { - "locale": "tr", - "source": "Overview", - "translations": [ - "Genel Bakış", - "Genel bakış" - ] - }, - { - "locale": "zh-TW", - "source": "Overview", - "translations": [ - "概覽", - "總覽" - ] - }, - { - "locale": "ar", - "source": "Pairing", - "translations": [ - "الإقران", - "جارٍ الاقتران" - ] - }, - { - "locale": "es", - "source": "Pairing", - "translations": [ - "Emparejamiento", - "Enlace" - ] - }, - { - "locale": "fa", - "source": "Pairing", - "translations": [ - "جفت‌سازی", - "در حال جفت‌سازی" - ] - }, - { - "locale": "id", - "source": "Pairing", - "translations": [ - "Pemasangan", - "Penyandingan" - ] - }, - { - "locale": "ja-JP", - "source": "Pairing", - "translations": [ - "ペアリング", - "ペアリング中" - ] - }, - { - "locale": "ko", - "source": "Pairing", - "translations": [ - "페어링", - "페어링 중" - ] - }, - { - "locale": "pt-BR", - "source": "Pairing", - "translations": [ - "Emparelhando", - "Pareamento" - ] - }, - { - "locale": "sv", - "source": "Pairing", - "translations": [ - "Parkopplar", - "Parkoppling" - ] - }, - { - "locale": "th", - "source": "Pairing", - "translations": [ - "การจับคู่", - "กำลังจับคู่" - ] - }, - { - "locale": "tr", - "source": "Pairing", - "translations": [ - "Eşleştiriliyor", - "Eşleştirme" - ] - }, - { - "locale": "vi", - "source": "Pairing", - "translations": [ - "Ghép nối", - "Đang ghép đôi" - ] - }, - { - "locale": "zh-CN", - "source": "Pairing", - "translations": [ - "正在配对", - "配对" - ] - }, - { - "locale": "zh-TW", - "source": "Pairing", - "translations": [ - "配對", - "配對中" - ] - }, - { - "locale": "de", - "source": "Pause", - "translations": [ - "Pause", - "Pausieren" - ] - }, - { - "locale": "fa", - "source": "Pause", - "translations": [ - "توقف", - "توقف موقت", - "مکث" - ] - }, - { - "locale": "fr", - "source": "Pause", - "translations": [ - "Mettre en pause", - "Pause", - "Suspendre" - ] - }, - { - "locale": "it", - "source": "Pause", - "translations": [ - "Metti in pausa", - "Pausa" - ] - }, - { - "locale": "ko", - "source": "Pause", - "translations": [ - "일시 중지", - "일시정지" - ] - }, - { - "locale": "ru", - "source": "Pause", - "translations": [ - "Пауза", - "Приостановить" - ] - }, - { - "locale": "uk", - "source": "Pause", - "translations": [ - "Пауза", - "Призупинити" - ] - }, - { - "locale": "ar", - "source": "Paused", - "translations": [ - "متوقف مؤقتًا", - "متوقفة مؤقتًا" - ] - }, - { - "locale": "hi", - "source": "Paused", - "translations": [ - "रोका गया", - "रोके गए" - ] - }, - { - "locale": "ko", - "source": "Paused", - "translations": [ - "일시 정지됨", - "일시 중지됨" - ] - }, - { - "locale": "pl", - "source": "Paused", - "translations": [ - "Wstrzymane", - "Wstrzymano" - ] - }, - { - "locale": "pt-BR", - "source": "Paused", - "translations": [ - "Pausadas", - "Pausado" - ] - }, - { - "locale": "ru", - "source": "Paused", - "translations": [ - "Приостановленные", - "Приостановлено" - ] - }, - { - "locale": "sv", - "source": "Paused", - "translations": [ - "Pausad", - "Pausade" - ] - }, - { - "locale": "uk", - "source": "Paused", - "translations": [ - "Призупинено", - "Призупинені" - ] - }, - { - "locale": "de", - "source": "Pin", - "translations": [ - "Anheften", - "Anpinnen" - ] - }, - { - "locale": "nl", - "source": "Pin", - "translations": [ - "Vastmaken", - "Vastzetten" - ] - }, - { - "locale": "ar", - "source": "Pinned", - "translations": [ - "مثبّت", - "مثبّتة" - ] - }, - { - "locale": "es", - "source": "Pinned", - "translations": [ - "Fijada", - "Fijadas", - "Fijado" - ] - }, - { - "locale": "fr", - "source": "Pinned", - "translations": [ - "Épinglé", - "Épinglée" - ] - }, - { - "locale": "it", - "source": "Pinned", - "translations": [ - "Fissata", - "Fissati", - "Fissato", - "In evidenza" - ] - }, - { - "locale": "ja-JP", - "source": "Pinned", - "translations": [ - "ピン留め", - "ピン留め済み" - ] - }, - { - "locale": "pl", - "source": "Pinned", - "translations": [ - "Przypięta", - "Przypięte" - ] - }, - { - "locale": "pt-BR", - "source": "Pinned", - "translations": [ - "Fixada", - "Fixado", - "Fixados" - ] - }, - { - "locale": "ru", - "source": "Pinned", - "translations": [ - "Закреплено", - "Закреплённые" - ] - }, - { - "locale": "sv", - "source": "Pinned", - "translations": [ - "Fäst", - "Fästa" - ] - }, - { - "locale": "tr", - "source": "Pinned", - "translations": [ - "Sabitlendi", - "Sabitlenmiş" - ] - }, - { - "locale": "uk", - "source": "Pinned", - "translations": [ - "Закріплено", - "Закріплені" - ] - }, - { - "locale": "zh-CN", - "source": "Pinned", - "translations": [ - "已固定", - "已置顶" - ] - }, - { - "locale": "ar", - "source": "Platform", - "translations": [ - "المنصة", - "النظام الأساسي" - ] - }, - { - "locale": "hi", - "source": "Platform", - "translations": [ - "प्लेटफ़ॉर्म", - "प्लैटफ़ॉर्म" - ] - }, - { - "locale": "fa", - "source": "Port", - "translations": [ - "درگاه", - "پورت" - ] - }, - { - "locale": "hi", - "source": "Port", - "translations": [ - "Port", - "पोर्ट" - ] - }, - { - "locale": "tr", - "source": "Port", - "translations": [ - "Bağlantı Noktası", - "Port" - ] - }, - { - "locale": "ko", - "source": "Provider", - "translations": [ - "제공업체", - "제공자" - ] - }, - { - "locale": "ru", - "source": "Provider", - "translations": [ - "Поставщик", - "Провайдер" - ] - }, - { - "locale": "zh-CN", - "source": "Provider", - "translations": [ - "提供商", - "提供方" - ] - }, - { - "locale": "ja-JP", - "source": "QR Scanner Unavailable", - "translations": [ - "QR スキャナーを利用できません", - "QRスキャナーを利用できません" - ] - }, - { - "locale": "pl", - "source": "QR Scanner Unavailable", - "translations": [ - "Skaner QR jest niedostępny", - "Skaner QR niedostępny" - ] - }, - { - "locale": "sv", - "source": "QR Scanner Unavailable", - "translations": [ - "QR-skanner inte tillgänglig", - "QR-skanner är inte tillgänglig" - ] - }, - { - "locale": "th", - "source": "QR Scanner Unavailable", - "translations": [ - "สแกนเนอร์ QR ไม่พร้อมใช้งาน", - "ไม่สามารถใช้เครื่องสแกน QR ได้" - ] - }, - { - "locale": "uk", - "source": "QR Scanner Unavailable", - "translations": [ - "QR-сканер недоступний", - "Сканер QR-кодів недоступний" - ] - }, - { - "locale": "zh-CN", - "source": "QR Scanner Unavailable", - "translations": [ - "QR 扫描器不可用", - "二维码扫描器不可用" - ] - }, - { - "locale": "fr", - "source": "Queued", - "translations": [ - "En attente", - "En file d'attente" - ] - }, - { - "locale": "id", - "source": "Queued", - "translations": [ - "Dalam Antrean", - "Dalam antrean" - ] - }, - { - "locale": "tr", - "source": "Queued", - "translations": [ - "Sırada", - "Sıraya alındı" - ] - }, - { - "locale": "vi", - "source": "Queued", - "translations": [ - "Trong hàng đợi", - "Đang chờ" - ] - }, - { - "locale": "it", - "source": "Ready", - "translations": [ - "Pronta", - "Pronto" - ] - }, - { - "locale": "nl", - "source": "Ready", - "translations": [ - "Gereed", - "Klaar" - ] - }, - { - "locale": "sv", - "source": "Ready", - "translations": [ - "Klar", - "Redo" - ] - }, - { - "locale": "ar", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "الصوت الفوري" - ] - }, - { - "locale": "de", - "source": "Realtime Voice", - "translations": [ - "Echtzeitstimme", - "Realtime Voice" - ] - }, - { - "locale": "es", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Voz en tiempo real" - ] - }, - { - "locale": "fa", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "صدای بلادرنگ" - ] - }, - { - "locale": "fr", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Voix en temps réel" - ] - }, - { - "locale": "hi", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "रीयलटाइम वॉइस" - ] - }, - { - "locale": "id", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Suara Realtime" - ] - }, - { - "locale": "it", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Voce in tempo reale" - ] - }, - { - "locale": "ja-JP", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "リアルタイム音声" - ] - }, - { - "locale": "ko", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "실시간 음성" - ] - }, - { - "locale": "pl", - "source": "Realtime Voice", - "translations": [ - "Głos w czasie rzeczywistym", - "Realtime Voice" - ] - }, - { - "locale": "pt-BR", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Voz em tempo real" - ] - }, - { - "locale": "ru", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Голос в реальном времени" - ] - }, - { - "locale": "sv", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Röst i realtid" - ] - }, - { - "locale": "th", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "เสียงแบบเรียลไทม์" - ] - }, - { - "locale": "tr", - "source": "Realtime Voice", - "translations": [ - "Gerçek Zamanlı Ses", - "Realtime Voice" - ] - }, - { - "locale": "uk", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "Голос у реальному часі" - ] - }, - { - "locale": "vi", - "source": "Realtime Voice", - "translations": [ - "Giọng nói thời gian thực", - "Realtime Voice" - ] - }, - { - "locale": "zh-CN", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "实时语音" - ] - }, - { - "locale": "zh-TW", - "source": "Realtime Voice", - "translations": [ - "Realtime Voice", - "即時語音" - ] - }, - { - "locale": "es", - "source": "Recent", - "translations": [ - "Reciente", - "Recientes" - ] - }, - { - "locale": "fr", - "source": "Recent", - "translations": [ - "Récent", - "Récents" - ] - }, - { - "locale": "hi", - "source": "Recent", - "translations": [ - "हाल के", - "हाल ही के", - "हालिया" - ] - }, - { - "locale": "tr", - "source": "Recent", - "translations": [ - "Son", - "Son Kullanılanlar" - ] - }, - { - "locale": "uk", - "source": "Recent", - "translations": [ - "Нещодавні", - "Останні" - ] - }, - { - "locale": "zh-TW", - "source": "Recent", - "translations": [ - "最近", - "最近使用" - ] - }, - { - "locale": "es", - "source": "Reconnect", - "translations": [ - "Reconectar", - "Volver a conectar" - ] - }, - { - "locale": "ru", - "source": "Reconnect", - "translations": [ - "Повторно подключиться", - "Подключиться повторно" - ] - }, - { - "locale": "sv", - "source": "Reconnect", - "translations": [ - "Anslut igen", - "Återanslut" - ] - }, - { - "locale": "uk", - "source": "Reconnect", - "translations": [ - "Повторно підключити", - "Підключитися повторно" - ] - }, - { - "locale": "hi", - "source": "Reconnecting…", - "translations": [ - "फिर से कनेक्ट किया जा रहा है…", - "फिर से कनेक्ट हो रहा है…" - ] - }, - { - "locale": "id", - "source": "Reconnecting…", - "translations": [ - "Menghubungkan kembali…", - "Menghubungkan ulang…" - ] - }, - { - "locale": "it", - "source": "Reconnecting…", - "translations": [ - "Riconnessione in corso…", - "Riconnessione…" - ] - }, - { - "locale": "ko", - "source": "Reconnecting…", - "translations": [ - "다시 연결 중…", - "다시 연결하는 중…", - "재연결 중…" - ] - }, - { - "locale": "nl", - "source": "Reconnecting…", - "translations": [ - "Opnieuw verbinden…", - "Opnieuw verbinding maken…" - ] - }, - { - "locale": "sv", - "source": "Reconnecting…", - "translations": [ - "Ansluter igen…", - "Återansluter…" - ] - }, - { - "locale": "tr", - "source": "Reconnecting…", - "translations": [ - "Yeniden bağlanılıyor…", - "Yeniden bağlanıyor…" - ] - }, - { - "locale": "zh-TW", - "source": "Reconnecting…", - "translations": [ - "正在重新連線…", - "重新連線中…" - ] - }, - { - "locale": "fa", - "source": "Refresh", - "translations": [ - "بازآوری", - "بازخوانی", - "تازه‌سازی" - ] - }, - { - "locale": "hi", - "source": "Refresh", - "translations": [ - "रिफ्रेश करें", - "रीफ़्रेश करें" - ] - }, - { - "locale": "fa", - "source": "Remote Domain", - "translations": [ - "دامنه راه دور", - "دامنهٔ راه دور" - ] - }, - { - "locale": "ar", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "أزل المرفقات أو انتظر حتى يكتمل التسليم قبل بدء محادثة جديدة.", - "أزِل المرفقات أو انتظر اكتمال التسليم قبل بدء محادثة جديدة." - ] - }, - { - "locale": "de", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Entferne Anhänge oder warte, bis die Zustellung abgeschlossen ist, bevor du einen neuen Chat startest.", - "Entfernen Sie Anhänge oder warten Sie auf die Zustellung, bevor Sie einen neuen Chat starten." - ] - }, - { - "locale": "es", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Elimina los adjuntos o espera a que se complete la entrega antes de iniciar un nuevo chat.", - "Elimina los archivos adjuntos o espera a que se complete la entrega antes de iniciar un nuevo chat." - ] - }, - { - "locale": "fa", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "پیش از شروع گفتگوی جدید، پیوست‌ها را حذف کنید یا منتظر تکمیل ارسال بمانید.", - "پیش از شروع گفت‌وگوی جدید، پیوست‌ها را حذف کنید یا منتظر بمانید تا ارسال آن‌ها تکمیل شود." - ] - }, - { - "locale": "fr", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Supprimez les pièces jointes ou attendez la fin de leur envoi avant de démarrer une nouvelle discussion.", - "Supprimez les pièces jointes ou attendez la livraison avant de démarrer une nouvelle conversation." - ] - }, - { - "locale": "hi", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "नई चैट शुरू करने से पहले अटैचमेंट हटाएँ या डिलीवरी पूरी होने की प्रतीक्षा करें।", - "नई चैट शुरू करने से पहले अटैचमेंट हटाएं या डिलीवरी पूरी होने की प्रतीक्षा करें।" - ] - }, - { - "locale": "id", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Hapus lampiran atau tunggu hingga pengiriman selesai sebelum memulai obrolan baru.", - "Hapus lampiran atau tunggu pengiriman selesai sebelum memulai obrolan baru." - ] - }, - { - "locale": "ja-JP", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "新しいチャットを開始する前に、添付ファイルを削除するか、配信が完了するまでお待ちください。", - "新しいチャットを開始する前に、添付ファイルを削除するか送信が完了するのを待ってください。" - ] - }, - { - "locale": "nl", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Verwijder bijlagen of wacht tot de bezorging is afgerond voordat je een nieuwe chat start.", - "Verwijder bijlagen of wacht tot de bezorging is voltooid voordat je een nieuwe chat start." - ] - }, - { - "locale": "pt-BR", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Remova os anexos ou aguarde a conclusão da entrega antes de iniciar uma nova conversa.", - "Remova os anexos ou aguarde a entrega ser concluída antes de iniciar uma nova conversa." - ] - }, - { - "locale": "ru", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Удалите вложения или дождитесь доставки, прежде чем начинать новый чат.", - "Удалите вложения или дождитесь завершения доставки, прежде чем начинать новый чат." - ] - }, - { - "locale": "sv", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Ta bort bilagor eller vänta på att leveransen slutförs innan du startar en ny chatt.", - "Ta bort bilagor eller vänta tills leveransen är klar innan du startar en ny chatt." - ] - }, - { - "locale": "th", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "นำไฟล์แนบออกหรือรอให้การส่งเสร็จสิ้นก่อนเริ่มแชตใหม่", - "ลบไฟล์แนบหรือรอให้การส่งเสร็จสิ้นก่อนเริ่มแชทใหม่" - ] - }, - { - "locale": "uk", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Видаліть вкладення або дочекайтеся завершення доставки, перш ніж починати новий чат.", - "Видаліть вкладення або зачекайте на завершення доставлення, перш ніж починати новий чат." - ] - }, - { - "locale": "vi", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "Gỡ tệp đính kèm hoặc chờ gửi xong trước khi bắt đầu cuộc trò chuyện mới.", - "Xóa tệp đính kèm hoặc chờ quá trình gửi hoàn tất trước khi bắt đầu cuộc trò chuyện mới." - ] - }, - { - "locale": "zh-CN", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "请移除附件或等待发送完成后再开始新聊天。", - "请移除附件或等待发送完成后,再开始新聊天。" - ] - }, - { - "locale": "zh-TW", - "source": "Remove attachments or wait for delivery to resolve before starting a new chat.", - "translations": [ - "請先移除附件或等待傳送完成,再開始新的聊天。", - "請移除附件,或等待傳送問題解決後再開始新的聊天。" - ] - }, - { - "locale": "ar", - "source": "Rename", - "translations": [ - "إعادة التسمية", - "إعادة تسمية" - ] - }, - { - "locale": "es", - "source": "Rename", - "translations": [ - "Cambiar nombre", - "Renombrar" - ] - }, - { - "locale": "id", - "source": "Rename", - "translations": [ - "Ganti Nama", - "Ubah Nama", - "Ubah nama" - ] - }, - { - "locale": "ko", - "source": "Rename", - "translations": [ - "이름 바꾸기", - "이름 변경" - ] - }, - { - "locale": "nl", - "source": "Rename", - "translations": [ - "Hernoem", - "Hernoemen" - ] - }, - { - "locale": "es", - "source": "Rename Group", - "translations": [ - "Cambiar nombre del grupo", - "Renombrar grupo" - ] - }, - { - "locale": "id", - "source": "Rename Group", - "translations": [ - "Ganti Nama Grup", - "Ubah Nama Grup" - ] - }, - { - "locale": "nl", - "source": "Rename Group", - "translations": [ - "Groep hernoemen", - "Groepsnaam wijzigen" - ] - }, - { - "locale": "ar", - "source": "Rename Thread", - "translations": [ - "إعادة تسمية المحادثة", - "إعادة تسمية سلسلة المحادثة" - ] - }, - { - "locale": "es", - "source": "Rename Thread", - "translations": [ - "Cambiar el nombre del hilo", - "Renombrar hilo" - ] - }, - { - "locale": "fr", - "source": "Rename Thread", - "translations": [ - "Renommer le fil", - "Renommer le fil de discussion" - ] - }, - { - "locale": "nl", - "source": "Rename Thread", - "translations": [ - "Gesprek hernoemen", - "Thread hernoemen" - ] - }, - { - "locale": "tr", - "source": "Rename Thread", - "translations": [ - "İleti Dizisini Yeniden Adlandır", - "İş Parçacığını Yeniden Adlandır" - ] - }, - { - "locale": "zh-CN", - "source": "Rename Thread", - "translations": [ - "重命名对话", - "重命名话题" - ] - }, - { - "locale": "ar", - "source": "Rename…", - "translations": [ - "إعادة التسمية…", - "إعادة تسمية…" - ] - }, - { - "locale": "es", - "source": "Rename…", - "translations": [ - "Cambiar nombre…", - "Renombrar…" - ] - }, - { - "locale": "id", - "source": "Rename…", - "translations": [ - "Ganti nama…", - "Ubah nama…" - ] - }, - { - "locale": "nl", - "source": "Rename…", - "translations": [ - "Hernoemen…", - "Naam wijzigen…" - ] - }, - { - "locale": "ja-JP", - "source": "Request ID: %@", - "translations": [ - "リクエストID: %@", - "リクエストID:%@" - ] - }, - { - "locale": "sv", - "source": "Request ID: %@", - "translations": [ - "Begärande-ID: %@", - "Förfrågnings-ID: %@" - ] - }, - { - "locale": "uk", - "source": "Request ID: %@", - "translations": [ - "ID запиту: %@", - "Ідентифікатор запиту: %@" - ] - }, - { - "locale": "pl", - "source": "Requesting approval", - "translations": [ - "Prośba o zatwierdzenie", - "Żądanie zatwierdzenia" - ] - }, - { - "locale": "ru", - "source": "Requesting approval", - "translations": [ - "Запрашивается одобрение", - "Запрос разрешения" - ] - }, - { - "locale": "uk", - "source": "Requesting approval", - "translations": [ - "Запит на схвалення", - "Запит схвалення" - ] - }, - { - "locale": "zh-TW", - "source": "Requesting approval", - "translations": [ - "正在要求核准", - "正在請求核准" - ] - }, - { - "locale": "hi", - "source": "Requesting permissions…", - "translations": [ - "अनुमतियाँ माँगी जा रही हैं…", - "अनुमतियाँ मांगी जा रही हैं…" - ] - }, - { - "locale": "nl", - "source": "Reset", - "translations": [ - "Opnieuw instellen", - "Resetten" - ] - }, - { - "locale": "ar", - "source": "Retry", - "translations": [ - "Retry", - "إعادة المحاولة" - ] - }, - { - "locale": "de", - "source": "Retry", - "translations": [ - "Erneut versuchen", - "Retry", - "Wiederholen" - ] - }, - { - "locale": "es", - "source": "Retry", - "translations": [ - "Reintentar", - "Retry", - "Volver a intentarlo" - ] - }, - { - "locale": "fa", - "source": "Retry", - "translations": [ - "Retry", - "تلاش مجدد" - ] - }, - { - "locale": "fr", - "source": "Retry", - "translations": [ - "Retry", - "Réessayer" - ] - }, - { - "locale": "hi", - "source": "Retry", - "translations": [ - "Retry", - "पुनः प्रयास करें", - "फिर से कोशिश करें", - "फिर से प्रयास करें" - ] - }, - { - "locale": "id", - "source": "Retry", - "translations": [ - "Coba Lagi", - "Coba lagi", - "Retry" - ] - }, - { - "locale": "it", - "source": "Retry", - "translations": [ - "Retry", - "Riprova" - ] - }, - { - "locale": "ja-JP", - "source": "Retry", - "translations": [ - "Retry", - "再試行" - ] - }, - { - "locale": "ko", - "source": "Retry", - "translations": [ - "Retry", - "다시 시도" - ] - }, - { - "locale": "nl", - "source": "Retry", - "translations": [ - "Opnieuw proberen", - "Retry" - ] - }, - { - "locale": "pl", - "source": "Retry", - "translations": [ - "Ponów", - "Ponów próbę", - "Retry", - "Spróbuj ponownie" - ] - }, - { - "locale": "pt-BR", - "source": "Retry", - "translations": [ - "Retry", - "Tentar novamente" - ] - }, - { - "locale": "ru", - "source": "Retry", - "translations": [ - "Retry", - "Повторить" - ] - }, - { - "locale": "sv", - "source": "Retry", - "translations": [ - "Försök igen", - "Retry" - ] - }, - { - "locale": "th", - "source": "Retry", - "translations": [ - "Retry", - "ลองอีกครั้ง", - "ลองใหม่" - ] - }, - { - "locale": "tr", - "source": "Retry", - "translations": [ - "Retry", - "Yeniden Dene", - "Yeniden dene" - ] - }, - { - "locale": "uk", - "source": "Retry", - "translations": [ - "Retry", - "Повторити" - ] - }, - { - "locale": "vi", - "source": "Retry", - "translations": [ - "Retry", - "Thử lại" - ] - }, - { - "locale": "zh-CN", - "source": "Retry", - "translations": [ - "Retry", - "重试" - ] - }, - { - "locale": "zh-TW", - "source": "Retry", - "translations": [ - "Retry", - "重試" - ] - }, - { - "locale": "ar", - "source": "Run", - "translations": [ - "التشغيل", - "تشغيل" - ] - }, - { - "locale": "de", - "source": "Run", - "translations": [ - "Ausführen", - "Ausführung" - ] - }, - { - "locale": "es", - "source": "Run", - "translations": [ - "Ejecución", - "Ejecutar" - ] - }, - { - "locale": "fr", - "source": "Run", - "translations": [ - "Exécuter", - "Exécution" - ] - }, - { - "locale": "hi", - "source": "Run", - "translations": [ - "चलाएँ", - "चलाएं", - "रन" - ] - }, - { - "locale": "id", - "source": "Run", - "translations": [ - "Jalankan", - "Proses" - ] - }, - { - "locale": "it", - "source": "Run", - "translations": [ - "Esecuzione", - "Esegui" - ] - }, - { - "locale": "nl", - "source": "Run", - "translations": [ - "Uitvoeren", - "Uitvoering" - ] - }, - { - "locale": "pl", - "source": "Run", - "translations": [ - "Uruchom", - "Uruchomienie" - ] - }, - { - "locale": "pt-BR", - "source": "Run", - "translations": [ - "Executar", - "Execução" - ] - }, - { - "locale": "ru", - "source": "Run", - "translations": [ - "Запуск", - "Запустить" - ] - }, - { - "locale": "sv", - "source": "Run", - "translations": [ - "Kör", - "Körning" - ] - }, - { - "locale": "th", - "source": "Run", - "translations": [ - "การทำงาน", - "เรียกใช้" - ] - }, - { - "locale": "tr", - "source": "Run", - "translations": [ - "Çalıştır", - "Çalıştırma" - ] - }, - { - "locale": "uk", - "source": "Run", - "translations": [ - "Запуск", - "Запустити" - ] - }, - { - "locale": "vi", - "source": "Run", - "translations": [ - "Chạy", - "Lần chạy" - ] - }, - { - "locale": "de", - "source": "Running", - "translations": [ - "Läuft", - "Wird ausgeführt" - ] - }, - { - "locale": "fr", - "source": "Running", - "translations": [ - "En cours", - "En cours d’exécution" - ] - }, - { - "locale": "hi", - "source": "Running", - "translations": [ - "चल रहा है", - "जारी" - ] - }, - { - "locale": "id", - "source": "Running", - "translations": [ - "Berjalan", - "Sedang Berjalan", - "Sedang berjalan" - ] - }, - { - "locale": "nl", - "source": "Running", - "translations": [ - "Actief", - "In uitvoering", - "Wordt uitgevoerd" - ] - }, - { - "locale": "pl", - "source": "Running", - "translations": [ - "Uruchomiona", - "Uruchomione", - "Uruchomiono", - "W toku" - ] - }, - { - "locale": "sv", - "source": "Running", - "translations": [ - "Körs", - "Pågår" - ] - }, - { - "locale": "zh-CN", - "source": "Running", - "translations": [ - "正在运行", - "运行中" - ] - }, - { - "locale": "de", - "source": "Same Machine (Dev)", - "translations": [ - "Derselbe Computer (Dev)", - "Gleicher Rechner (Dev)" - ] - }, - { - "locale": "es", - "source": "Same Machine (Dev)", - "translations": [ - "Misma máquina (Dev)", - "Misma máquina (desarrollo)" - ] - }, - { - "locale": "it", - "source": "Same Machine (Dev)", - "translations": [ - "Stessa macchina (Dev)", - "Stessa macchina (dev)" - ] - }, - { - "locale": "ja-JP", - "source": "Same Machine (Dev)", - "translations": [ - "同じマシン(Dev)", - "同じマシン(開発)" - ] - }, - { - "locale": "ko", - "source": "Same Machine (Dev)", - "translations": [ - "동일한 기기(Dev)", - "동일한 머신(개발)" - ] - }, - { - "locale": "nl", - "source": "Same Machine (Dev)", - "translations": [ - "Zelfde machine (Dev)", - "Zelfde machine (dev)" - ] - }, - { - "locale": "pl", - "source": "Same Machine (Dev)", - "translations": [ - "Ta sama maszyna (Dev)", - "Ten sam komputer (Dev)" - ] - }, - { - "locale": "pt-BR", - "source": "Same Machine (Dev)", - "translations": [ - "Mesma máquina (Dev)", - "Mesma máquina (dev)" - ] - }, - { - "locale": "tr", - "source": "Same Machine (Dev)", - "translations": [ - "Aynı Makine (Dev)", - "Aynı Makine (Geliştirme)" - ] - }, - { - "locale": "nl", - "source": "Save", - "translations": [ - "Bewaar", - "Opslaan" - ] - }, - { - "locale": "ar", - "source": "Scan QR", - "translations": [ - "مسح QR", - "مسح QR ضوئيًا", - "مسح رمز QR" - ] - }, - { - "locale": "nl", - "source": "Scan QR", - "translations": [ - "QR scannen", - "QR-code scannen" - ] - }, - { - "locale": "ru", - "source": "Scan QR", - "translations": [ - "Сканировать QR", - "Сканировать QR-код" - ] - }, - { - "locale": "tr", - "source": "Scan QR", - "translations": [ - "QR tara", - "QR'ı Tara", - "QR'ı tara" - ] - }, - { - "locale": "uk", - "source": "Scan QR", - "translations": [ - "Сканувати QR", - "Сканувати QR-код" - ] - }, - { - "locale": "ar", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "ستظهر هنا المهام المجدولة التي أُنشئت على Gateway.", - "سيظهر هنا العمل المجدول الذي تم إنشاؤه على Gateway." - ] - }, - { - "locale": "es", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "El trabajo programado creado en el Gateway aparecerá aquí.", - "El trabajo programado creado en el gateway aparecerá aquí." - ] - }, - { - "locale": "it", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Le attività pianificate create sul Gateway appariranno qui.", - "Le attività pianificate create sul Gateway verranno visualizzate qui." - ] - }, - { - "locale": "ja-JP", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Gateway で作成されたスケジュール済みの作業がここに表示されます。", - "Gatewayで作成されたスケジュール済みの処理がここに表示されます。" - ] - }, - { - "locale": "nl", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Gepland werk dat op de Gateway is aangemaakt, verschijnt hier.", - "Geplande taken die op de gateway zijn aangemaakt, worden hier weergegeven." - ] - }, - { - "locale": "pt-BR", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "As tarefas agendadas criadas no Gateway aparecerão aqui.", - "Os trabalhos agendados criados no gateway aparecerão aqui." - ] - }, - { - "locale": "ru", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Запланированные задачи, созданные на Gateway, появятся здесь.", - "Запланированные на Gateway задачи появятся здесь." - ] - }, - { - "locale": "sv", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Schemalagda uppgifter som skapas på Gateway visas här.", - "Schemalagda uppgifter som skapas på gatewayen visas här." - ] - }, - { - "locale": "th", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "งานตามกำหนดเวลาที่สร้างบน Gateway จะปรากฏที่นี่", - "งานที่กำหนดเวลาไว้ซึ่งสร้างบน Gateway จะปรากฏที่นี่" - ] - }, - { - "locale": "tr", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Gateway'de oluşturulan zamanlanmış işler burada görünecek.", - "Gateway'de oluşturulan zamanlanmış işler burada görünecektir." - ] - }, - { - "locale": "uk", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Заплановані завдання, створені на Gateway, з’являться тут.", - "Заплановані на Gateway завдання з’являться тут." - ] - }, - { - "locale": "vi", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "Công việc theo lịch được tạo trên Gateway sẽ xuất hiện tại đây.", - "Công việc đã lên lịch được tạo trên Gateway sẽ xuất hiện tại đây." - ] - }, - { - "locale": "zh-CN", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "在 Gateway 上创建的计划任务将显示在此处。", - "在 Gateway 上创建的计划任务将显示在这里。" - ] - }, - { - "locale": "zh-TW", - "source": "Scheduled work created on the gateway will appear here.", - "translations": [ - "在 Gateway 上建立的排程工作將顯示於此。", - "在 Gateway 上建立的排程工作會顯示於此。" - ] - }, - { - "locale": "es", - "source": "Scope", - "translations": [ - "Alcance", - "Ámbito" - ] - }, - { - "locale": "fr", - "source": "Scope", - "translations": [ - "Portée", - "Périmètre" - ] - }, - { - "locale": "hi", - "source": "Scope", - "translations": [ - "दायरा", - "स्कोप" - ] - }, - { - "locale": "ja-JP", - "source": "Scope", - "translations": [ - "スコープ", - "範囲" - ] - }, - { - "locale": "nl", - "source": "Scope", - "translations": [ - "Bereik", - "Scope" - ] - }, - { - "locale": "uk", - "source": "Scope", - "translations": [ - "Область", - "Обсяг" - ] - }, - { - "locale": "fa", - "source": "Search ClawHub", - "translations": [ - "جستجو در ClawHub", - "جستجوی ClawHub" - ] - }, - { - "locale": "hi", - "source": "Search ClawHub", - "translations": [ - "ClawHub खोजें", - "ClawHub में खोजें" - ] - }, - { - "locale": "id", - "source": "Search ClawHub", - "translations": [ - "Cari ClawHub", - "Cari di ClawHub" - ] - }, - { - "locale": "nl", - "source": "Search ClawHub", - "translations": [ - "ClawHub zoeken", - "Zoeken in ClawHub" - ] - }, - { - "locale": "pl", - "source": "Search ClawHub", - "translations": [ - "Przeszukaj ClawHub", - "Wyszukaj w ClawHub" - ] - }, - { - "locale": "pt-BR", - "source": "Search ClawHub", - "translations": [ - "Buscar no ClawHub", - "Pesquisar no ClawHub" - ] - }, - { - "locale": "th", - "source": "Search ClawHub", - "translations": [ - "ค้นหา ClawHub", - "ค้นหาใน ClawHub" - ] - }, - { - "locale": "vi", - "source": "Search ClawHub", - "translations": [ - "Tìm kiếm ClawHub", - "Tìm kiếm trên ClawHub" - ] - }, - { - "locale": "ar", - "source": "Search agents", - "translations": [ - "البحث عن الوكلاء", - "البحث في الوكلاء" - ] - }, - { - "locale": "id", - "source": "Search agents", - "translations": [ - "Cari agen", - "Cari agent" - ] - }, - { - "locale": "zh-CN", - "source": "Search agents", - "translations": [ - "搜索 agent", - "搜索代理" - ] - }, - { - "locale": "ja-JP", - "source": "Secure (TLS)", - "translations": [ - "セキュア (TLS)", - "セキュア(TLS)" - ] - }, - { - "locale": "ko", - "source": "Secure (TLS)", - "translations": [ - "보안 (TLS)", - "보안(TLS)" - ] - }, - { - "locale": "ru", - "source": "Secure (TLS)", - "translations": [ - "Защищено (TLS)", - "Защищённое (TLS)" - ] - }, - { - "locale": "uk", - "source": "Secure (TLS)", - "translations": [ - "Безпечне (TLS)", - "Захищено (TLS)" - ] - }, - { - "locale": "ar", - "source": "Selected", - "translations": [ - "محدد", - "محدّد" - ] - }, - { - "locale": "es", - "source": "Selected", - "translations": [ - "Seleccionada", - "Seleccionado" - ] - }, - { - "locale": "fa", - "source": "Selected", - "translations": [ - "انتخاب شده", - "انتخاب‌شده" - ] - }, - { - "locale": "sv", - "source": "Selected", - "translations": [ - "Vald", - "Valt" - ] - }, - { - "locale": "tr", - "source": "Selected", - "translations": [ - "Seçildi", - "Seçili" - ] - }, - { - "locale": "fa", - "source": "Sessions unavailable", - "translations": [ - "جلسه‌ها در دسترس نیستند", - "نشست‌ها در دسترس نیستند" - ] - }, - { - "locale": "vi", - "source": "Sessions unavailable", - "translations": [ - "Không có phiên khả dụng", - "Không thể truy cập các phiên" - ] - }, - { - "locale": "zh-TW", - "source": "Sessions unavailable", - "translations": [ - "工作階段無法使用", - "無法使用工作階段" - ] - }, - { - "locale": "ar", - "source": "Setup", - "translations": [ - "إعداد", - "الإعداد" - ] - }, - { - "locale": "de", - "source": "Setup", - "translations": [ - "Einrichten", - "Einrichtung" - ] - }, - { - "locale": "id", - "source": "Setup", - "translations": [ - "Pengaturan", - "Penyiapan", - "Siapkan" - ] - }, - { - "locale": "ja-JP", - "source": "Setup", - "translations": [ - "セットアップ", - "設定" - ] - }, - { - "locale": "nl", - "source": "Setup", - "translations": [ - "Configureren", - "Instellen" - ] - }, - { - "locale": "pt-BR", - "source": "Setup", - "translations": [ - "Configurar", - "Configuração" - ] - }, - { - "locale": "nl", - "source": "Show less", - "translations": [ - "Minder tonen", - "Minder weergeven" - ] - }, - { - "locale": "de", - "source": "Skill Workshop", - "translations": [ - "Skill Workshop", - "Skill-Workshop" - ] - }, - { - "locale": "fa", - "source": "Skill Workshop", - "translations": [ - "کارگاه Skill", - "کارگاه مهارت" - ] - }, - { - "locale": "fr", - "source": "Skill Workshop", - "translations": [ - "Atelier Skills", - "Atelier des Skills" - ] - }, - { - "locale": "hi", - "source": "Skill Workshop", - "translations": [ - "Skill Workshop", - "Skill वर्कशॉप" - ] - }, - { - "locale": "it", - "source": "Skill Workshop", - "translations": [ - "Skill Workshop", - "Workshop Skills" - ] - }, - { - "locale": "ko", - "source": "Skill Workshop", - "translations": [ - "Skill Workshop", - "Skill 워크숍" - ] - }, - { - "locale": "ru", - "source": "Skill Workshop", - "translations": [ - "Мастерская Skill", - "Мастерская Skills" - ] - }, - { - "locale": "th", - "source": "Skill Workshop", - "translations": [ - "เวิร์กชอป Skill", - "เวิร์กช็อป Skills" - ] - }, - { - "locale": "zh-CN", - "source": "Skill Workshop", - "translations": [ - "Skill Workshop", - "技能工作坊" - ] - }, - { - "locale": "zh-TW", - "source": "Skill Workshop", - "translations": [ - "Skill Workshop", - "Skill 工作坊" - ] - }, - { - "locale": "ar", - "source": "Speaking", - "translations": [ - "جارٍ التحدث", - "جارٍ النطق" - ] - }, - { - "locale": "fa", - "source": "Speaking", - "translations": [ - "در حال صحبت", - "در حال گفتار" - ] - }, - { - "locale": "fr", - "source": "Speaking", - "translations": [ - "En train de parler", - "Lecture en cours" - ] - }, - { - "locale": "ja-JP", - "source": "Speaking", - "translations": [ - "発話中", - "読み上げ中" - ] - }, - { - "locale": "nl", - "source": "Speaking", - "translations": [ - "Aan het spreken", - "Bezig met spreken" - ] - }, - { - "locale": "pl", - "source": "Speaking", - "translations": [ - "Mówi", - "Mówienie" - ] - }, - { - "locale": "ru", - "source": "Speaking", - "translations": [ - "Воспроизведение речи", - "Говорит" - ] - }, - { - "locale": "uk", - "source": "Speaking", - "translations": [ - "Говорить", - "Озвучення" - ] - }, - { - "locale": "vi", - "source": "Speaking", - "translations": [ - "Đang nói", - "Đang phát giọng nói" - ] - }, - { - "locale": "zh-CN", - "source": "Speaking", - "translations": [ - "正在朗读", - "正在说话" - ] - }, - { - "locale": "zh-TW", - "source": "Speaking", - "translations": [ - "正在朗讀", - "正在說話" - ] - }, - { - "locale": "ar", - "source": "Speaking…", - "translations": [ - "جارٍ التحدث…", - "يتحدث…" - ] - }, - { - "locale": "fr", - "source": "Speaking…", - "translations": [ - "En train de parler…", - "Lecture…" - ] - }, - { - "locale": "hi", - "source": "Speaking…", - "translations": [ - "बोल रहा है…", - "बोला जा रहा है…" - ] - }, - { - "locale": "it", - "source": "Speaking…", - "translations": [ - "In riproduzione…", - "Riproduzione vocale…" - ] - }, - { - "locale": "ja-JP", - "source": "Speaking…", - "translations": [ - "発話中…", - "読み上げ中…" - ] - }, - { - "locale": "nl", - "source": "Speaking…", - "translations": [ - "Aan het spreken…", - "Spreekt…" - ] - }, - { - "locale": "ru", - "source": "Speaking…", - "translations": [ - "Воспроизведение речи…", - "Говорит…" - ] - }, - { - "locale": "uk", - "source": "Speaking…", - "translations": [ - "Говорить…", - "Озвучення…" - ] - }, - { - "locale": "zh-TW", - "source": "Speaking…", - "translations": [ - "正在朗讀…", - "說話中…" - ] - }, - { - "locale": "hi", - "source": "Speech recognition", - "translations": [ - "वाक् पहचान", - "स्पीच रिकग्निशन" - ] - }, - { - "locale": "de", - "source": "Start", - "translations": [ - "Start", - "Starten" - ] - }, - { - "locale": "pl", - "source": "Start", - "translations": [ - "Rozpocznij", - "Uruchom" - ] - }, - { - "locale": "ru", - "source": "Start", - "translations": [ - "Запустить", - "Начать" - ] - }, - { - "locale": "uk", - "source": "Start", - "translations": [ - "Запустити", - "Почати" - ] - }, - { - "locale": "zh-CN", - "source": "Start", - "translations": [ - "启动", - "开始" - ] - }, - { - "locale": "zh-TW", - "source": "Start", - "translations": [ - "啟動", - "開始" - ] - }, - { - "locale": "ar", - "source": "Start a chat and it will appear here.", - "translations": [ - "ابدأ دردشة وستظهر هنا.", - "ابدأ محادثة وستظهر هنا." - ] - }, - { - "locale": "de", - "source": "Start a chat and it will appear here.", - "translations": [ - "Starte einen Chat, dann wird er hier angezeigt.", - "Starten Sie einen Chat, dann wird er hier angezeigt." - ] - }, - { - "locale": "fa", - "source": "Start a chat and it will appear here.", - "translations": [ - "گفتگویی را شروع کنید تا اینجا نمایش داده شود.", - "یک گفتگو را شروع کنید تا اینجا نمایش داده شود." - ] - }, - { - "locale": "fr", - "source": "Start a chat and it will appear here.", - "translations": [ - "Démarrez un chat et il apparaîtra ici.", - "Démarrez une discussion et elle apparaîtra ici." - ] - }, - { - "locale": "id", - "source": "Start a chat and it will appear here.", - "translations": [ - "Mulai chat dan akan muncul di sini.", - "Mulai chat dan chat tersebut akan muncul di sini." - ] - }, - { - "locale": "th", - "source": "Start a chat and it will appear here.", - "translations": [ - "เริ่มแชท แล้วจะแสดงที่นี่", - "เริ่มแชท แล้วแชทจะแสดงที่นี่" - ] - }, - { - "locale": "hi", - "source": "Start failed: %@", - "translations": [ - "शुरू करना विफल रहा: %@", - "शुरू नहीं हो सका: %@" - ] - }, - { - "locale": "ja-JP", - "source": "Start failed: %@", - "translations": [ - "開始に失敗しました: %@", - "開始に失敗しました:%@" - ] - }, - { - "locale": "th", - "source": "Start failed: %@", - "translations": [ - "การเริ่มต้นล้มเหลว: %@", - "เริ่มไม่สำเร็จ: %@" - ] - }, - { - "locale": "tr", - "source": "Start failed: %@", - "translations": [ - "Başlatma başarısız: %@", - "Başlatılamadı: %@" - ] - }, - { - "locale": "vi", - "source": "Start failed: %@", - "translations": [ - "Không thể bắt đầu: %@", - "Không thể khởi động: %@" - ] - }, - { - "locale": "fr", - "source": "Status", - "translations": [ - "Statut", - "État" - ] - }, - { - "locale": "uk", - "source": "Status", - "translations": [ - "Стан", - "Статус" - ] - }, - { - "locale": "id", - "source": "Stop", - "translations": [ - "Berhenti", - "Hentikan" - ] - }, - { - "locale": "ar", - "source": "Technical details", - "translations": [ - "التفاصيل التقنية", - "التفاصيل الفنية" - ] - }, - { - "locale": "sv", - "source": "Technical details", - "translations": [ - "Teknisk information", - "Tekniska detaljer" - ] - }, - { - "locale": "zh-CN", - "source": "Technical details", - "translations": [ - "技术详情", - "技术详细信息" - ] - }, - { - "locale": "zh-TW", - "source": "Technical details", - "translations": [ - "技術詳細資料", - "技術詳細資訊" - ] - }, - { - "locale": "hi", - "source": "The Gateway did not start this automation.", - "translations": [ - "Gateway ने यह ऑटोमेशन शुरू नहीं किया।", - "Gateway ने यह स्वचालन शुरू नहीं किया।" - ] - }, - { - "locale": "ja-JP", - "source": "The Gateway did not start this automation.", - "translations": [ - "Gateway はこの自動化を開始しませんでした。", - "Gatewayはこの自動化を開始しませんでした。" - ] - }, - { - "locale": "vi", - "source": "The Gateway did not start this automation.", - "translations": [ - "Gateway không khởi chạy tác vụ tự động hóa này.", - "Gateway không khởi chạy tác vụ tự động này." - ] - }, - { - "locale": "zh-CN", - "source": "The Gateway did not start this automation.", - "translations": [ - "Gateway 未启动此自动化。", - "Gateway 未启动此自动化任务。" - ] - }, - { - "locale": "hi", - "source": "The automation scheduler is stopped.", - "translations": [ - "ऑटोमेशन शेड्यूलर रुका हुआ है।", - "स्वचालन शेड्यूलर बंद है।" - ] - }, - { - "locale": "it", - "source": "The automation scheduler is stopped.", - "translations": [ - "Il pianificatore delle automazioni è arrestato.", - "L'utilità di pianificazione delle automazioni è arrestata." - ] - }, - { - "locale": "ru", - "source": "The automation scheduler is stopped.", - "translations": [ - "Планировщик автоматизации остановлен.", - "Планировщик автоматизаций остановлен." - ] - }, - { - "locale": "th", - "source": "The automation scheduler is stopped.", - "translations": [ - "ตัวกำหนดเวลาการทำงานอัตโนมัติหยุดทำงานอยู่", - "ตัวจัดกำหนดการทำงานอัตโนมัติหยุดทำงานอยู่" - ] - }, - { - "locale": "uk", - "source": "The automation scheduler is stopped.", - "translations": [ - "Планувальник автоматизацій зупинено.", - "Планувальник автоматизації зупинено." - ] - }, - { - "locale": "vi", - "source": "The automation scheduler is stopped.", - "translations": [ - "Bộ lập lịch tự động hóa đã dừng.", - "Trình lập lịch tự động hóa đã dừng." - ] - }, - { - "locale": "ar", - "source": "Thinking", - "translations": [ - "التفكير", - "يفكر" - ] - }, - { - "locale": "de", - "source": "Thinking", - "translations": [ - "Denkmodus", - "Denkprozess", - "Denkt nach", - "Nachdenken" - ] - }, - { - "locale": "es", - "source": "Thinking", - "translations": [ - "Pensando", - "Razonamiento" - ] - }, - { - "locale": "fa", - "source": "Thinking", - "translations": [ - "تفکر", - "در حال فکر کردن" - ] - }, - { - "locale": "hi", - "source": "Thinking", - "translations": [ - "विचार प्रक्रिया", - "सोच रहा है", - "सोच-विचार" - ] - }, - { - "locale": "id", - "source": "Thinking", - "translations": [ - "Berpikir", - "Penalaran" - ] - }, - { - "locale": "it", - "source": "Thinking", - "translations": [ - "Elaborazione", - "Ragionamento" - ] - }, - { - "locale": "ja-JP", - "source": "Thinking", - "translations": [ - "思考", - "思考中" - ] - }, - { - "locale": "ko", - "source": "Thinking", - "translations": [ - "사고", - "사고 중", - "생각 중" - ] - }, - { - "locale": "nl", - "source": "Thinking", - "translations": [ - "Aan het nadenken", - "Denken", - "Denkmodus", - "Redeneren" - ] - }, - { - "locale": "pl", - "source": "Thinking", - "translations": [ - "Myślenie", - "Rozumowanie" - ] - }, - { - "locale": "pt-BR", - "source": "Thinking", - "translations": [ - "Pensando", - "Raciocínio" - ] - }, - { - "locale": "ru", - "source": "Thinking", - "translations": [ - "Размышление", - "Рассуждение" - ] - }, - { - "locale": "sv", - "source": "Thinking", - "translations": [ - "Tänkande", - "Tänker" - ] - }, - { - "locale": "th", - "source": "Thinking", - "translations": [ - "การคิด", - "กำลังคิด" - ] - }, - { - "locale": "tr", - "source": "Thinking", - "translations": [ - "Düşünme", - "Düşünüyor" - ] - }, - { - "locale": "uk", - "source": "Thinking", - "translations": [ - "Мислення", - "Міркування", - "Обмірковування" - ] - }, - { - "locale": "vi", - "source": "Thinking", - "translations": [ - "Suy luận", - "Đang suy nghĩ" - ] - }, - { - "locale": "zh-CN", - "source": "Thinking", - "translations": [ - "思考", - "思考中", - "正在思考" - ] - }, - { - "locale": "zh-TW", - "source": "Thinking", - "translations": [ - "思考", - "思考中" - ] - }, - { - "locale": "it", - "source": "This automation has an invalid configuration.", - "translations": [ - "Questa automazione ha una configurazione non valida.", - "Questa automazione presenta una configurazione non valida." - ] - }, - { - "locale": "ja-JP", - "source": "This automation has an invalid configuration.", - "translations": [ - "このオートメーションの設定は無効です。", - "この自動化の設定は無効です。" - ] - }, - { - "locale": "ko", - "source": "This automation has an invalid configuration.", - "translations": [ - "이 자동화의 구성이 올바르지 않습니다.", - "이 자동화의 구성이 잘못되었습니다." - ] - }, - { - "locale": "sv", - "source": "This automation has an invalid configuration.", - "translations": [ - "Den här automationen har en ogiltig konfiguration.", - "Den här automatiseringen har en ogiltig konfiguration." - ] - }, - { - "locale": "th", - "source": "This automation has an invalid configuration.", - "translations": [ - "การทำงานอัตโนมัตินี้มีการกำหนดค่าที่ไม่ถูกต้อง", - "ระบบอัตโนมัตินี้มีการกำหนดค่าที่ไม่ถูกต้อง" - ] - }, - { - "locale": "ja-JP", - "source": "This automation is already running.", - "translations": [ - "このオートメーションはすでに実行中です。", - "この自動化はすでに実行中です。" - ] - }, - { - "locale": "sv", - "source": "This automation is already running.", - "translations": [ - "Den här automationen körs redan.", - "Den här automatiseringen körs redan." - ] - }, - { - "locale": "th", - "source": "This automation is already running.", - "translations": [ - "การทำงานอัตโนมัตินี้กำลังทำงานอยู่แล้ว", - "ระบบอัตโนมัตินี้กำลังทำงานอยู่แล้ว" - ] - }, - { - "locale": "es", - "source": "This automation is not due yet.", - "translations": [ - "Aún no es el momento de ejecutar esta automatización.", - "Esta automatización aún no debe ejecutarse." - ] - }, - { - "locale": "fr", - "source": "This automation is not due yet.", - "translations": [ - "Cette automatisation n’est pas encore arrivée à échéance.", - "Cette automatisation n’est pas encore prévue." - ] - }, - { - "locale": "hi", - "source": "This automation is not due yet.", - "translations": [ - "इस ऑटोमेशन का समय अभी नहीं आया है।", - "इस ऑटोमेशन का समय अभी नहीं हुआ है।" - ] - }, - { - "locale": "ja-JP", - "source": "This automation is not due yet.", - "translations": [ - "このオートメーションはまだ実行予定時刻になっていません。", - "この自動化はまだ実行予定時刻になっていません。" - ] - }, - { - "locale": "ko", - "source": "This automation is not due yet.", - "translations": [ - "이 자동화는 아직 실행 예정 시간이 되지 않았습니다.", - "이 자동화는 아직 실행할 시간이 아닙니다." - ] - }, - { - "locale": "pl", - "source": "This automation is not due yet.", - "translations": [ - "Nie nadszedł jeszcze czas uruchomienia tej automatyzacji.", - "Termin tej automatyzacji jeszcze nie nadszedł." - ] - }, - { - "locale": "sv", - "source": "This automation is not due yet.", - "translations": [ - "Den här automationen ska inte köras ännu.", - "Den här automatiseringen ska inte köras ännu." - ] - }, - { - "locale": "th", - "source": "This automation is not due yet.", - "translations": [ - "การทำงานอัตโนมัตินี้ยังไม่ถึงกำหนด", - "ยังไม่ถึงกำหนดเรียกใช้ระบบอัตโนมัตินี้" - ] - }, - { - "locale": "uk", - "source": "This automation is not due yet.", - "translations": [ - "Час виконання цієї автоматизації ще не настав.", - "Час запуску цієї автоматизації ще не настав." - ] - }, - { - "locale": "fr", - "source": "Thread", - "translations": [ - "Fil", - "Fil de discussion" - ] - }, - { - "locale": "it", - "source": "Thread", - "translations": [ - "Conversazione", - "Thread" - ] - }, - { - "locale": "nl", - "source": "Thread", - "translations": [ - "Gesprek", - "Thread" - ] - }, - { - "locale": "tr", - "source": "Thread", - "translations": [ - "Dizi", - "Konu" - ] - }, - { - "locale": "vi", - "source": "Thread", - "translations": [ - "Chuỗi", - "Luồng" - ] - }, - { - "locale": "zh-CN", - "source": "Thread", - "translations": [ - "对话串", - "话题串" - ] - }, - { - "locale": "zh-TW", - "source": "Thread", - "translations": [ - "對話串", - "討論串" - ] - }, - { - "locale": "ar", - "source": "Thread name", - "translations": [ - "اسم المحادثة", - "اسم سلسلة المحادثة" - ] - }, - { - "locale": "fr", - "source": "Thread name", - "translations": [ - "Nom du fil", - "Nom du fil de discussion" - ] - }, - { - "locale": "nl", - "source": "Thread name", - "translations": [ - "Gespreksnaam", - "Threadnaam" - ] - }, - { - "locale": "tr", - "source": "Thread name", - "translations": [ - "İleti dizisi adı", - "İş parçacığı adı" - ] - }, - { - "locale": "zh-CN", - "source": "Thread name", - "translations": [ - "对话名称", - "话题名称" - ] - }, - { - "locale": "uk", - "source": "Title", - "translations": [ - "Заголовок", - "Назва" - ] - }, - { - "locale": "it", - "source": "Unarchive", - "translations": [ - "Annulla archiviazione", - "Rimuovi dall’archivio" - ] - }, - { - "locale": "ja-JP", - "source": "Unarchive", - "translations": [ - "アーカイブを解除", - "アーカイブ解除" - ] - }, - { - "locale": "pl", - "source": "Unarchive", - "translations": [ - "Cofnij archiwizację", - "Przywróć z archiwum" - ] - }, - { - "locale": "sv", - "source": "Unarchive", - "translations": [ - "Avarkivera", - "Ta bort från arkiv" - ] - }, - { - "locale": "ja-JP", - "source": "Unavailable", - "translations": [ - "利用できません", - "利用不可" - ] - }, - { - "locale": "sv", - "source": "Unavailable", - "translations": [ - "Ej tillgänglig", - "Inte tillgänglig", - "Otillgänglig" - ] - }, - { - "locale": "ar", - "source": "Unencrypted", - "translations": [ - "غير مشفّر", - "غير مُشفّر" - ] - }, - { - "locale": "fa", - "source": "Unencrypted", - "translations": [ - "رمزنگاری‌نشده", - "رمزگذاری‌نشده" - ] - }, - { - "locale": "id", - "source": "Unencrypted", - "translations": [ - "Tidak Terenkripsi", - "Tidak terenkripsi" - ] - }, - { - "locale": "pt-BR", - "source": "Unencrypted", - "translations": [ - "Não criptografado", - "Sem criptografia" - ] - }, - { - "locale": "uk", - "source": "Unencrypted", - "translations": [ - "Без шифрування", - "Незашифроване" - ] - }, - { - "locale": "fa", - "source": "Unknown", - "translations": [ - "ناشناخته", - "نامشخص" - ] - }, - { - "locale": "sv", - "source": "Unknown", - "translations": [ - "Okänd", - "Okänt" - ] - }, - { - "locale": "de", - "source": "Unpin", - "translations": [ - "Loslösen", - "Lösen" - ] - }, - { - "locale": "es", - "source": "Unpin", - "translations": [ - "Dejar de fijar", - "Desfijar" - ] - }, - { - "locale": "fr", - "source": "Unpin", - "translations": [ - "Désépingler", - "Détacher" - ] - }, - { - "locale": "hi", - "source": "Unpin", - "translations": [ - "अनपिन करें", - "पिन हटाएँ" - ] - }, - { - "locale": "id", - "source": "Unpin", - "translations": [ - "Lepas Sematan", - "Lepas sematan" - ] - }, - { - "locale": "it", - "source": "Unpin", - "translations": [ - "Rimuovi", - "Rimuovi fissaggio" - ] - }, - { - "locale": "tr", - "source": "Unpin", - "translations": [ - "Sabitlemeyi Kaldır", - "Sabitlemeyi kaldır" - ] - }, - { - "locale": "hi", - "source": "Updating OpenClaw", - "translations": [ - "OpenClaw अपडेट किया जा रहा है", - "OpenClaw को अपडेट किया जा रहा है" - ] - }, - { - "locale": "ja-JP", - "source": "Updating OpenClaw", - "translations": [ - "OpenClaw を更新中", - "OpenClawを更新中" - ] - }, - { - "locale": "fa", - "source": "Usage", - "translations": [ - "استفاده", - "مصرف" - ] - }, - { - "locale": "zh-CN", - "source": "Usage", - "translations": [ - "使用情况", - "用量" - ] - }, - { - "locale": "zh-TW", - "source": "Usage", - "translations": [ - "使用量", - "用量" - ] - }, - { - "locale": "es", - "source": "Use Manual Setup", - "translations": [ - "Usar Configuración manual", - "Usar configuración manual" - ] - }, - { - "locale": "fr", - "source": "Use Manual Setup", - "translations": [ - "Utiliser la Configuration manuelle", - "Utiliser la configuration manuelle" - ] - }, - { - "locale": "hi", - "source": "Use Manual Setup", - "translations": [ - "Manual Setup का उपयोग करें", - "मैन्युअल सेटअप का उपयोग करें" - ] - }, - { - "locale": "id", - "source": "Use Manual Setup", - "translations": [ - "Gunakan Manual Setup", - "Gunakan Pengaturan Manual" - ] - }, - { - "locale": "it", - "source": "Use Manual Setup", - "translations": [ - "Usa Configurazione manuale", - "Usa configurazione manuale" - ] - }, - { - "locale": "nl", - "source": "Use Manual Setup", - "translations": [ - "Handmatige configuratie gebruiken", - "Handmatige installatie gebruiken" - ] - }, - { - "locale": "pt-BR", - "source": "Use Manual Setup", - "translations": [ - "Usar Configuração Manual", - "Usar configuração manual" - ] - }, - { - "locale": "sv", - "source": "Use Manual Setup", - "translations": [ - "Använd Manuell konfiguration", - "Använd manuell konfiguration" - ] - }, - { - "locale": "th", - "source": "Use Manual Setup", - "translations": [ - "ใช้ Manual Setup", - "ใช้การตั้งค่าด้วยตนเอง" - ] - }, - { - "locale": "vi", - "source": "Use Manual Setup", - "translations": [ - "Dùng Thiết lập thủ công", - "Sử dụng thiết lập thủ công" - ] - }, - { - "locale": "fr", - "source": "Verbosity", - "translations": [ - "Niveau de détail", - "Verbosité" - ] - }, - { - "locale": "pt-BR", - "source": "Verbosity", - "translations": [ - "Nível de detalhe", - "Nível de detalhes" - ] - }, - { - "locale": "tr", - "source": "Verbosity", - "translations": [ - "Ayrıntı Düzeyi", - "Ayrıntı düzeyi" - ] - }, - { - "locale": "fa", - "source": "Voice & Talk", - "translations": [ - "صدا و صحبت", - "صدا و مکالمه" - ] - }, - { - "locale": "fr", - "source": "Voice & Talk", - "translations": [ - "Voix et conversation", - "Voix et parole" - ] - }, - { - "locale": "hi", - "source": "Voice & Talk", - "translations": [ - "वॉइस और टॉक", - "वॉइस और बातचीत" - ] - }, - { - "locale": "sv", - "source": "Voice & Talk", - "translations": [ - "Röst och samtal", - "Röst och tal" - ] - }, - { - "locale": "de", - "source": "Voice Wake", - "translations": [ - "Sprachaktivierung", - "Voice Wake" - ] - }, - { - "locale": "hi", - "source": "Voice Wake", - "translations": [ - "Voice Wake", - "वॉइस वेक" - ] - }, - { - "locale": "id", - "source": "Voice Wake", - "translations": [ - "Bangun Suara", - "Bangun dengan Suara" - ] - }, - { - "locale": "it", - "source": "Voice Wake", - "translations": [ - "Attivazione vocale", - "Risveglio vocale" - ] - }, - { - "locale": "nl", - "source": "Voice Wake", - "translations": [ - "Spraakactivering", - "Stemactivering" - ] - }, - { - "locale": "ru", - "source": "Voice Wake", - "translations": [ - "Голосовая активация", - "Голосовое пробуждение" - ] - }, - { - "locale": "sv", - "source": "Voice Wake", - "translations": [ - "Röstaktivering", - "Röstväckning" - ] - }, - { - "locale": "th", - "source": "Voice Wake", - "translations": [ - "การปลุกด้วยเสียง", - "ปลุกด้วยเสียง" - ] - }, - { - "locale": "ar", - "source": "Voice note", - "translations": [ - "Voice note", - "ملاحظة صوتية" - ] - }, - { - "locale": "de", - "source": "Voice note", - "translations": [ - "Sprachnachricht", - "Sprachnotiz", - "Voice note" - ] - }, - { - "locale": "es", - "source": "Voice note", - "translations": [ - "Nota de voz", - "Voice note" - ] - }, - { - "locale": "fa", - "source": "Voice note", - "translations": [ - "Voice note", - "یادداشت صوتی" - ] - }, - { - "locale": "fr", - "source": "Voice note", - "translations": [ - "Note vocale", - "Voice note" - ] - }, - { - "locale": "hi", - "source": "Voice note", - "translations": [ - "Voice note", - "वॉइस नोट" - ] - }, - { - "locale": "id", - "source": "Voice note", - "translations": [ - "Catatan suara", - "Voice note" - ] - }, - { - "locale": "it", - "source": "Voice note", - "translations": [ - "Nota vocale", - "Voice note" - ] - }, - { - "locale": "ja-JP", - "source": "Voice note", - "translations": [ - "Voice note", - "ボイスノート", - "ボイスメモ" - ] - }, - { - "locale": "ko", - "source": "Voice note", - "translations": [ - "Voice note", - "음성 메모" - ] - }, - { - "locale": "nl", - "source": "Voice note", - "translations": [ - "Spraakbericht", - "Spraaknotitie", - "Voice note" - ] - }, - { - "locale": "pl", - "source": "Voice note", - "translations": [ - "Notatka głosowa", - "Voice note" - ] - }, - { - "locale": "pt-BR", - "source": "Voice note", - "translations": [ - "Nota de voz", - "Voice note" - ] - }, - { - "locale": "ru", - "source": "Voice note", - "translations": [ - "Voice note", - "Голосовое сообщение" - ] - }, - { - "locale": "sv", - "source": "Voice note", - "translations": [ - "Röstmeddelande", - "Voice note" - ] - }, - { - "locale": "th", - "source": "Voice note", - "translations": [ - "Voice note", - "ข้อความเสียง", - "บันทึกเสียง" - ] - }, - { - "locale": "tr", - "source": "Voice note", - "translations": [ - "Sesli not", - "Voice note" - ] - }, - { - "locale": "uk", - "source": "Voice note", - "translations": [ - "Voice note", - "Голосова нотатка", - "Голосове повідомлення" - ] - }, - { - "locale": "vi", - "source": "Voice note", - "translations": [ - "Ghi chú thoại", - "Voice note" - ] - }, - { - "locale": "zh-CN", - "source": "Voice note", - "translations": [ - "Voice note", - "语音消息", - "语音留言" - ] - }, - { - "locale": "zh-TW", - "source": "Voice note", - "translations": [ - "Voice note", - "語音留言" - ] - }, - { - "locale": "de", - "source": "Wake Words", - "translations": [ - "Aktivierungswörter", - "Wake Words" - ] - }, - { - "locale": "fr", - "source": "Wake Words", - "translations": [ - "Mots de réveil", - "Mots d’activation" - ] - }, - { - "locale": "hi", - "source": "Wake Words", - "translations": [ - "Wake Words", - "वेक वर्ड्स" - ] - }, - { - "locale": "ko", - "source": "Wake Words", - "translations": [ - "Wake Words", - "웨이크 워드" - ] - }, - { - "locale": "nl", - "source": "Wake Words", - "translations": [ - "Wake Words", - "Wakewoorden" - ] - }, - { - "locale": "ru", - "source": "Wake Words", - "translations": [ - "Ключевые слова пробуждения", - "Слова активации" - ] - }, - { - "locale": "sv", - "source": "Wake Words", - "translations": [ - "Väckningsord", - "Wake Words" - ] - }, - { - "locale": "tr", - "source": "Wake Words", - "translations": [ - "Uyandırma Sözcükleri", - "Uyandırma sözcükleri" - ] - }, - { - "locale": "uk", - "source": "Wake Words", - "translations": [ - "Слова активації", - "Слова пробудження" - ] - }, - { - "locale": "es", - "source": "While Using the App", - "translations": [ - "Al usar la app", - "Mientras se usa la app" - ] - }, - { - "locale": "fr", - "source": "While Using the App", - "translations": [ - "Lors de l'utilisation de l'app", - "Lorsque l’app est utilisée" - ] - }, - { - "locale": "hi", - "source": "While Using the App", - "translations": [ - "ऐप उपयोग करते समय", - "ऐप का उपयोग करते समय" - ] - }, - { - "locale": "id", - "source": "While Using the App", - "translations": [ - "Saat Menggunakan Aplikasi", - "Saat Menggunakan App" - ] - }, - { - "locale": "it", - "source": "While Using the App", - "translations": [ - "Durante l'uso dell'app", - "Mentre usi l'app" - ] - }, - { - "locale": "ja-JP", - "source": "While Using the App", - "translations": [ - "アプリの使用中", - "アプリ使用中のみ" - ] - }, - { - "locale": "ko", - "source": "While Using the App", - "translations": [ - "앱 사용 중", - "앱을 사용하는 동안" - ] - }, - { - "locale": "nl", - "source": "While Using the App", - "translations": [ - "Bij gebruik van de app", - "Tijdens gebruik van de app" - ] - }, - { - "locale": "pl", - "source": "While Using the App", - "translations": [ - "Podczas korzystania z aplikacji", - "Podczas używania aplikacji" - ] - }, - { - "locale": "pt-BR", - "source": "While Using the App", - "translations": [ - "Ao Usar o App", - "Durante o uso do app" - ] - }, - { - "locale": "sv", - "source": "While Using the App", - "translations": [ - "Medan appen används", - "När appen används" - ] - }, - { - "locale": "th", - "source": "While Using the App", - "translations": [ - "ขณะใช้งานแอป", - "ขณะใช้แอป" - ] - }, - { - "locale": "tr", - "source": "While Using the App", - "translations": [ - "Uygulama Kullanılırken", - "Uygulamayı Kullanırken" - ] - }, - { - "locale": "vi", - "source": "While Using the App", - "translations": [ - "Khi dùng ứng dụng", - "Khi đang dùng ứng dụng" - ] - }, - { - "locale": "zh-CN", - "source": "While Using the App", - "translations": [ - "使用 App 期间", - "使用应用时" - ] - }, - { - "locale": "zh-TW", - "source": "While Using the App", - "translations": [ - "使用 App 期間", - "使用應用程式時" - ] - }, - { - "locale": "de", - "source": "Workboard", - "translations": [ - "Arbeitsboard", - "Workboard" - ] - }, - { - "locale": "id", - "source": "Workboard", - "translations": [ - "Papan Kerja", - "Workboard" - ] - }, - { - "locale": "it", - "source": "Workboard", - "translations": [ - "Bacheca di lavoro", - "Workboard" - ] - }, - { - "locale": "ko", - "source": "Workboard", - "translations": [ - "워크보드", - "작업 보드" - ] - }, - { - "locale": "nl", - "source": "Workboard", - "translations": [ - "Werkbord", - "Workboard" - ] - }, - { - "locale": "th", - "source": "Workboard", - "translations": [ - "Workboard", - "กระดานงาน" - ] - }, - { - "locale": "tr", - "source": "Workboard", - "translations": [ - "Çalışma Panosu", - "Çalışma panosu" - ] - }, - { - "locale": "nl", - "source": "You", - "translations": [ - "Jij", - "U" - ] - }, - { - "locale": "ar", - "source": "active", - "translations": [ - "نشط", - "نشطة" - ] - }, - { - "locale": "es", - "source": "active", - "translations": [ - "activa", - "activo" - ] - }, - { - "locale": "fr", - "source": "active", - "translations": [ - "actif", - "active" - ] - }, - { - "locale": "it", - "source": "active", - "translations": [ - "attiva", - "attivo" - ] - }, - { - "locale": "ja-JP", - "source": "active", - "translations": [ - "アクティブ", - "有効" - ] - }, - { - "locale": "pl", - "source": "active", - "translations": [ - "aktywna", - "aktywne" - ] - }, - { - "locale": "pt-BR", - "source": "active", - "translations": [ - "ativa", - "ativo" - ] - }, - { - "locale": "ru", - "source": "active", - "translations": [ - "активна", - "активно" - ] - }, - { - "locale": "tr", - "source": "active", - "translations": [ - "aktif", - "etkin" - ] - }, - { - "locale": "uk", - "source": "active", - "translations": [ - "активна", - "активно" - ] - }, - { - "locale": "zh-CN", - "source": "active", - "translations": [ - "已启用", - "活跃" - ] - }, - { - "locale": "zh-TW", - "source": "active", - "translations": [ - "使用中", - "啟用中" - ] - }, - { - "locale": "ar", - "source": "enabled", - "translations": [ - "مفعّل", - "مُمكّن" - ] - }, - { - "locale": "pl", - "source": "enabled", - "translations": [ - "włączone", - "włączono" - ] - }, - { - "locale": "id", - "source": "idle", - "translations": [ - "siaga", - "tidak aktif" - ] - }, - { - "locale": "pl", - "source": "idle", - "translations": [ - "bezczynne", - "bezczynny" - ] - }, - { - "locale": "ru", - "source": "idle", - "translations": [ - "бездействует", - "ожидание" - ] - }, - { - "locale": "ar", - "source": "live", - "translations": [ - "مباشر", - "متصل" - ] - }, - { - "locale": "es", - "source": "live", - "translations": [ - "en directo", - "en vivo" - ] - }, - { - "locale": "nl", - "source": "live", - "translations": [ - "actief", - "live" - ] - }, - { - "locale": "ru", - "source": "live", - "translations": [ - "активен", - "активно" - ] - }, - { - "locale": "sv", - "source": "live", - "translations": [ - "aktiv", - "live" - ] - }, - { - "locale": "pl", - "source": "off", - "translations": [ - "wył.", - "wyłączone" - ] - }, - { - "locale": "pt-BR", - "source": "off", - "translations": [ - "desativado", - "desligado" - ] - }, - { - "locale": "ru", - "source": "off", - "translations": [ - "выкл.", - "выключено" - ] - }, - { - "locale": "zh-CN", - "source": "off", - "translations": [ - "关", - "关闭" - ] - }, - { - "locale": "id", - "source": "offline", - "translations": [ - "luring", - "offline" - ] - }, - { - "locale": "uk", - "source": "offline", - "translations": [ - "не в мережі", - "офлайн" - ] - }, - { - "locale": "ar", - "source": "on", - "translations": [ - "قيد التشغيل", - "مفعّل" - ] - }, - { - "locale": "de", - "source": "on", - "translations": [ - "an", - "ein" - ] - }, - { - "locale": "pl", - "source": "on", - "translations": [ - "wł.", - "włączone" - ] - }, - { - "locale": "zh-CN", - "source": "on", - "translations": [ - "开", - "开启" - ] - }, - { - "locale": "uk", - "source": "online", - "translations": [ - "онлайн", - "у мережі" - ] - }, - { - "locale": "de", - "source": "setup", - "translations": [ - "Einrichtung", - "einrichten" - ] - }, - { - "locale": "it", - "source": "setup", - "translations": [ - "configurazione", - "da configurare" - ] - } - ] -} diff --git a/scripts/apple-app-i18n.ts b/scripts/apple-app-i18n.ts index c25ad9473c40..0d85d8542430 100644 --- a/scripts/apple-app-i18n.ts +++ b/scripts/apple-app-i18n.ts @@ -39,7 +39,6 @@ const INFLECTED_COUNT_MARKER = "](inflect: true)"; const IOS_CATALOG_PATH = "apps/ios/Resources/Localizable.xcstrings"; const MACOS_CATALOG_PATH = "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings"; const MACOS_INFO_PLIST_PATH = "apps/macos/Sources/OpenClaw/Resources/Info.plist"; -const IOS_CONTRADICTIONS_PATH = "apps/.i18n/apple-translation-contradictions.json"; const NATIVE_SOURCE_PATH = "apps/.i18n/native-source.json"; const NATIVE_TRANSLATIONS_DIR = "apps/.i18n/native"; const SHARED_CHAT_UI_SOURCE_PREFIX = "apps/shared/OpenClawKit/Sources/OpenClawChatUI/"; @@ -479,10 +478,6 @@ function serializeCatalog(catalog: Catalog): string { return `${JSON.stringify(catalog, null, 2)}\n`; } -function serializeContradictions(contradictions: AppleTranslationContradiction[]): string { - return `${JSON.stringify({ version: 1, contradictions }, null, 2)}\n`; -} - function decodeXml(value: string): string { return value .replaceAll(""", '"') @@ -933,17 +928,6 @@ export async function syncIosCatalog(write: boolean): Promise } await writeFile(catalogPath, expected, "utf8"); } - const contradictionsPath = path.join(ROOT, IOS_CONTRADICTIONS_PATH); - const expectedContradictions = serializeContradictions(build.contradictions); - const actualContradictions = await readOptionalFile(contradictionsPath); - if (actualContradictions !== expectedContradictions) { - if (!write) { - throw new Error( - `Apple contradiction report ${IOS_CONTRADICTIONS_PATH} is stale; run apple-app-i18n.ts sync-ios --write`, - ); - } - await writeFile(contradictionsPath, expectedContradictions, "utf8"); - } return build; } @@ -971,9 +955,9 @@ export function assertMacosCatalogCurrent(actual: string, build: AppleCatalogBui } /** - * Regenerates every Apple derived artifact (app catalogs, contradiction report, - * InfoPlist strings). Shared by this CLI and native-app-i18n's sync so the - * inventory can never be rewritten without its derived catalogs. + * Regenerates every Apple derived artifact (app catalogs and InfoPlist strings). + * Shared by this CLI and native-app-i18n's sync so the inventory can never be + * rewritten without its derived catalogs. */ export async function syncAppleAppI18n(): Promise<{ build: AppleCatalogBuild; diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index a670f7fa5336..084580422a14 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -74,7 +74,7 @@ const NATIVE_I18N_SCOPE_RE = const NATIVE_COOWNED_GENERATED_I18N_RE = /^apps\/android\/app\/src\/main\/res\/values\/(?:assistant|strings)\.xml$/; const NATIVE_HARD_GENERATED_I18N_RE = - /^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/\.i18n\/apple-translation-contradictions\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml|apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/macos\/Sources\/OpenClaw\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/; + /^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml|apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/macos\/Sources\/OpenClaw\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/; const FAST_INSTALL_SMOKE_SCOPE_RE = /^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/; const FULL_INSTALL_SMOKE_SCOPE_RE = diff --git a/src/scripts/ci-changed-scope.native-i18n.test.ts b/src/scripts/ci-changed-scope.native-i18n.test.ts index d1952ac979db..35089a23aafa 100644 --- a/src/scripts/ci-changed-scope.native-i18n.test.ts +++ b/src/scripts/ci-changed-scope.native-i18n.test.ts @@ -20,7 +20,6 @@ describe("native i18n changed scope", () => { ]; const generatedPaths = [ "apps/.i18n/native/sv.json", - "apps/.i18n/apple-translation-contradictions.json", "apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt", "apps/android/app/src/main/res/values-sv/strings.xml", "apps/android/app/src/thirdParty/res/values-sv/accessibility_strings.xml", diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 7517ffc33186..3c2c8301a3a6 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -1178,7 +1178,6 @@ describe("ci workflow guards", () => { ); expect(nativePublishStep.with["generated-paths"].trim().split("\n")).toEqual([ "apps/.i18n/native", - "apps/.i18n/apple-translation-contradictions.json", "apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt", "apps/android/app/src/main/res/values*/assistant.xml", "apps/android/app/src/main/res/values*/strings.xml", From 554d786b7ba080ae205e851e019267188db9df42 Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Sat, 1 Aug 2026 12:38:33 +0900 Subject: [PATCH 15/15] fix(onboard): stop a full reset from resetting the default workspace when the config is unreadable (#114110) The unreadable-config guard tested !snapshot.sourceConfig, but ConfigFileSnapshot.sourceConfig is required and every construction site passes an object ({} when the read failed), so the guard never fired and --reset-scope full fell through to its DEFAULT_WORKSPACE fallback. Test it against snapshot.readError, the field io.snapshot.ts sets only when the config file could not be read. A readable config that simply configures no workspace keeps using the default. Co-authored-by: Peter Steinberger --- src/commands/onboard.test.ts | 30 ++++++++++++++++++++++++++++++ src/commands/onboard.ts | 5 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 07adc2505a23..5ee8ec3def69 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -5,6 +5,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; import { setupWizardCommand } from "./onboard.js"; type ConfigSnapshotStub = { @@ -12,6 +13,7 @@ type ConfigSnapshotStub = { valid: boolean; config: OpenClawConfig; sourceConfig?: OpenClawConfig; + readError?: { code: string | null }; }; type ProviderAuthMethodNonInteractiveValidationContext = Parameters< @@ -364,10 +366,14 @@ describe("setupWizardCommand", () => { it("requires an explicit workspace for a full reset when config is unreadable", async () => { const runtime = makeRuntime(); + // readConfigFileSnapshot always returns a sourceConfig object, so an + // unreadable config is only recognizable through readError. mocks.readConfigFileSnapshot.mockResolvedValue({ exists: true, valid: false, config: {}, + sourceConfig: {}, + readError: { code: "EACCES" }, }); await setupWizardCommand( @@ -384,6 +390,30 @@ describe("setupWizardCommand", () => { expect(mocks.handleReset).not.toHaveBeenCalled(); }); + it("uses the default workspace for a full reset when a readable config configures none", async () => { + const runtime = makeRuntime(); + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: false, + config: {}, + sourceConfig: { gateway: { port: 1 } }, + }); + + await setupWizardCommand( + { + reset: true, + resetScope: "full", + }, + runtime, + ); + + expect(mocks.handleReset).toHaveBeenCalledWith( + "full", + resolveUserPath("~/.openclaw/workspace"), + runtime, + ); + }); + it("accepts explicit --reset-scope full", async () => { const runtime = makeRuntime(); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 0c2755a00266..2e34e49d6ab2 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -578,7 +578,10 @@ export async function setupWizardCommand( normalizedOpts.workspace === undefined && snapshot.exists && !snapshot.valid && - !snapshot.sourceConfig + // A snapshot always carries a sourceConfig object (empty on failure), so + // only readError distinguishes "config could not be read" from "config + // parsed but configures no workspace", where the default is correct. + snapshot.readError !== undefined ) { rejectOption( runtime,