diff --git a/CONTEXT.md b/CONTEXT.md index 574b6a5e..eb9fb4af 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,12 @@ A cockpit is the user-facing control surface for coding-agent work. In JCode thi The cockpit coordinates local tools and provider runtimes; it should not leak raw provider protocol details directly into ordinary user-facing concepts. +### OpenClaw Gateway Provider + +The OpenClaw Gateway Provider is a first-class JCode Provider that connects to a user-configured OpenClaw Gateway URL and presents OpenClaw chat inside normal JCode Threads. + +Its first version should treat the OpenClaw Gateway as a provider runtime rather than a separate external chat launcher. JCode owns the Settings configuration, provider health, thread-to-session mapping, and runtime-event translation while keeping OpenClaw protocol details behind the Provider boundary. + ### Skill Library The Skill Library is the settings-native surface for discovering and managing coding-agent skills across providers such as OpenCode and Codex. diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 883e2889..1b5cea05 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -43,6 +43,25 @@ const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); const itLiveUnlessCi = (process.env.CI ? it.skip : it.live) as typeof it.live; type IntegrationProvider = ProviderKind; +function defaultModelSelectionFor( + provider: Exclude, +): ModelSelection { + switch (provider) { + case "codex": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.codex }; + case "claudeAgent": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.claudeAgent }; + case "cursor": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.cursor }; + case "gemini": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.gemini }; + case "kilo": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.kilo }; + case "opencode": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.opencode }; + } +} + function nowIso() { return new Date().toISOString(); } @@ -109,10 +128,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); const provider = harness.adapterHarness?.provider ?? "codex"; - if (provider === "pi") { - throw new Error("Pi integration tests require an explicit model selection."); + if (provider === "openclaw" || provider === "pi") { + throw new Error("OpenClaw and Pi integration tests require an explicit model selection."); } - const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; + const defaultModelSelection = defaultModelSelectionFor(provider); yield* harness.engine.dispatch({ type: "project.create", @@ -120,10 +139,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, - defaultModelSelection: { - provider, - model: defaultModel, - }, + defaultModelSelection, createdAt, }); @@ -133,10 +149,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", - modelSelection: { - provider, - model: defaultModel, - }, + modelSelection: defaultModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 144960ca..01435ff2 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -15,6 +15,8 @@ import { Open, type OpenShape } from "./open"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./effectServer"; +import { ServerSettingsService } from "./serverSettings"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; vi.mock("./threadRetention", async () => { const Effect = await import("effect/Effect"); @@ -37,6 +39,14 @@ const serverStart = Effect.acquireRelease( () => Effect.sync(() => stop()), ); const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)); +const serverSecretStoreLayer = ServerSecretStoreLive.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "jcode-main-test-", + }), + ), + Layer.provide(NodeServices.layer), +); // Shared service layer used by this CLI test suite. const testLayer = Layer.mergeAll( @@ -62,6 +72,8 @@ const testLayer = Layer.mergeAll( AnalyticsService.layerTest, FetchHttpClient.layer, NodeServices.layer, + ServerSettingsService.layerTest(), + serverSecretStoreLayer, ); const runCli = ( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6df3e123..0e63b276 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -882,7 +882,7 @@ const make = Effect.gen(function* () { input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; const modelForTurn = sessionModelSwitch === "unsupported" - ? activeSession?.model !== undefined + ? activeSession?.model !== undefined && requestedModelSelection.provider !== "openclaw" ? { ...requestedModelSelection, model: activeSession.model, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index eeb54f23..4728f1a7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1715,9 +1715,11 @@ const make = Effect.gen(function* () { (entry) => entry.id === childThreadId, ); const resolvedModelSelection = - identity?.model && identity.modelIsRequestedHint !== true + identity?.model && + identity.modelIsRequestedHint !== true && + parentThread.modelSelection.provider !== "openclaw" ? { - provider: parentThread.modelSelection.provider, + ...parentThread.modelSelection, model: identity.model, } : undefined; diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.test.ts b/apps/server/src/provider/Layers/OpenClawAdapter.test.ts new file mode 100644 index 00000000..7906bc62 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawAdapter.test.ts @@ -0,0 +1,435 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ApprovalRequestId, ThreadId, TurnId } from "@jcode/contracts"; +import { Effect, Fiber, Layer, Stream } from "effect"; +import { it, vi } from "@effect/vitest"; + +import { + ServerSecretStore, + type ServerSecretStoreShape, +} from "../../auth/Services/ServerSecretStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { OpenClawRequest } from "../openclawGatewayProtocol.ts"; +import { + type OpenClawGatewayClient, + type OpenClawGatewayConnectInput, + type OpenClawGatewayEvent, + type OpenClawGatewayRequestResult, +} from "../openclawGatewayClient.ts"; +import { OPENCLAW_SECRET_NAMES, deriveOpenClawDeviceId } from "../openclawSecrets.ts"; +import { OpenClawAdapter } from "../Services/OpenClawAdapter.ts"; +import { makeOpenClawAdapterLive } from "./OpenClawAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => + ApprovalRequestId.makeUnsafe(value); + +const textEncoder = new TextEncoder(); + +class FakeOpenClawGatewayClient implements OpenClawGatewayClient { + public connectImpl = vi.fn((_input: OpenClawGatewayConnectInput) => + Effect.succeed({ methods: ["chat.history", "chat.send", "chat.abort"] }), + ); + public requestImpl = vi.fn((_request: OpenClawRequest) => + Effect.succeed({} satisfies OpenClawGatewayRequestResult), + ); + + connect: OpenClawGatewayClient["connect"] = (input) => this.connectImpl(input); + request: OpenClawGatewayClient["request"] = (request) => this.requestImpl(request); +} + +type SecretStoreValue = string | Uint8Array; + +function makeSecretStore(values: Record): ServerSecretStoreShape { + return { + get: (name) => + Effect.succeed( + values[name] instanceof Uint8Array + ? values[name] + : values[name] + ? textEncoder.encode(values[name]) + : null, + ), + set: vi.fn((_name, _value) => Effect.void), + getOrCreateRandom: vi.fn((_name, bytes) => Effect.succeed(new Uint8Array(bytes))), + remove: vi.fn((_name) => Effect.void), + }; +} + +function makeLayer(input: { + readonly client: FakeOpenClawGatewayClient; + readonly secrets?: Record; +}) { + return makeOpenClawAdapterLive({ gatewayClient: input.client }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + openclaw: { + gatewayUrl: "https://gateway.example.test/path?token=must-not-leak", + authMode: "token", + hasSecret: true, + }, + }, + }), + ), + Layer.provideMerge( + Layer.succeed( + ServerSecretStore, + makeSecretStore(input.secrets ?? { [OPENCLAW_SECRET_NAMES.token]: "openclaw-secret" }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +function collectEvents(count: number) { + return Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter.streamEvents.pipe(Stream.take(count), Stream.runCollect); + }); +} + +const startOpenClawSession = Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + modelSelection: { provider: "openclaw", model: "gateway" }, + }); +}); + +it.effect( + "starts a session by resolving settings/secrets, validating methods, and loading history", + () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.requestImpl.mockImplementation((request) => + request.method === "chat.history" + ? Effect.succeed({ turns: [{ id: "existing-turn", items: [] }] }) + : Effect.succeed({}), + ); + + const session = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + cwd: "/tmp/project", + runtimeMode: "full-access", + modelSelection: { provider: "openclaw", model: "gateway" }, + }); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(session.provider, "openclaw"); + assert.equal(session.status, "ready"); + assert.equal(session.threadId, asThreadId("thread-openclaw")); + assert.equal(session.model, "gateway"); + + assert.equal( + client.connectImpl.mock.calls[0]?.[0].websocketUrl, + "wss://gateway.example.test/path", + ); + assert.deepEqual(client.connectImpl.mock.calls[0]?.[0].auth, { + type: "token", + token: "openclaw-secret", + }); + assert.deepEqual(client.requestImpl.mock.calls[0]?.[0], { + method: "chat.history", + params: { sessionKey: "jcode:thread-openclaw" }, + }); + }), +); + +it.effect("uses settings gateway URL and ignores browser-supplied OpenClaw provider options", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + + yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + providerOptions: { openclaw: { gatewayUrl: "https://browser.example.test/steal" } }, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal( + client.connectImpl.mock.calls[0]?.[0].websocketUrl, + "wss://gateway.example.test/path", + ); + }), +); + +it.effect("sends stable derived device ids instead of raw binary device keys", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + const deviceKey = new Uint8Array([1, 2, 3, 4]); + + yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + }); + }).pipe( + Effect.provide( + makeOpenClawAdapterLive({ gatewayClient: client }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + openclaw: { + gatewayUrl: "https://gateway.example.test/path", + authMode: "device", + paired: true, + }, + }, + }), + ), + Layer.provideMerge( + Layer.succeed( + ServerSecretStore, + makeSecretStore({ + [OPENCLAW_SECRET_NAMES.deviceKey]: deviceKey, + [OPENCLAW_SECRET_NAMES.deviceToken]: "paired-token", + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + + assert.deepEqual(client.connectImpl.mock.calls[0]?.[0].device, { + id: deriveOpenClawDeviceId(deviceKey), + token: "paired-token", + }); + }), +); + +it.effect("rejects gateways missing required chat methods", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.connectImpl.mockImplementation(() => Effect.succeed({ methods: ["chat.history"] })); + + const result = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter + .startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + }) + .pipe(Effect.result); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterRequestError"); + assert.match(result.failure.message, /chat\.send/); + assert.match(result.failure.message, /chat\.abort/); + } + }), +); + +it.effect("sends text turns and emits assistant/completion events from gateway events", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + const gatewayEvents: ReadonlyArray = [ + { type: "assistant.delta", runId: "run-1", text: "Hel" }, + { type: "assistant.delta", runId: "run-1", text: "lo" }, + { type: "assistant.completed", runId: "run-1", text: "Hello" }, + { type: "run.completed", runId: "run-1", stopReason: "end_turn" }, + ]; + client.requestImpl.mockImplementation((request) => + request.method === "chat.send" + ? Effect.succeed({ runId: "run-1", events: gatewayEvents }) + : Effect.succeed({}), + ); + + const { result, events } = yield* Effect.gen(function* () { + const eventsFiber = yield* collectEvents(5).pipe(Effect.forkChild); + const adapter = yield* OpenClawAdapter; + yield* startOpenClawSession; + client.requestImpl.mockClear(); + const result = yield* adapter.sendTurn({ + threadId: asThreadId("thread-openclaw"), + input: "Hello OpenClaw", + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { result, events }; + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(result.threadId, asThreadId("thread-openclaw")); + assert.deepEqual(client.requestImpl.mock.calls[0]?.[0], { + method: "chat.send", + params: { + sessionKey: "jcode:thread-openclaw", + message: "Hello OpenClaw", + idempotencyKey: `jcode:thread-openclaw:${result.turnId}`, + }, + }); + assert.deepEqual( + events.map((event) => event.type), + ["turn.started", "content.delta", "content.delta", "item.completed", "turn.completed"], + ); + assert.deepEqual(events[1]?.payload, { streamKind: "assistant_text", delta: "Hel" }); + assert.deepEqual(events[2]?.payload, { streamKind: "assistant_text", delta: "lo" }); + assert.deepEqual(events[3]?.payload, { + itemType: "assistant_message", + status: "completed", + data: { text: "Hello" }, + }); + assert.deepEqual(events[4]?.payload, { state: "completed", stopReason: "end_turn" }); + }), +); + +it.effect("emits failed canonical events with redacted gateway raw payloads", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.requestImpl.mockImplementation((request) => + request.method === "chat.send" + ? Effect.succeed({ + runId: "run-2", + events: [ + { + type: "error", + runId: "run-2", + message: "gateway failed", + token: "secret-token", + password: "secret-password", + nested: { authorization: "Bearer secret" }, + }, + ], + }) + : Effect.succeed({}), + ); + + const events = yield* Effect.gen(function* () { + const eventsFiber = yield* collectEvents(3).pipe(Effect.forkChild); + const adapter = yield* OpenClawAdapter; + yield* startOpenClawSession; + yield* adapter.sendTurn({ threadId: asThreadId("thread-openclaw"), input: "fail" }); + return Array.from(yield* Fiber.join(eventsFiber)); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.deepEqual( + events.map((event) => event.type), + ["turn.started", "runtime.error", "turn.completed"], + ); + assert.deepEqual(events[1]?.payload, { + message: "gateway failed", + class: "provider_error", + }); + assert.equal(events[1]?.raw?.source, "openclaw.gateway.event"); + assert.equal(JSON.stringify(events[1]?.raw?.payload).includes("secret"), false); + assert.deepEqual(events[2]?.payload, { state: "failed", errorMessage: "gateway failed" }); + }), +); + +it.effect("aborts active turns and stopped sessions with chat.abort", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.requestImpl.mockImplementation((request) => + request.method === "chat.send" ? Effect.succeed({ runId: "run-1" }) : Effect.succeed({}), + ); + + yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + yield* startOpenClawSession; + client.requestImpl.mockClear(); + const turn = yield* adapter.sendTurn({ + threadId: asThreadId("thread-openclaw"), + input: "abort me", + }); + yield* adapter.interruptTurn(asThreadId("thread-openclaw"), turn.turnId, "provider-thread-1"); + yield* adapter.stopSession(asThreadId("thread-openclaw")); + }).pipe(Effect.provide(makeLayer({ client }))); + + const requests = client.requestImpl.mock.calls.map((call) => call[0]); + const sendRequest = requests[0]; + assert.ok(sendRequest); + assert.equal(sendRequest.method, "chat.send"); + assert.ok("sessionKey" in sendRequest.params); + assert.ok("message" in sendRequest.params); + assert.ok("idempotencyKey" in sendRequest.params); + assert.equal(sendRequest.params.sessionKey, "jcode:thread-openclaw"); + assert.equal(sendRequest.params.message, "abort me"); + assert.match(String(sendRequest.params.idempotencyKey), /^jcode:thread-openclaw:/); + assert.deepEqual(requests.slice(1), [ + { method: "chat.abort", params: { sessionKey: "jcode:thread-openclaw", runId: "run-1" } }, + { method: "chat.abort", params: { sessionKey: "jcode:thread-openclaw" } }, + ]); + }), +); + +it.effect("fails unsupported approvals and structured user-input clearly", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + + const result = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + const approval = yield* adapter + .respondToRequest(asThreadId("thread-openclaw"), asApprovalRequestId("request-1"), "accept") + .pipe(Effect.result); + const userInput = yield* adapter + .respondToUserInput(asThreadId("thread-openclaw"), asApprovalRequestId("request-2"), {}) + .pipe(Effect.result); + return { approval, userInput }; + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(result.approval._tag, "Failure"); + assert.equal(result.userInput._tag, "Failure"); + if (result.approval._tag === "Failure") { + assert.equal(result.approval.failure._tag, "ProviderAdapterValidationError"); + assert.match(result.approval.failure.message, /does not support approvals/); + } + if (result.userInput._tag === "Failure") { + assert.equal(result.userInput.failure._tag, "ProviderAdapterValidationError"); + assert.match(result.userInput.failure.message, /does not support structured user input/); + } + }), +); + +it.effect("redacts gateway secrets when the production gateway client cannot connect", () => + Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter + .startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + }) + .pipe(Effect.result); + }).pipe( + Effect.provide( + makeOpenClawAdapterLive().pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + openclaw: { + gatewayUrl: "https://user:pass@gateway.example.test/ws?token=secret", + }, + }, + }), + ), + Layer.provideMerge(Layer.succeed(ServerSecretStore, makeSecretStore({}))), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterRequestError"); + assert.equal(result.failure.message.includes("secret"), false); + assert.equal(result.failure.message.includes("pass"), false); + assert.match(result.failure.message, /gateway\.example\.test/); + } + }), +); diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.ts b/apps/server/src/provider/Layers/OpenClawAdapter.ts new file mode 100644 index 00000000..d5336dca --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawAdapter.ts @@ -0,0 +1,608 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + ThreadId, + TurnId, +} from "@jcode/contracts"; +import { Effect, Layer, Queue, Ref, Stream } from "effect"; + +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + buildOpenClawAbortRequest, + buildOpenClawChallengeResponse, + buildOpenClawHistoryRequest, + buildOpenClawSendRequest, + type OpenClawAuthFrame, + type OpenClawChallenge, + type OpenClawChallengeResponse, + type OpenClawDeviceFrame, + type OpenClawRequest, + validateOpenClawMethodSupport, +} from "../openclawGatewayProtocol.ts"; +import { + defaultOpenClawGatewayClient, + type OpenClawGatewayClient, + type OpenClawGatewayEvent, + type OpenClawGatewayRequestResult, + type OpenClawGatewaySendResult, +} from "../openclawGatewayClient.ts"; +import { normalizeOpenClawGatewayUrl, OpenClawGatewayUrlError } from "../openclawGatewayUrl.ts"; +import { + deriveOpenClawDeviceId, + getOpenClawSecret, + getOpenClawSecretBytes, +} from "../openclawSecrets.ts"; +import { OpenClawAdapter, type OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; +import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; + +const PROVIDER = "openclaw" as const; + +export interface OpenClawAdapterLiveOptions { + readonly gatewayClient?: OpenClawGatewayClient; +} + +interface OpenClawSessionContext { + readonly session: ProviderSession; + readonly turns: ReadonlyArray<{ readonly id: TurnId; readonly items: ReadonlyArray }>; + readonly activeRunIdsByTurn: ReadonlyMap; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function asTurnId(value: string): TurnId { + return TurnId.makeUnsafe(value); +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function sessionGatewayKey(threadId: ThreadId): string { + return `jcode:${threadId}`; +} + +function requestError( + method: string, + detail: string, + cause?: unknown, +): ProviderAdapterRequestError { + return new ProviderAdapterRequestError({ provider: PROVIDER, method, detail, cause }); +} + +function validationError(operation: string, issue: string): ProviderAdapterValidationError { + return new ProviderAdapterValidationError({ provider: PROVIDER, operation, issue }); +} + +function causeMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message.trim(); + } + return fallback; +} + +function redactSensitiveValue(value: unknown, key = ""): unknown { + const lowerKey = key.toLowerCase(); + if ( + lowerKey.includes("token") || + lowerKey.includes("password") || + lowerKey.includes("secret") || + lowerKey.includes("authorization") || + lowerKey.includes("apikey") || + lowerKey.includes("api_key") + ) { + return "[redacted]"; + } + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveValue(item)); + } + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value as Record).map(([entryKey, entryValue]) => [ + entryKey, + redactSensitiveValue(entryValue, entryKey), + ]), + ); + } + if (typeof value === "string") { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return value; + } + } + return value; +} + +function buildEventBase(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId; + readonly itemId?: string; + readonly raw?: unknown; +}): Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "raw" +> { + return { + eventId: EventId.makeUnsafe(randomUUID()), + provider: PROVIDER, + threadId: input.threadId, + createdAt: nowIso(), + ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), + ...(input.itemId !== undefined ? { itemId: asRuntimeItemId(input.itemId) } : {}), + ...(input.raw !== undefined + ? { raw: { source: "openclaw.gateway.event", payload: redactSensitiveValue(input.raw) } } + : {}), + }; +} + +function sendResultEvents( + result: OpenClawGatewayRequestResult, +): ReadonlyArray { + if (typeof result !== "object" || result === null || !("events" in result)) { + return []; + } + const events = (result as OpenClawGatewaySendResult).events; + return Array.isArray(events) ? events : []; +} + +function historyTurns( + result: OpenClawGatewayRequestResult, +): ReadonlyArray<{ readonly id: TurnId; readonly items: ReadonlyArray }> { + if (typeof result !== "object" || result === null || !("turns" in result)) { + return []; + } + const turns = (result as { readonly turns?: ReadonlyArray }).turns; + if (!Array.isArray(turns)) { + return []; + } + return turns.flatMap((turn) => { + if (typeof turn !== "object" || turn === null) { + return []; + } + const record = turn as Record; + const id = typeof record.id === "string" && record.id.trim().length > 0 ? record.id : undefined; + if (id === undefined) { + return []; + } + return [ + { + id: asTurnId(id), + items: Array.isArray(record.items) ? record.items : [], + }, + ]; + }); +} + +export const makeOpenClawAdapterLive = (options: OpenClawAdapterLiveOptions = {}) => + Layer.effect( + OpenClawAdapter, + Effect.gen(function* () { + const settingsService = yield* ServerSettingsService; + const secretStore = yield* ServerSecretStore; + const gatewayClient = options.gatewayClient ?? defaultOpenClawGatewayClient; + const runtimeEvents = yield* Queue.unbounded(); + const sessionsRef = yield* Ref.make(new Map()); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + + const resolveGateway = () => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "Failed to load OpenClaw settings."), + cause, + ), + ), + ); + const gatewayUrl = settings.providers.openclaw.gatewayUrl.trim(); + if (gatewayUrl.length === 0) { + return yield* Effect.fail( + requestError("connect", "OpenClaw gateway URL is not configured."), + ); + } + const normalized = yield* Effect.try({ + try: () => normalizeOpenClawGatewayUrl(gatewayUrl), + catch: (cause) => + requestError( + "connect", + cause instanceof OpenClawGatewayUrlError + ? cause.message + : "Invalid OpenClaw gateway URL.", + cause, + ), + }); + + const authMode = settings.providers.openclaw.authMode; + const readSecret = (kind: "token" | "password" | "deviceKey" | "deviceToken") => + getOpenClawSecret(kind).pipe( + Effect.provideService(ServerSecretStore, secretStore), + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "Failed to read OpenClaw secret metadata."), + cause, + ), + ), + ); + const readSecretBytes = (kind: "deviceKey") => + getOpenClawSecretBytes(kind).pipe( + Effect.provideService(ServerSecretStore, secretStore), + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "Failed to read OpenClaw secret metadata."), + cause, + ), + ), + ); + const token = authMode === "token" ? yield* readSecret("token") : null; + const password = authMode === "password" ? yield* readSecret("password") : null; + const deviceKey = authMode === "device" ? yield* readSecretBytes("deviceKey") : null; + const deviceToken = authMode === "device" ? yield* readSecret("deviceToken") : null; + + const auth: OpenClawAuthFrame | undefined = + authMode === "token" + ? token + ? { type: "token", token } + : undefined + : authMode === "password" + ? password + ? { type: "password", password } + : undefined + : undefined; + const device: OpenClawDeviceFrame | undefined = + authMode === "device" && deviceKey !== null + ? { + id: deriveOpenClawDeviceId(deviceKey), + ...(deviceToken !== null ? { token: deviceToken } : {}), + } + : undefined; + + if ((authMode === "token" || authMode === "password") && auth === undefined) { + return yield* Effect.fail( + requestError("connect", `OpenClaw ${authMode} secret is not configured.`), + ); + } + if (authMode === "device" && device === undefined) { + return yield* Effect.fail( + requestError("connect", "OpenClaw device identity is not configured."), + ); + } + + const respondToChallenge = + authMode === "device" && deviceKey !== null && device !== undefined + ? (challenge: OpenClawChallenge) => + buildOpenClawChallengeResponse({ + challenge, + deviceId: device.id, + deviceKey, + }) + : undefined; + + return { normalized, auth, device, respondToChallenge }; + }); + + const requestGateway = (request: OpenClawRequest) => + gatewayClient + .request(request) + .pipe( + Effect.mapError((cause) => + requestError(request.method, causeMessage(cause, `${request.method} failed.`), cause), + ), + ); + + const adapter: OpenClawAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession: (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* Effect.fail( + validationError( + "startSession", + `Expected provider ${PROVIDER}, received ${input.provider}.`, + ), + ); + } + if ( + input.modelSelection !== undefined && + (input.modelSelection.provider !== PROVIDER || + input.modelSelection.model !== "gateway") + ) { + return yield* Effect.fail( + validationError( + "startSession", + "OpenClaw sessions must use the gateway model sentinel.", + ), + ); + } + + const { normalized, auth, device, respondToChallenge } = yield* resolveGateway(); + const connectResult = yield* gatewayClient + .connect({ + websocketUrl: normalized.websocketUrl, + redactedGatewayUrl: normalized.redactedUrl, + ...(auth !== undefined ? { auth } : {}), + ...(device !== undefined ? { device } : {}), + ...(respondToChallenge !== undefined ? { respondToChallenge } : {}), + }) + .pipe( + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "OpenClaw gateway connection failed."), + cause, + ), + ), + ); + const support = validateOpenClawMethodSupport(connectResult.methods); + if (!support.supported) { + return yield* Effect.fail( + requestError( + "connect", + `OpenClaw gateway is missing required methods: ${support.missing.join(", ")}.`, + ), + ); + } + const history = yield* requestGateway( + buildOpenClawHistoryRequest({ sessionKey: sessionGatewayKey(input.threadId) }), + ); + const now = nowIso(); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + threadId: input.threadId, + model: "gateway", + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + createdAt: now, + updatedAt: now, + }; + yield* Ref.update(sessionsRef, (sessions) => + new Map(sessions).set(input.threadId, { + session, + turns: historyTurns(history), + activeRunIdsByTurn: new Map(), + }), + ); + return session; + }), + sendTurn: (input) => + Effect.gen(function* () { + if (input.attachments !== undefined && input.attachments.length > 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw v1 does not support attachments."), + ); + } + if (input.skills !== undefined && input.skills.length > 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw v1 does not support skill mentions."), + ); + } + if (input.mentions !== undefined && input.mentions.length > 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw v1 does not support provider mentions."), + ); + } + if (input.modelSelection !== undefined && input.modelSelection.provider !== PROVIDER) { + return yield* Effect.fail( + validationError( + "sendTurn", + `Expected provider ${PROVIDER}, received ${input.modelSelection.provider}.`, + ), + ); + } + if (input.input === undefined || input.input.trim().length === 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw turns require text input."), + ); + } + const sessions = yield* Ref.get(sessionsRef); + if (!sessions.has(input.threadId)) { + return yield* Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: input.threadId, + }), + ); + } + + const turnId = asTurnId(`openclaw-turn-${randomUUID()}`); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.started", + payload: { model: "gateway" }, + }); + + const result = yield* requestGateway( + buildOpenClawSendRequest({ + sessionKey: sessionGatewayKey(input.threadId), + threadId: input.threadId, + turnId, + message: input.input, + }), + ); + const gatewayEvents = sendResultEvents(result); + const runId = + ("runId" in result && typeof result.runId === "string" ? result.runId : undefined) ?? + gatewayEvents.find((event) => typeof event.runId === "string")?.runId; + yield* Ref.update(sessionsRef, (currentSessions) => { + const context = currentSessions.get(input.threadId); + if (context === undefined) { + return currentSessions; + } + const activeRunIdsByTurn = new Map(context.activeRunIdsByTurn); + if (runId !== undefined) { + activeRunIdsByTurn.set(turnId, runId); + } + const nextTurns = [ + ...context.turns, + { id: turnId, items: [{ role: "user", text: input.input }] }, + ]; + return new Map(currentSessions).set(input.threadId, { + ...context, + turns: nextTurns, + activeRunIdsByTurn, + }); + }); + if (gatewayEvents.length === 0 && runId !== undefined) { + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: { runId } }), + type: "turn.completed", + payload: { state: "completed", stopReason: null }, + }); + } + for (const event of gatewayEvents) { + switch (event.type) { + case "assistant.delta": { + const delta = event.text ?? event.delta ?? ""; + if (delta.length > 0) { + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "content.delta", + payload: { streamKind: "assistant_text", delta }, + }); + } + break; + } + case "assistant.completed": { + yield* emit({ + ...buildEventBase({ + threadId: input.threadId, + turnId, + itemId: `openclaw-assistant-${event.runId ?? turnId}`, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + data: { text: event.text ?? "" }, + }, + }); + break; + } + case "run.completed": { + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { state: "completed", stopReason: event.stopReason ?? null }, + }); + break; + } + case "error": { + const message = event.message ?? "OpenClaw gateway error."; + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "runtime.error", + payload: { message, class: "provider_error" }, + }); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { state: "failed", errorMessage: message }, + }); + break; + } + } + } + return { threadId: input.threadId, turnId }; + }), + interruptTurn: (threadId, turnId) => + Ref.get(sessionsRef).pipe( + Effect.flatMap((sessions) => { + const runId = turnId + ? sessions.get(threadId)?.activeRunIdsByTurn.get(turnId) + : undefined; + return requestGateway( + buildOpenClawAbortRequest({ + sessionKey: sessionGatewayKey(threadId), + ...(runId !== undefined ? { runId } : {}), + }), + ); + }), + Effect.asVoid, + ), + respondToRequest: () => + Effect.fail( + validationError("respondToRequest", "OpenClaw v1 does not support approvals."), + ), + respondToUserInput: () => + Effect.fail( + validationError( + "respondToUserInput", + "OpenClaw v1 does not support structured user input.", + ), + ), + stopSession: (threadId) => + requestGateway( + buildOpenClawAbortRequest({ sessionKey: sessionGatewayKey(threadId) }), + ).pipe( + Effect.tap(() => + Ref.update(sessionsRef, (sessions) => { + const next = new Map(sessions); + next.delete(threadId); + return next; + }), + ), + Effect.asVoid, + ), + listSessions: () => + Ref.get(sessionsRef).pipe( + Effect.map((sessions) => Array.from(sessions.values(), (context) => context.session)), + ), + hasSession: (threadId) => + Ref.get(sessionsRef).pipe(Effect.map((sessions) => sessions.has(threadId))), + readThread: (threadId): Effect.Effect => + Ref.get(sessionsRef).pipe( + Effect.flatMap((sessions) => { + const context = sessions.get(threadId); + if (context === undefined) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed({ threadId, turns: context.turns }); + }), + ), + rollbackThread: () => + Effect.fail(validationError("rollbackThread", "OpenClaw v1 does not support rollback.")), + stopAll: () => + Ref.get(sessionsRef).pipe( + Effect.flatMap((sessions) => + Effect.forEach( + Array.from(sessions.keys()), + (threadId) => adapter.stopSession(ThreadId.makeUnsafe(threadId)), + { discard: true }, + ), + ), + Effect.asVoid, + ), + streamEvents: Stream.fromQueue(runtimeEvents), + }; + + return adapter; + }), + ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 635dd37e..6bbf45b7 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -10,6 +10,7 @@ import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts" import { GeminiAdapter, GeminiAdapterShape } from "../Services/GeminiAdapter.ts"; import { KiloAdapter, KiloAdapterShape } from "../Services/KiloAdapter.ts"; import { OpenCodeAdapter, OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { OpenClawAdapter, OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; import { PiAdapter, PiAdapterShape } from "../Services/PiAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -101,6 +102,23 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { streamEvents: Stream.empty, }; +const fakeOpenClawAdapter: OpenClawAdapterShape = { + provider: "openclaw", + capabilities: { sessionModelSwitch: "unsupported" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const fakeKiloAdapter: KiloAdapterShape = { provider: "kilo", capabilities: { sessionModelSwitch: "in-session" }, @@ -146,6 +164,7 @@ const layer = it.layer( Layer.succeed(GeminiAdapter, fakeGeminiAdapter), Layer.succeed(KiloAdapter, fakeKiloAdapter), Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), + Layer.succeed(OpenClawAdapter, fakeOpenClawAdapter), Layer.succeed(PiAdapter, fakePiAdapter), ), ), @@ -163,6 +182,7 @@ layer("ProviderAdapterRegistryLive", (it) => { const gemini = yield* registry.getByProvider("gemini"); const kilo = yield* registry.getByProvider("kilo"); const opencode = yield* registry.getByProvider("opencode"); + const openclaw = yield* registry.getByProvider("openclaw"); const pi = yield* registry.getByProvider("pi"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); @@ -170,6 +190,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.equal(gemini, fakeGeminiAdapter); assert.equal(kilo, fakeKiloAdapter); assert.equal(opencode, fakeOpenCodeAdapter); + assert.equal(openclaw, fakeOpenClawAdapter); assert.equal(pi, fakePiAdapter); const providers = yield* registry.listProviders(); @@ -180,6 +201,7 @@ layer("ProviderAdapterRegistryLive", (it) => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); }), diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b607ebbd..7fce6fdc 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -21,6 +21,7 @@ import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { GeminiAdapter } from "../Services/GeminiAdapter.ts"; import { KiloAdapter } from "../Services/KiloAdapter.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { OpenClawAdapter } from "../Services/OpenClawAdapter.ts"; import { PiAdapter } from "../Services/PiAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { @@ -39,6 +40,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption yield* GeminiAdapter, yield* KiloAdapter, yield* OpenCodeAdapter, + yield* OpenClawAdapter, yield* PiAdapter, ]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index bd22c266..6346615f 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -10,6 +10,7 @@ import { checkCodexProviderStatus, checkCursorProviderStatus, checkOpenCodeProviderStatus, + checkOpenClawProviderStatus, hasCustomModelProvider, isExternalOpenCodeRuntimeActive, makeCheckClaudeProviderStatus, @@ -17,6 +18,7 @@ import { makeCheckCursorProviderStatus, makeCheckKiloProviderStatus, makeCheckOpenCodeProviderStatus, + makeCheckOpenClawProviderStatus, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, ProviderHealthLive, @@ -24,12 +26,33 @@ import { } from "./ProviderHealth"; import { ServerConfig } from "../../config"; import { ServerSettingsService } from "../../serverSettings"; +import { ServerSecretStoreLive } from "../../auth/Layers/ServerSecretStore"; +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore"; +import { setOpenClawToken } from "../openclawSecrets"; import { ProviderHealth } from "../Services/ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const openClawSecretLayer = ServerSecretStoreLive.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "jcode-provider-health-openclaw-test-", + }), + ), + Layer.provide(NodeServices.layer), +); + +const openClawTokenSecretLayer = Layer.effect( + ServerSecretStore, + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* setOpenClawToken("token-secret"); + return store; + }), +).pipe(Layer.provide(openClawSecretLayer)); + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -413,28 +436,28 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { Effect.gen(function* () { yield* withTempCodexHome(); assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns undefined when config has no model_provider key", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\n'); assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns the provider when model_provider is set at top level", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns openai when model_provider is openai", () => Effect.gen(function* () { yield* withTempCodexHome('model_provider = "openai"\n'); assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("ignores model_provider inside section headers", () => @@ -450,7 +473,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ].join("\n"), ); assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("handles comments and whitespace", () => @@ -466,14 +489,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ].join("\n"), ); assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("handles single-quoted values in TOML", () => Effect.gen(function* () { yield* withTempCodexHome("model_provider = 'mistral'\n"); assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); }); @@ -484,14 +507,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { Effect.gen(function* () { yield* withTempCodexHome(); assert.strictEqual(yield* hasCustomModelProvider, false); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns false when model_provider is not set", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\n'); assert.strictEqual(yield* hasCustomModelProvider, false); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns false when model_provider is openai", () => @@ -733,6 +756,225 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ); }); + describe("checkOpenClawProviderStatus", () => { + it.effect("reports unconfigured when no gateway URL is set", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /gateway URL is not configured/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports ready when the gateway probe succeeds", () => + Effect.gen(function* () { + let capturedAuth: unknown; + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://gateway.example.test/path?token=must-not-leak", + authMode: "token", + hasSecret: true, + paired: false, + }, + { + probe: (input) => { + capturedAuth = input.auth; + return Effect.succeed({ methods: ["chat.history", "chat.send", "chat.abort"] }); + }, + }, + ); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.authType, "token"); + assert.deepStrictEqual(capturedAuth, { type: "token", token: "token-secret" }); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawTokenSecretLayer)), + ); + + it.effect("does not report available when live gateway probing is unavailable", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://gateway.example.test/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /probing is not configured/); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("live wrapper attempts the default gateway probe", () => + Effect.gen(function* () { + const status = yield* checkOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://127.0.0.1:1/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.match(status.message ?? "", /gateway probe failed/); + assert.strictEqual(/probing is not configured/.test(status.message ?? ""), false); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports pairing needed when device mode is not paired", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "device", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.match(status.message ?? "", /pairing is required/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports unauthenticated when token mode has no stored secret", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "token", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.match(status.message ?? "", /token secret is not configured/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports unreachable when the gateway probe fails", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://user:pass@gateway.example.test/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }, + { probe: () => Effect.fail(new Error("connection refused token=must-not-leak")) }, + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /probe failed/); + assert.match(status.message ?? "", /connection refused/); + assert.strictEqual(/must-not-leak|user:pass/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports unauthenticated when the gateway probe rejects auth", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://gateway.example.test/path?token=must-not-leak", + authMode: "token", + hasSecret: true, + paired: false, + }, + { probe: () => Effect.fail(new Error("authentication failed token=must-not-leak")) }, + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.authType, "token"); + assert.match(status.message ?? "", /authentication failed/); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawTokenSecretLayer)), + ); + + it.effect("reports unsupported when required chat methods are missing", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "none", + hasSecret: false, + paired: false, + }, + { probe: () => Effect.succeed({ methods: ["chat.history"] }) }, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "authenticated"); + assert.match(status.message ?? "", /chat\.send/); + assert.match(status.message ?? "", /chat\.abort/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports protocol mismatch when the gateway protocol is outside v1 support", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "none", + hasSecret: false, + paired: false, + }, + { + probe: () => + Effect.succeed({ + methods: ["chat.history", "chat.send", "chat.abort"], + protocolVersion: 5, + }), + }, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /protocol/i); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("rejects public insecure WebSocket gateway URLs", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "ws://gateway.example.test/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /requires wss/); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + }); + describe("isExternalOpenCodeRuntimeActive", () => { it("does not treat the default OpenCode CLI runtime as external", () => { assert.strictEqual(isExternalOpenCodeRuntimeActive(DEFAULT_SERVER_SETTINGS), false); @@ -917,6 +1159,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "jcode-provider-health-" }), ), + Effect.provide(openClawSecretLayer), Effect.provide( mockSpawnerLayer((args, command, options) => { calls.push({ command, args, shell: options.shell }); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 47a27719..a3f43ca5 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -11,6 +11,7 @@ import * as OS from "node:os"; import * as nodePath from "node:path"; import type { + OpenClawServerProviderSettings, ProviderKind, ServerSettings, ServerProviderAuthStatus, @@ -51,8 +52,29 @@ import { } from "../codexCliVersion"; import { ServerConfig } from "../../config"; import { ServerSettingsService } from "../../serverSettings"; +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore"; import { isWindowsShellCommandMissingResult } from "../../shell-command-detection"; import { normalizeGeminiCapabilityProbeResult, probeGeminiCapabilities } from "../geminiAcpProbe"; +import { + buildOpenClawChallengeResponse, + type OpenClawAuthFrame, + type OpenClawChallenge, + type OpenClawChallengeResponse, + type OpenClawDeviceFrame, + OPENCLAW_MAX_PROTOCOL_VERSION, + OPENCLAW_MIN_PROTOCOL_VERSION, + validateOpenClawMethodSupport, +} from "../openclawGatewayProtocol"; +import { + defaultOpenClawHealthProbeClient, + type OpenClawGatewayConnectInput, +} from "../openclawGatewayClient"; +import { normalizeOpenClawGatewayUrl } from "../openclawGatewayUrl"; +import { + deriveOpenClawDeviceId, + getOpenClawSecret, + getOpenClawSecretBytes, +} from "../openclawSecrets"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; import { orderProviderStatuses, @@ -79,6 +101,7 @@ const CURSOR_PROVIDER = "cursor" as const; const GEMINI_PROVIDER = "gemini" as const; const KILO_PROVIDER = "kilo" as const; const OPENCODE_PROVIDER = "opencode" as const; +const OPENCLAW_PROVIDER = "openclaw" as const; const PI_PROVIDER = "pi" as const; type ProviderStatuses = ReadonlyArray; @@ -89,6 +112,7 @@ const PROVIDERS = [ GEMINI_PROVIDER, KILO_PROVIDER, OPENCODE_PROVIDER, + OPENCLAW_PROVIDER, PI_PROVIDER, ] as const satisfies ReadonlyArray; @@ -218,6 +242,25 @@ function detailFromResult( return undefined; } +function detailFromUnknown(error: unknown): string | undefined { + const raw = + error instanceof Error ? error.message : typeof error === "string" ? error : undefined; + const detail = nonEmptyTrimmed(raw); + if (!detail) return undefined; + return detail + .replace(/\/\/[^/@\s]+@/g, "//") + .replace(/\b(token|password|client_secret|secret)=([^\s&]+)/gi, "$1=[redacted]"); +} + +function isAuthLikeProbeFailure(detail: string | undefined): boolean { + return ( + detail !== undefined && + /auth|unauth|forbid|credential|invalid token|token rejected|invalid password|password rejected/i.test( + detail, + ) + ); +} + function extractAuthBoolean(value: unknown): boolean | undefined { if (Array.isArray(value)) { for (const entry of value) { @@ -1377,6 +1420,203 @@ export const makeCheckKiloProviderStatus = ( export const checkKiloProviderStatus = makeCheckKiloProviderStatus(); +export interface OpenClawHealthProbeResult { + readonly methods?: ReadonlyArray; + readonly protocolVersion?: number; +} + +export interface OpenClawHealthProbeClient { + readonly probe: ( + input: OpenClawGatewayConnectInput, + ) => Effect.Effect; +} + +const resolveOpenClawHealthProbeAuth = ( + authType: OpenClawServerProviderSettings["authMode"], +): Effect.Effect< + Pick, + unknown, + ServerSecretStore +> => + Effect.gen(function* () { + if (authType === "token") { + const token = yield* getOpenClawSecret("token"); + return token !== null ? { auth: { type: "token", token } } : {}; + } + if (authType === "password") { + const password = yield* getOpenClawSecret("password"); + return password !== null ? { auth: { type: "password", password } } : {}; + } + if (authType === "device") { + const deviceKey = yield* getOpenClawSecretBytes("deviceKey"); + if (deviceKey === null) { + return {}; + } + const deviceToken = yield* getOpenClawSecret("deviceToken"); + const deviceId = deriveOpenClawDeviceId(deviceKey); + return { + device: { + id: deviceId, + ...(deviceToken !== null ? { token: deviceToken } : {}), + }, + respondToChallenge: (challenge: OpenClawChallenge): OpenClawChallengeResponse => + buildOpenClawChallengeResponse({ + challenge, + deviceId, + deviceKey, + }), + }; + } + return {}; + }); + +export const makeCheckOpenClawProviderStatus = ( + settings: OpenClawServerProviderSettings, + probeClient?: OpenClawHealthProbeClient, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const gatewayUrl = settings.gatewayUrl.trim(); + if (gatewayUrl.length === 0) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "OpenClaw gateway URL is not configured.", + } satisfies ServerProviderStatus; + } + + let normalized: ReturnType; + try { + normalized = normalizeOpenClawGatewayUrl(gatewayUrl); + } catch (cause) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: cause instanceof Error ? cause.message : "OpenClaw gateway URL is invalid.", + } satisfies ServerProviderStatus; + } + const authType = settings.authMode; + if ((authType === "token" || authType === "password") && !settings.hasSecret) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + authType, + checkedAt, + message: `OpenClaw ${authType} secret is not configured.`, + } satisfies ServerProviderStatus; + } + if (authType === "device" && !settings.paired) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unauthenticated" as const, + authType, + checkedAt, + message: "OpenClaw device pairing is required before the gateway can be used.", + } satisfies ServerProviderStatus; + } + + if (!probeClient) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: authType === "none" ? ("unknown" as const) : ("authenticated" as const), + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway is configured at ${normalized.redactedUrl}. Live gateway probing is not configured.`, + } satisfies ServerProviderStatus; + } + + const probeAuthResult = yield* resolveOpenClawHealthProbeAuth(authType).pipe(Effect.result); + if (Result.isFailure(probeAuthResult)) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: "OpenClaw auth secret could not be read.", + } satisfies ServerProviderStatus; + } + const probeAuth = probeAuthResult.success; + const probe = yield* probeClient + .probe({ + websocketUrl: normalized.websocketUrl, + redactedGatewayUrl: normalized.redactedUrl, + ...probeAuth, + }) + .pipe(Effect.result); + if (Result.isFailure(probe)) { + const detail = detailFromUnknown(probe.failure); + const authLike = isAuthLikeProbeFailure(detail); + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: authLike ? ("unauthenticated" as const) : ("unknown" as const), + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: authLike + ? `OpenClaw gateway authentication failed at ${normalized.redactedUrl}${detail ? `: ${detail}` : ""}.` + : `OpenClaw gateway probe failed at ${normalized.redactedUrl}${detail ? `: ${detail}` : ""}.`, + } satisfies ServerProviderStatus; + } + + const probeResult = probe.success; + if ( + probeResult.protocolVersion !== undefined && + (probeResult.protocolVersion < OPENCLAW_MIN_PROTOCOL_VERSION || + probeResult.protocolVersion > OPENCLAW_MAX_PROTOCOL_VERSION) + ) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unknown" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway protocol mismatch. JCode supports protocol v${OPENCLAW_MIN_PROTOCOL_VERSION}.`, + } satisfies ServerProviderStatus; + } + + const support = validateOpenClawMethodSupport(probeResult.methods); + if (!support.supported) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "authenticated" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway is missing required methods: ${support.missing.join(", ")}.`, + } satisfies ServerProviderStatus; + } + + return { + provider: OPENCLAW_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway is ready at ${normalized.redactedUrl}.`, + } satisfies ServerProviderStatus; + }); + +export const checkOpenClawProviderStatus = (settings: OpenClawServerProviderSettings) => + makeCheckOpenClawProviderStatus(settings, defaultOpenClawHealthProbeClient); + // ── Pi health check ───────────────────────────────────────────── export const checkPiProviderStatus = ( @@ -1542,6 +1782,7 @@ export const ProviderHealthLive = Layer.effect( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; + const serverSecretStore = yield* ServerSecretStore; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, @@ -1643,6 +1884,15 @@ export const ProviderHealthLive = Layer.effect( updateLockKey: null, }); } + if (provider === "openclaw") { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: null, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); + } const definition = PACKAGE_MANAGED_PROVIDER_UPDATES[provider]; if (!definition) { return makeProviderMaintenanceCapabilities({ @@ -1653,8 +1903,9 @@ export const ProviderHealthLive = Layer.effect( updateLockKey: null, }); } + const binaryPath = getProviderBinaryPath(provider, settings) || null; return yield* resolveProviderMaintenanceCapabilitiesEffect(definition, { - binaryPath: getProviderBinaryPath(provider, settings), + binaryPath, env: process.env, platform: process.platform, }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); @@ -1745,6 +1996,9 @@ export const ProviderHealthLive = Layer.effect( makeCheckGeminiProviderStatus(settings.providers.gemini.binaryPath), makeCheckKiloProviderStatus(settings.providers.kilo.binaryPath), makeCheckOpenCodeProviderStatus(settings.providers.opencode.binaryPath), + checkOpenClawProviderStatus(settings.providers.openclaw).pipe( + Effect.provideService(ServerSecretStore, serverSecretStore), + ), checkPiProviderStatus( settings.providers.pi.agentDir, settings.providers.pi.binaryPath, diff --git a/apps/server/src/provider/Services/OpenClawAdapter.ts b/apps/server/src/provider/Services/OpenClawAdapter.ts new file mode 100644 index 00000000..b1873f97 --- /dev/null +++ b/apps/server/src/provider/Services/OpenClawAdapter.ts @@ -0,0 +1,20 @@ +/** + * OpenClawAdapter - OpenClaw gateway implementation of the generic provider adapter contract. + * + * This service owns JCode's OpenClaw gateway session mapping and emits canonical + * provider runtime events. The initial adapter is intentionally text-only. + * + * @module OpenClawAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OpenClawAdapterShape extends ProviderAdapterShape { + readonly provider: "openclaw"; +} + +export class OpenClawAdapter extends ServiceMap.Service()( + "jcode/provider/Services/OpenClawAdapter", +) {} diff --git a/apps/server/src/provider/openclawGatewayClient.test.ts b/apps/server/src/provider/openclawGatewayClient.test.ts new file mode 100644 index 00000000..52634410 --- /dev/null +++ b/apps/server/src/provider/openclawGatewayClient.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { scrubOpenClawGatewayDiagnostic } from "./openclawGatewayClient"; + +describe("scrubOpenClawGatewayDiagnostic", () => { + it("redacts auth tokens, credentials, query strings, and fragments", () => { + const scrubbed = scrubOpenClawGatewayDiagnostic( + "gateway rejected token=must-not-leak password=also-secret Bearer bearer-secret at https://user:pass@gateway.example.test/path?token=query-secret#fragment", + ); + + expect(scrubbed).toBe( + "gateway rejected token= password= Bearer at https://gateway.example.test/path", + ); + expect(scrubbed).not.toContain("must-not-leak"); + expect(scrubbed).not.toContain("also-secret"); + expect(scrubbed).not.toContain("bearer-secret"); + expect(scrubbed).not.toContain("user:pass"); + expect(scrubbed).not.toContain("query-secret"); + }); +}); diff --git a/apps/server/src/provider/openclawGatewayClient.ts b/apps/server/src/provider/openclawGatewayClient.ts new file mode 100644 index 00000000..e4a5d228 --- /dev/null +++ b/apps/server/src/provider/openclawGatewayClient.ts @@ -0,0 +1,419 @@ +import WebSocket, { type RawData } from "ws"; + +import { Effect } from "effect"; + +import { + buildOpenClawConnectFrame, + type OpenClawAuthFrame, + type OpenClawChallenge, + type OpenClawChallengeResponse, + type OpenClawDeviceFrame, + type OpenClawRequest, + isOpenClawAuthFailureFrame, +} from "./openclawGatewayProtocol"; + +const CONNECT_TIMEOUT_MS = 10_000; +const REQUEST_TIMEOUT_MS = 30_000; + +export type OpenClawGatewayEvent = + | { + readonly type: "assistant.delta"; + readonly runId?: string; + readonly text?: string; + readonly delta?: string; + readonly [key: string]: unknown; + } + | { + readonly type: "assistant.completed"; + readonly runId?: string; + readonly text?: string; + readonly [key: string]: unknown; + } + | { + readonly type: "run.completed"; + readonly runId?: string; + readonly stopReason?: string | null; + readonly [key: string]: unknown; + } + | { + readonly type: "error"; + readonly runId?: string; + readonly message?: string; + readonly [key: string]: unknown; + }; + +export interface OpenClawGatewayConnectInput { + readonly websocketUrl: string; + readonly redactedGatewayUrl: string; + readonly auth?: OpenClawAuthFrame; + readonly device?: OpenClawDeviceFrame; + readonly respondToChallenge?: (challenge: OpenClawChallenge) => OpenClawChallengeResponse; +} + +export interface OpenClawGatewayConnectResult { + readonly methods?: ReadonlyArray; + readonly protocolVersion?: number; +} + +export interface OpenClawGatewaySendResult { + readonly runId?: string; + readonly events?: ReadonlyArray; +} + +export type OpenClawGatewayRequestResult = + | OpenClawGatewaySendResult + | { + readonly turns?: ReadonlyArray<{ + readonly id: string; + readonly items: ReadonlyArray; + }>; + } + | Record; + +export interface OpenClawGatewayClient { + readonly connect: ( + input: OpenClawGatewayConnectInput, + ) => Effect.Effect; + readonly request: ( + request: OpenClawRequest, + ) => Effect.Effect; +} + +export interface OpenClawHealthProbeResult { + readonly methods?: ReadonlyArray; + readonly protocolVersion?: number; +} + +export interface OpenClawHealthProbeClient { + readonly probe: ( + input: OpenClawGatewayConnectInput, + ) => Effect.Effect; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(value: Record, key: string): string | undefined { + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field : undefined; +} + +function frameType(frame: unknown): string | undefined { + return isRecord(frame) ? readString(frame, "type") : undefined; +} + +function isGatewayEvent(frame: unknown): frame is OpenClawGatewayEvent { + const type = frameType(frame); + return ( + type === "assistant.delta" || + type === "assistant.completed" || + type === "run.completed" || + type === "error" + ); +} + +function isChallengeFrame(frame: unknown): frame is OpenClawChallenge { + return ( + isRecord(frame) && + frame.type === "connect.challenge" && + typeof frame.nonce === "string" && + frame.nonce.trim().length > 0 && + typeof frame.timestamp === "string" && + frame.timestamp.trim().length > 0 + ); +} + +function rawDataToString(data: RawData): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return Buffer.from(data).toString("utf8"); +} + +function parseFrame(data: RawData): unknown { + return JSON.parse(rawDataToString(data)) as unknown; +} + +function extractResult(frame: unknown): unknown { + return isRecord(frame) && "result" in frame ? frame.result : frame; +} + +function stringArray(value: unknown): ReadonlyArray | undefined { + return Array.isArray(value) && value.every((entry) => typeof entry === "string") + ? value + : undefined; +} + +function extractMethods(frame: unknown): ReadonlyArray | undefined { + const result = extractResult(frame); + if (!isRecord(result)) return undefined; + const direct = stringArray(result.methods); + if (direct) return direct; + const features = isRecord(result.features) ? stringArray(result.features.methods) : undefined; + if (features) return features; + return isRecord(result.hello) && isRecord(result.hello.features) + ? stringArray(result.hello.features.methods) + : undefined; +} + +function extractProtocolVersion(frame: unknown): number | undefined { + const result = extractResult(frame); + if (!isRecord(result)) return undefined; + if (typeof result.protocolVersion === "number") return result.protocolVersion; + if (isRecord(result.protocol) && typeof result.protocol.version === "number") { + return result.protocol.version; + } + return undefined; +} + +function errorMessageFromFrame(frame: unknown): string | undefined { + const result = extractResult(frame); + if (!isRecord(result)) return undefined; + const message = readString(result, "message"); + if (message) return scrubOpenClawGatewayDiagnostic(message); + const error = result.error; + if (typeof error === "string") return scrubOpenClawGatewayDiagnostic(error); + if (isRecord(error)) { + const nestedMessage = readString(error, "message") ?? readString(error, "code"); + return nestedMessage ? scrubOpenClawGatewayDiagnostic(nestedMessage) : undefined; + } + return undefined; +} + +export function scrubOpenClawGatewayDiagnostic(message: string): string { + return message + .replace(/\b(Bearer\s+)[^\s]+/gi, "$1") + .replace( + /\b(token|password|deviceToken|device-token|authorization|auth)=([^\s&#]+)/gi, + "$1=", + ) + .replace( + /\b([a-zA-Z][a-zA-Z\d+.-]*:\/\/)([^\s/@]+@)([^\s?#]+)([^\s]*)/g, + (_match, scheme, _userinfo, host, rest) => { + const path = String(rest).split(/[?#]/, 1)[0] ?? ""; + return `${scheme}${host}${path}`; + }, + ); +} + +function receiveFrame( + socket: WebSocket, + timeoutMs: number, + redactedGatewayUrl: string, +): Promise { + return new Promise((resolve, reject) => { + let done = false; + const timeout = setTimeout(() => { + finish({ + error: new Error( + `Timed out waiting for OpenClaw gateway frame from ${redactedGatewayUrl}.`, + ), + }); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timeout); + socket.off("message", onMessage); + socket.off("error", onError); + socket.off("close", onClose); + }; + const finish = (result: { readonly value?: unknown; readonly error?: unknown }) => { + if (done) return; + done = true; + cleanup(); + if ("error" in result) reject(result.error); + else resolve(result.value); + }; + const onMessage = (data: RawData) => { + try { + finish({ value: parseFrame(data) }); + } catch (cause) { + finish({ error: cause }); + } + }; + const onError = (error: Error) => finish({ error }); + const onClose = (code: number, reason: Buffer) => { + const detail = + reason.length > 0 ? `: ${scrubOpenClawGatewayDiagnostic(reason.toString("utf8"))}` : ""; + finish({ error: new Error(`OpenClaw gateway closed before responding (${code})${detail}.`) }); + }; + socket.once("message", onMessage); + socket.once("error", onError); + socket.once("close", onClose); + }); +} + +function openSocket(websocketUrl: string, redactedGatewayUrl: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(websocketUrl); + let done = false; + const timeout = setTimeout(() => { + socket.close(); + finish({ + error: new Error(`Timed out connecting to OpenClaw gateway at ${redactedGatewayUrl}.`), + }); + }, CONNECT_TIMEOUT_MS); + const cleanup = () => { + clearTimeout(timeout); + socket.off("open", onOpen); + socket.off("error", onError); + socket.off("close", onClose); + }; + const finish = (result: { readonly socket?: WebSocket; readonly error?: unknown }) => { + if (done) return; + done = true; + cleanup(); + if ("error" in result) reject(result.error); + else if (result.socket !== undefined) resolve(result.socket); + else reject(new Error("OpenClaw gateway connection failed.")); + }; + const onOpen = () => finish({ socket }); + const onError = (error: Error) => finish({ error }); + const onClose = (code: number, reason: Buffer) => { + const detail = + reason.length > 0 ? `: ${scrubOpenClawGatewayDiagnostic(reason.toString("utf8"))}` : ""; + finish({ error: new Error(`OpenClaw gateway closed during connect (${code})${detail}.`) }); + }; + socket.once("open", onOpen); + socket.once("error", onError); + socket.once("close", onClose); + }); +} + +function sendFrame(socket: WebSocket, frame: unknown): Promise { + return new Promise((resolve, reject) => { + if (socket.readyState !== WebSocket.OPEN) { + reject(new Error("OpenClaw gateway socket is not open.")); + return; + } + socket.send(JSON.stringify(frame), (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +async function completeHandshake( + socket: WebSocket, + input: OpenClawGatewayConnectInput, +): Promise { + await sendFrame( + socket, + buildOpenClawConnectFrame({ + ...(input.auth !== undefined ? { auth: input.auth } : {}), + ...(input.device !== undefined ? { device: input.device } : {}), + }), + ); + let frame = await receiveFrame(socket, CONNECT_TIMEOUT_MS, input.redactedGatewayUrl); + if (isChallengeFrame(frame)) { + if (!input.respondToChallenge) { + throw new Error( + "OpenClaw gateway requested device challenge but no challenge responder is configured.", + ); + } + await sendFrame(socket, input.respondToChallenge(frame)); + frame = await receiveFrame(socket, CONNECT_TIMEOUT_MS, input.redactedGatewayUrl); + } + if (isOpenClawAuthFailureFrame(frame)) { + throw new Error(errorMessageFromFrame(frame) ?? "OpenClaw gateway rejected authentication."); + } + if (frameType(frame) === "error") { + throw new Error(errorMessageFromFrame(frame) ?? "OpenClaw gateway connect failed."); + } + const methods = extractMethods(frame); + const protocolVersion = extractProtocolVersion(frame); + return { + ...(methods !== undefined ? { methods } : {}), + ...(protocolVersion !== undefined ? { protocolVersion } : {}), + }; +} + +function normalizeRequestResult(frame: unknown): OpenClawGatewayRequestResult { + const result = extractResult(frame); + return isRecord(result) ? result : {}; +} + +async function requestOverSocket( + socket: WebSocket, + redactedGatewayUrl: string, + request: OpenClawRequest, +): Promise { + await sendFrame(socket, request); + if (request.method !== "chat.send") { + return normalizeRequestResult( + await receiveFrame(socket, REQUEST_TIMEOUT_MS, redactedGatewayUrl), + ); + } + + const firstFrame = await receiveFrame(socket, REQUEST_TIMEOUT_MS, redactedGatewayUrl); + const firstResult = normalizeRequestResult(firstFrame); + if ("events" in firstResult) { + return firstResult; + } + if ("runId" in firstResult && !isGatewayEvent(firstFrame)) { + return firstResult; + } + if (!isGatewayEvent(firstFrame)) { + return firstResult; + } + + const events: OpenClawGatewayEvent[] = [firstFrame]; + while (true) { + const latest = events[events.length - 1]; + if (latest?.type === "run.completed" || latest?.type === "error") break; + const frame = await receiveFrame(socket, REQUEST_TIMEOUT_MS, redactedGatewayUrl); + if (!isGatewayEvent(frame)) break; + events.push(frame); + } + const runId = events.find((event) => typeof event.runId === "string")?.runId; + return { ...(runId !== undefined ? { runId } : {}), events }; +} + +export function makeOpenClawGatewayClient(): OpenClawGatewayClient { + let socket: WebSocket | null = null; + let redactedGatewayUrl = "OpenClaw gateway"; + return { + connect: (input) => + Effect.tryPromise({ + try: async () => { + if (socket !== null) { + socket.close(); + socket = null; + } + const nextSocket = await openSocket(input.websocketUrl, input.redactedGatewayUrl); + const result = await completeHandshake(nextSocket, input); + socket = nextSocket; + redactedGatewayUrl = input.redactedGatewayUrl; + return result; + }, + catch: (cause) => cause, + }), + request: (request) => + Effect.tryPromise({ + try: async () => { + if (socket === null || socket.readyState !== WebSocket.OPEN) { + throw new Error("OpenClaw gateway is not connected."); + } + return await requestOverSocket(socket, redactedGatewayUrl, request); + }, + catch: (cause) => cause, + }), + }; +} + +export const defaultOpenClawGatewayClient = makeOpenClawGatewayClient(); + +export const defaultOpenClawHealthProbeClient: OpenClawHealthProbeClient = { + probe: (input) => + Effect.tryPromise({ + try: async () => { + const socket = await openSocket(input.websocketUrl, input.redactedGatewayUrl); + try { + return await completeHandshake(socket, input); + } finally { + socket.close(); + } + }, + catch: (cause) => cause, + }), +}; diff --git a/apps/server/src/provider/openclawGatewayProtocol.test.ts b/apps/server/src/provider/openclawGatewayProtocol.test.ts new file mode 100644 index 00000000..61e05f3f --- /dev/null +++ b/apps/server/src/provider/openclawGatewayProtocol.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { + OPENCLAW_CLIENT_DISPLAY_NAME, + OPENCLAW_CLIENT_ID, + OPENCLAW_CLIENT_MODE, + OPENCLAW_MAX_PROTOCOL_VERSION, + OPENCLAW_MIN_PROTOCOL_VERSION, + OPENCLAW_REQUIRED_METHODS, + OPENCLAW_SCOPES, + buildOpenClawAbortRequest, + buildOpenClawChallengeResponse, + buildOpenClawConnectFrame, + isOpenClawAuthFailureFrame, + buildOpenClawHistoryRequest, + buildOpenClawSendRequest, + deriveOpenClawIdempotencyKey, + validateOpenClawMethodSupport, + waitForOpenClawChallenge, +} from "./openclawGatewayProtocol"; + +describe("openclawGatewayProtocol", () => { + it("builds the canonical backend operator connect frame", () => { + expect(buildOpenClawConnectFrame({ auth: { type: "token", token: "secret" } })).toEqual({ + type: "connect", + minProtocol: OPENCLAW_MIN_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_MAX_PROTOCOL_VERSION, + client: { + id: OPENCLAW_CLIENT_ID, + mode: OPENCLAW_CLIENT_MODE, + displayName: OPENCLAW_CLIENT_DISPLAY_NAME, + }, + role: "operator", + scopes: OPENCLAW_SCOPES, + auth: { type: "token", token: "secret" }, + }); + }); + + it("creates deterministic challenge responses bound to nonce, timestamp, and client id", () => { + const response = buildOpenClawChallengeResponse({ + challenge: { nonce: "nonce-1", timestamp: "2026-06-05T00:00:00.000Z" }, + deviceId: "device-1", + deviceKey: new Uint8Array([1, 2, 3, 4]), + }); + + expect(response).toEqual({ + type: "connect.challenge-response", + clientId: OPENCLAW_CLIENT_ID, + deviceId: "device-1", + nonce: "nonce-1", + timestamp: "2026-06-05T00:00:00.000Z", + signature: expect.any(String), + }); + expect(response.signature).toBe( + buildOpenClawChallengeResponse({ + challenge: { nonce: "nonce-1", timestamp: "2026-06-05T00:00:00.000Z" }, + deviceId: "device-1", + deviceKey: new Uint8Array([1, 2, 3, 4]), + }).signature, + ); + expect(response.signature).not.toBe( + buildOpenClawChallengeResponse({ + challenge: { nonce: "nonce-2", timestamp: "2026-06-05T00:00:00.000Z" }, + deviceId: "device-1", + deviceKey: new Uint8Array([1, 2, 3, 4]), + }).signature, + ); + }); + + it("waits for connect.challenge and times out with redacted details", async () => { + await expect( + waitForOpenClawChallenge({ + receive: () => + Promise.resolve({ + type: "connect.challenge", + nonce: "nonce-1", + timestamp: "2026-06-05T00:00:00.000Z", + }), + timeoutMs: 50, + redactedGatewayUrl: "wss://gateway.example.test/path", + }), + ).resolves.toEqual({ nonce: "nonce-1", timestamp: "2026-06-05T00:00:00.000Z" }); + + await expect( + waitForOpenClawChallenge({ + receive: () => new Promise(() => undefined), + timeoutMs: 1, + redactedGatewayUrl: "wss://gateway.example.test/path", + }), + ).rejects.toThrow("wss://gateway.example.test/path"); + await expect( + waitForOpenClawChallenge({ + receive: () => new Promise(() => undefined), + timeoutMs: 1, + redactedGatewayUrl: "wss://gateway.example.test/path?token=secret", + }), + ).rejects.not.toThrow(/token=secret/); + }); + + it("identifies auth failure frames that require paired-token clearing", () => { + expect(isOpenClawAuthFailureFrame({ type: "connect.error", code: "unauthorized" })).toBe(true); + expect(isOpenClawAuthFailureFrame({ type: "error", message: "authentication failed" })).toBe( + true, + ); + expect(isOpenClawAuthFailureFrame({ type: "error", message: "network unavailable" })).toBe( + false, + ); + }); + + it("validates required gateway chat methods while allowing absent method lists for probing", () => { + expect(validateOpenClawMethodSupport(undefined)).toEqual({ supported: true, missing: [] }); + expect(validateOpenClawMethodSupport([...OPENCLAW_REQUIRED_METHODS, "other.method"])).toEqual({ + supported: true, + missing: [], + }); + expect(validateOpenClawMethodSupport(["chat.history", "chat.send"])).toEqual({ + supported: false, + missing: ["chat.abort"], + }); + }); + + it("builds chat request payloads with stable session and idempotency keys", () => { + expect(buildOpenClawHistoryRequest({ sessionKey: "jcode:thread-1" })).toEqual({ + method: "chat.history", + params: { sessionKey: "jcode:thread-1" }, + }); + expect(deriveOpenClawIdempotencyKey({ threadId: "thread-1", turnId: "turn-1" })).toBe( + "jcode:thread-1:turn-1", + ); + expect( + buildOpenClawSendRequest({ + sessionKey: "jcode:thread-1", + threadId: "thread-1", + turnId: "turn-1", + message: "hello", + }), + ).toEqual({ + method: "chat.send", + params: { + sessionKey: "jcode:thread-1", + message: "hello", + idempotencyKey: "jcode:thread-1:turn-1", + }, + }); + expect(buildOpenClawAbortRequest({ sessionKey: "jcode:thread-1", runId: "run-1" })).toEqual({ + method: "chat.abort", + params: { sessionKey: "jcode:thread-1", runId: "run-1" }, + }); + }); +}); diff --git a/apps/server/src/provider/openclawGatewayProtocol.ts b/apps/server/src/provider/openclawGatewayProtocol.ts new file mode 100644 index 00000000..12d6d217 --- /dev/null +++ b/apps/server/src/provider/openclawGatewayProtocol.ts @@ -0,0 +1,246 @@ +import * as Crypto from "node:crypto"; + +export const OPENCLAW_MIN_PROTOCOL_VERSION = 4; +export const OPENCLAW_MAX_PROTOCOL_VERSION = 4; +export const OPENCLAW_CLIENT_ID = "gateway-client"; +export const OPENCLAW_CLIENT_MODE = "backend"; +export const OPENCLAW_CLIENT_DISPLAY_NAME = "JCode"; +export const OPENCLAW_SCOPES = ["operator.read", "operator.write"] as const; +export const OPENCLAW_REQUIRED_METHODS = ["chat.history", "chat.send", "chat.abort"] as const; + +export type OpenClawScope = (typeof OPENCLAW_SCOPES)[number]; +export type OpenClawRequiredMethod = (typeof OPENCLAW_REQUIRED_METHODS)[number]; + +export interface OpenClawAuthFrame { + readonly type: string; + readonly token?: string; + readonly password?: string; +} + +export interface OpenClawDeviceFrame { + readonly id: string; + readonly token?: string; +} + +export interface OpenClawConnectFrameInput { + readonly auth?: OpenClawAuthFrame; + readonly device?: OpenClawDeviceFrame; +} + +export interface OpenClawConnectFrame { + readonly type: "connect"; + readonly minProtocol: typeof OPENCLAW_MIN_PROTOCOL_VERSION; + readonly maxProtocol: typeof OPENCLAW_MAX_PROTOCOL_VERSION; + readonly client: { + readonly id: typeof OPENCLAW_CLIENT_ID; + readonly mode: typeof OPENCLAW_CLIENT_MODE; + readonly displayName: typeof OPENCLAW_CLIENT_DISPLAY_NAME; + }; + readonly role: "operator"; + readonly scopes: typeof OPENCLAW_SCOPES; + readonly auth?: OpenClawAuthFrame; + readonly device?: OpenClawDeviceFrame; +} + +export interface OpenClawChallenge { + readonly nonce: string; + readonly timestamp: string; +} + +export interface OpenClawChallengeResponseInput { + readonly challenge: OpenClawChallenge; + readonly deviceId: string; + readonly deviceKey: Uint8Array; +} + +export interface OpenClawChallengeResponse { + readonly type: "connect.challenge-response"; + readonly clientId: typeof OPENCLAW_CLIENT_ID; + readonly deviceId: string; + readonly nonce: string; + readonly timestamp: string; + readonly signature: string; +} + +export interface OpenClawMethodSupport { + readonly supported: boolean; + readonly missing: ReadonlyArray; +} + +export interface OpenClawRequest { + readonly method: TMethod; + readonly params: TParams; +} + +export class OpenClawProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "OpenClawProtocolError"; + } +} + +function redactedProtocolDetail(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return value.split("?")[0]?.split("#")[0] ?? "OpenClaw gateway"; + } +} + +function readStringField(value: unknown, field: string): string | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + const fieldValue = record[field]; + return typeof fieldValue === "string" && fieldValue.trim().length > 0 ? fieldValue : undefined; +} + +export async function waitForOpenClawChallenge(input: { + readonly receive: () => Promise; + readonly timeoutMs: number; + readonly redactedGatewayUrl: string; +}): Promise { + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject( + new OpenClawProtocolError( + `Timed out waiting for OpenClaw connect.challenge from ${redactedProtocolDetail( + input.redactedGatewayUrl, + )}.`, + ), + ); + }, input.timeoutMs); + }); + + try { + const frame = await Promise.race([input.receive(), timeoutPromise]); + const type = readStringField(frame, "type"); + const nonce = readStringField(frame, "nonce"); + const timestamp = readStringField(frame, "timestamp"); + if (type !== "connect.challenge" || nonce === undefined || timestamp === undefined) { + throw new OpenClawProtocolError( + "OpenClaw gateway did not provide a valid connect.challenge frame.", + ); + } + return { nonce, timestamp }; + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +export function isOpenClawAuthFailureFrame(frame: unknown): boolean { + const code = readStringField(frame, "code") ?? ""; + const message = readStringField(frame, "message") ?? ""; + return /auth|unauth|forbid|token|credential/i.test(`${code} ${message}`); +} + +export function buildOpenClawConnectFrame( + input: OpenClawConnectFrameInput = {}, +): OpenClawConnectFrame { + return { + type: "connect", + minProtocol: OPENCLAW_MIN_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_MAX_PROTOCOL_VERSION, + client: { + id: OPENCLAW_CLIENT_ID, + mode: OPENCLAW_CLIENT_MODE, + displayName: OPENCLAW_CLIENT_DISPLAY_NAME, + }, + role: "operator", + scopes: OPENCLAW_SCOPES, + ...(input.auth !== undefined ? { auth: input.auth } : {}), + ...(input.device !== undefined ? { device: input.device } : {}), + }; +} + +function challengeSigningPayload(input: OpenClawChallengeResponseInput): string { + return [ + OPENCLAW_CLIENT_ID, + OPENCLAW_CLIENT_MODE, + OPENCLAW_CLIENT_DISPLAY_NAME, + input.deviceId, + input.challenge.nonce, + input.challenge.timestamp, + ].join("\n"); +} + +export function buildOpenClawChallengeResponse( + input: OpenClawChallengeResponseInput, +): OpenClawChallengeResponse { + const signature = Crypto.createHmac("sha256", Buffer.from(input.deviceKey)) + .update(challengeSigningPayload(input)) + .digest("base64url"); + return { + type: "connect.challenge-response", + clientId: OPENCLAW_CLIENT_ID, + deviceId: input.deviceId, + nonce: input.challenge.nonce, + timestamp: input.challenge.timestamp, + signature, + }; +} + +export function validateOpenClawMethodSupport( + advertisedMethods: ReadonlyArray | undefined, +): OpenClawMethodSupport { + if (advertisedMethods === undefined) { + return { supported: true, missing: [] }; + } + const advertised = new Set(advertisedMethods); + const missing = OPENCLAW_REQUIRED_METHODS.filter((method) => !advertised.has(method)); + return { supported: missing.length === 0, missing }; +} + +export function buildOpenClawHistoryRequest(input: { + readonly sessionKey: string; +}): OpenClawRequest<"chat.history", { readonly sessionKey: string }> { + return { method: "chat.history", params: { sessionKey: input.sessionKey } }; +} + +export function deriveOpenClawIdempotencyKey(input: { + readonly threadId: string; + readonly turnId: string; +}): string { + return `jcode:${input.threadId}:${input.turnId}`; +} + +export function buildOpenClawSendRequest(input: { + readonly sessionKey: string; + readonly threadId: string; + readonly turnId: string; + readonly message: string; +}): OpenClawRequest< + "chat.send", + { readonly sessionKey: string; readonly message: string; readonly idempotencyKey: string } +> { + return { + method: "chat.send", + params: { + sessionKey: input.sessionKey, + message: input.message, + idempotencyKey: deriveOpenClawIdempotencyKey(input), + }, + }; +} + +export function buildOpenClawAbortRequest(input: { + readonly sessionKey: string; + readonly runId?: string; +}): OpenClawRequest<"chat.abort", { readonly sessionKey: string; readonly runId?: string }> { + return { + method: "chat.abort", + params: { + sessionKey: input.sessionKey, + ...(input.runId !== undefined ? { runId: input.runId } : {}), + }, + }; +} diff --git a/apps/server/src/provider/openclawGatewayUrl.test.ts b/apps/server/src/provider/openclawGatewayUrl.test.ts new file mode 100644 index 00000000..ca653a6b --- /dev/null +++ b/apps/server/src/provider/openclawGatewayUrl.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeOpenClawGatewayUrl, redactOpenClawGatewayUrl } from "./openclawGatewayUrl"; + +describe("openclawGatewayUrl", () => { + it("normalizes HTTP(S) gateway URLs to WebSocket URLs", () => { + expect(normalizeOpenClawGatewayUrl("http://127.0.0.1:18789").websocketUrl).toBe( + "ws://127.0.0.1:18789/", + ); + expect(normalizeOpenClawGatewayUrl("https://gateway.example.test/openclaw").websocketUrl).toBe( + "wss://gateway.example.test/openclaw", + ); + }); + + it("allows insecure WebSocket only for loopback gateways by default", () => { + expect(normalizeOpenClawGatewayUrl("ws://localhost:18789").websocketUrl).toBe( + "ws://localhost:18789/", + ); + expect(normalizeOpenClawGatewayUrl("ws://[::1]:18789").websocketUrl).toBe("ws://[::1]:18789/"); + expect(normalizeOpenClawGatewayUrl("ws://127.42.0.1:18789").websocketUrl).toBe( + "ws://127.42.0.1:18789/", + ); + expect(() => normalizeOpenClawGatewayUrl("ws://127.evil.test:18789")).toThrow(/requires wss/i); + expect(() => normalizeOpenClawGatewayUrl("ws://gateway.example.test")).toThrow(/requires wss/i); + expect( + normalizeOpenClawGatewayUrl("ws://gateway.example.test", { allowInsecureRemote: true }) + .websocketUrl, + ).toBe("ws://gateway.example.test/"); + }); + + it("redacts URL userinfo, query, and fragment values", () => { + const normalized = normalizeOpenClawGatewayUrl( + "https://user:pass@gateway.example.test/path?token=secret#fragment", + ); + + expect(normalized.websocketUrl).toBe("wss://gateway.example.test/path"); + expect(normalized.redactedUrl).toBe("wss://gateway.example.test/path"); + expect(redactOpenClawGatewayUrl("ws://user:pass@127.0.0.1:18789/?token=secret")).toBe( + "ws://127.0.0.1:18789/", + ); + }); + + it("redacts sensitive URL parts from rejection details", () => { + expect(() => + normalizeOpenClawGatewayUrl("ws://user:pass@gateway.example.test/path?token=secret#hash"), + ).toThrow("ws://gateway.example.test/path"); + expect(() => + normalizeOpenClawGatewayUrl("ws://user:pass@gateway.example.test/path?token=secret#hash"), + ).not.toThrow(/user|pass|token=secret|hash/); + }); +}); diff --git a/apps/server/src/provider/openclawGatewayUrl.ts b/apps/server/src/provider/openclawGatewayUrl.ts new file mode 100644 index 00000000..dd604319 --- /dev/null +++ b/apps/server/src/provider/openclawGatewayUrl.ts @@ -0,0 +1,85 @@ +import * as Net from "node:net"; + +export interface OpenClawGatewayUrlOptions { + readonly allowInsecureRemote?: boolean; +} + +export interface NormalizedOpenClawGatewayUrl { + readonly websocketUrl: string; + readonly redactedUrl: string; + readonly isLoopback: boolean; +} + +export class OpenClawGatewayUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "OpenClawGatewayUrlError"; + } +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1") { + return true; + } + if (Net.isIP(normalized) !== 4) { + return false; + } + const firstOctet = Number(normalized.split(".")[0] ?? "0"); + return firstOctet === 127; +} + +function parseGatewayUrl(value: string): URL { + try { + return new URL(value.trim()); + } catch (cause) { + throw new OpenClawGatewayUrlError("OpenClaw gateway URL must be a valid URL."); + } +} + +function stripSensitiveUrlParts(url: URL): URL { + const clone = new URL(url.toString()); + clone.username = ""; + clone.password = ""; + clone.search = ""; + clone.hash = ""; + return clone; +} + +export function redactOpenClawGatewayUrl(value: string): string { + return stripSensitiveUrlParts(parseGatewayUrl(value)).toString(); +} + +export function normalizeOpenClawGatewayUrl( + value: string, + options: OpenClawGatewayUrlOptions = {}, +): NormalizedOpenClawGatewayUrl { + const url = stripSensitiveUrlParts(parseGatewayUrl(value)); + switch (url.protocol) { + case "http:": + url.protocol = "ws:"; + break; + case "https:": + url.protocol = "wss:"; + break; + case "ws:": + case "wss:": + break; + default: + throw new OpenClawGatewayUrlError("OpenClaw gateway URL must use http, https, ws, or wss."); + } + + const isLoopback = isLoopbackHostname(url.hostname); + if (url.protocol === "ws:" && !isLoopback && options.allowInsecureRemote !== true) { + throw new OpenClawGatewayUrlError( + `OpenClaw gateway URL requires wss:// for non-loopback hosts: ${url.toString()}`, + ); + } + + const websocketUrl = url.toString(); + return { + websocketUrl, + redactedUrl: websocketUrl, + isLoopback, + }; +} diff --git a/apps/server/src/provider/openclawSecretUpdate.ts b/apps/server/src/provider/openclawSecretUpdate.ts new file mode 100644 index 00000000..7d2bb4c0 --- /dev/null +++ b/apps/server/src/provider/openclawSecretUpdate.ts @@ -0,0 +1,39 @@ +import type { ServerUpdateOpenClawSecretsInput } from "@jcode/contracts"; +import { Effect } from "effect"; + +import { ServerSecretStore, type SecretStoreError } from "../auth/Services/ServerSecretStore"; +import { + clearOpenClawDeviceIdentity, + clearOpenClawPairedToken, + clearOpenClawPassword, + clearOpenClawToken, + readOpenClawSecretMetadata, + rotateOpenClawDeviceKey, + setOpenClawPairedToken, + setOpenClawPassword, + setOpenClawToken, + type OpenClawSecretMetadata, +} from "./openclawSecrets"; + +export const applyOpenClawSecretUpdate = ( + input: ServerUpdateOpenClawSecretsInput, +): Effect.Effect => + Effect.gen(function* () { + if (input.token !== undefined) { + yield* input.token === null ? clearOpenClawToken : setOpenClawToken(input.token); + } + if (input.password !== undefined) { + yield* input.password === null ? clearOpenClawPassword : setOpenClawPassword(input.password); + } + if (input.clearDeviceIdentity === true) { + yield* clearOpenClawDeviceIdentity; + } else if (input.rotateDeviceKey === true) { + yield* rotateOpenClawDeviceKey; + } + if (input.deviceToken !== undefined) { + yield* input.deviceToken === null + ? clearOpenClawPairedToken + : setOpenClawPairedToken(input.deviceToken); + } + return yield* readOpenClawSecretMetadata; + }); diff --git a/apps/server/src/provider/openclawSecrets.test.ts b/apps/server/src/provider/openclawSecrets.test.ts new file mode 100644 index 00000000..10e3d20e --- /dev/null +++ b/apps/server/src/provider/openclawSecrets.test.ts @@ -0,0 +1,171 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { ServerSecretStore, type SecretStoreError } from "../auth/Services/ServerSecretStore"; +import { ServerSecretStoreLive } from "../auth/Layers/ServerSecretStore"; +import { ServerConfig } from "../config"; +import { applyOpenClawSecretUpdate } from "./openclawSecretUpdate"; +import { + OPENCLAW_SECRET_NAMES, + clearOpenClawAuthSecrets, + clearOpenClawDeviceIdentity, + clearOpenClawPairedToken, + deriveOpenClawDeviceId, + getOpenClawSecretBytes, + getOpenClawSecret, + readOpenClawSecretMetadata, + rotateOpenClawDeviceKey, + setOpenClawPassword, + setOpenClawPairedToken, + setOpenClawToken, +} from "./openclawSecrets"; + +const makeLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "jcode-openclaw-secrets-test-", + }), + ), + Layer.provide(NodeServices.layer), + ); + +const runWithSecretStore = (effect: Effect.Effect) => + effect.pipe(Effect.provide(makeLayer()), Effect.scoped, Effect.runPromise); + +describe("openclawSecrets", () => { + it("stores text secrets as UTF-8 bytes under deterministic names", async () => { + await runWithSecretStore( + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("pass-✓"); + + expect(Array.from((yield* store.get(OPENCLAW_SECRET_NAMES.token)) ?? [])).toEqual( + Array.from(new TextEncoder().encode("token-secret")), + ); + expect(Array.from((yield* store.get(OPENCLAW_SECRET_NAMES.password)) ?? [])).toEqual( + Array.from(new TextEncoder().encode("pass-✓")), + ); + }), + ); + }); + + it("stores auth secrets while exposing only redacted metadata", async () => { + await runWithSecretStore( + Effect.gen(function* () { + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("password-secret"); + + const metadata = yield* readOpenClawSecretMetadata; + + expect(metadata).toEqual({ + hasToken: true, + hasPassword: true, + hasDeviceKey: false, + hasDeviceToken: false, + paired: false, + }); + expect(Object.values(metadata)).not.toContain("token-secret"); + expect(yield* getOpenClawSecret("token")).toBe("token-secret"); + }), + ); + }); + + it("clears token and password secrets without touching device identity", async () => { + await runWithSecretStore( + Effect.gen(function* () { + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("password-secret"); + yield* rotateOpenClawDeviceKey; + yield* clearOpenClawAuthSecrets; + + const metadata = yield* readOpenClawSecretMetadata; + + expect(metadata.hasToken).toBe(false); + expect(metadata.hasPassword).toBe(false); + expect(metadata.hasDeviceKey).toBe(true); + }), + ); + }); + + it("rotates and clears device identity plus stale paired tokens", async () => { + await runWithSecretStore( + Effect.gen(function* () { + const firstKey = yield* rotateOpenClawDeviceKey; + yield* setOpenClawPairedToken("paired-token"); + const secondKey = yield* rotateOpenClawDeviceKey; + + let metadata = yield* readOpenClawSecretMetadata; + expect(Array.from(secondKey)).not.toEqual(Array.from(firstKey)); + expect(metadata.hasDeviceKey).toBe(true); + expect(metadata.hasDeviceToken).toBe(false); + expect(metadata.paired).toBe(false); + + yield* setOpenClawPairedToken("paired-token"); + yield* clearOpenClawPairedToken; + metadata = yield* readOpenClawSecretMetadata; + expect(metadata.hasDeviceToken).toBe(false); + expect(metadata.paired).toBe(false); + + yield* clearOpenClawDeviceIdentity; + metadata = yield* readOpenClawSecretMetadata; + expect(metadata.hasDeviceKey).toBe(false); + expect(metadata.hasDeviceToken).toBe(false); + }), + ); + }); + + it("reads device keys as binary bytes and derives a stable non-secret device id", async () => { + await runWithSecretStore( + Effect.gen(function* () { + const key = yield* rotateOpenClawDeviceKey; + const storedKey = yield* getOpenClawSecretBytes("deviceKey"); + + expect(storedKey).not.toBeNull(); + expect(Array.from(storedKey ?? [])).toEqual(Array.from(key)); + expect(yield* getOpenClawSecret("deviceKey")).not.toBe(deriveOpenClawDeviceId(key)); + expect(deriveOpenClawDeviceId(key)).toBe(deriveOpenClawDeviceId(key)); + }), + ); + }); + + it("applies secret updates while returning only metadata", async () => { + await runWithSecretStore( + Effect.gen(function* () { + let metadata = yield* applyOpenClawSecretUpdate({ + token: "token-secret", + password: "password-secret", + rotateDeviceKey: true, + deviceToken: "paired-token", + }); + + expect(metadata).toEqual({ + hasToken: true, + hasPassword: true, + hasDeviceKey: true, + hasDeviceToken: true, + paired: true, + }); + expect(Object.values(metadata)).not.toContain("token-secret"); + expect(yield* getOpenClawSecret("token")).toBe("token-secret"); + expect(yield* getOpenClawSecret("password")).toBe("password-secret"); + + metadata = yield* applyOpenClawSecretUpdate({ + token: null, + password: null, + clearDeviceIdentity: true, + }); + + expect(metadata).toEqual({ + hasToken: false, + hasPassword: false, + hasDeviceKey: false, + hasDeviceToken: false, + paired: false, + }); + }), + ); + }); +}); diff --git a/apps/server/src/provider/openclawSecrets.ts b/apps/server/src/provider/openclawSecrets.ts new file mode 100644 index 00000000..0dc64161 --- /dev/null +++ b/apps/server/src/provider/openclawSecrets.ts @@ -0,0 +1,121 @@ +import * as Crypto from "node:crypto"; + +import { Effect } from "effect"; + +import { ServerSecretStore, type SecretStoreError } from "../auth/Services/ServerSecretStore"; + +export const OPENCLAW_SECRET_NAMES = { + token: "provider.openclaw.token", + password: "provider.openclaw.password", + deviceKey: "provider.openclaw.device-key", + deviceToken: "provider.openclaw.device-token", +} as const; + +export type OpenClawSecretKind = keyof typeof OPENCLAW_SECRET_NAMES; + +export interface OpenClawSecretMetadata { + readonly hasToken: boolean; + readonly hasPassword: boolean; + readonly hasDeviceKey: boolean; + readonly hasDeviceToken: boolean; + readonly paired: boolean; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function encodeSecret(value: string): Uint8Array { + return textEncoder.encode(value); +} + +function decodeSecret(value: Uint8Array): string { + return textDecoder.decode(value); +} + +export const getOpenClawSecret = ( + kind: OpenClawSecretKind, +): Effect.Effect => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + const value = yield* store.get(OPENCLAW_SECRET_NAMES[kind]); + return value ? decodeSecret(value) : null; + }); + +export const getOpenClawSecretBytes = ( + kind: OpenClawSecretKind, +): Effect.Effect => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + return yield* store.get(OPENCLAW_SECRET_NAMES[kind]); + }); + +export function deriveOpenClawDeviceId(deviceKey: Uint8Array): string { + const digest = Crypto.createHash("sha256").update(deviceKey).digest("base64url"); + return `jcode:${digest}`; +} + +const setOpenClawTextSecret = (kind: OpenClawSecretKind, value: string) => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* store.set(OPENCLAW_SECRET_NAMES[kind], encodeSecret(value)); + }); + +const removeOpenClawSecret = (kind: OpenClawSecretKind) => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* store.remove(OPENCLAW_SECRET_NAMES[kind]); + }); + +export const setOpenClawToken = (value: string) => setOpenClawTextSecret("token", value); + +export const setOpenClawPassword = (value: string) => setOpenClawTextSecret("password", value); + +export const clearOpenClawToken = removeOpenClawSecret("token"); + +export const clearOpenClawPassword = removeOpenClawSecret("password"); + +export const setOpenClawPairedToken = (value: string) => + setOpenClawTextSecret("deviceToken", value); + +export const clearOpenClawAuthSecrets = Effect.all([ + clearOpenClawToken, + clearOpenClawPassword, +]).pipe(Effect.asVoid); + +export const clearOpenClawPairedToken = removeOpenClawSecret("deviceToken"); + +export const clearOpenClawDeviceIdentity = Effect.all([ + removeOpenClawSecret("deviceKey"), + removeOpenClawSecret("deviceToken"), +]).pipe(Effect.asVoid); + +export const rotateOpenClawDeviceKey: Effect.Effect< + Uint8Array, + SecretStoreError, + ServerSecretStore +> = Effect.gen(function* () { + const store = yield* ServerSecretStore; + const key = Uint8Array.from(Crypto.randomBytes(32)); + yield* store.set(OPENCLAW_SECRET_NAMES.deviceKey, key); + yield* store.remove(OPENCLAW_SECRET_NAMES.deviceToken); + return key; +}); + +export const readOpenClawSecretMetadata: Effect.Effect< + OpenClawSecretMetadata, + SecretStoreError, + ServerSecretStore +> = Effect.gen(function* () { + const store = yield* ServerSecretStore; + const token = yield* store.get(OPENCLAW_SECRET_NAMES.token); + const password = yield* store.get(OPENCLAW_SECRET_NAMES.password); + const deviceKey = yield* store.get(OPENCLAW_SECRET_NAMES.deviceKey); + const deviceToken = yield* store.get(OPENCLAW_SECRET_NAMES.deviceToken); + return { + hasToken: token !== null, + hasPassword: password !== null, + hasDeviceKey: deviceKey !== null, + hasDeviceToken: deviceToken !== null, + paired: deviceToken !== null, + }; +}); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 4ba24a48..5debf4c4 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -105,6 +105,13 @@ describe("providerStatusCache", () => { it("keeps provider ordering stable for transport consumers", () => { expect( orderProviderStatuses([ + { + provider: "openclaw", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-15T10:05:00.000Z", + }, { provider: "gemini", status: "ready", @@ -151,6 +158,13 @@ describe("providerStatusCache", () => { authStatus: "authenticated", checkedAt: "2026-04-15T10:02:00.000Z", }, + { + provider: "openclaw", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-15T10:05:00.000Z", + }, ]); }); }); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index dbbb32fc..db37365c 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -16,6 +16,7 @@ const PROVIDER_STATUS_CACHE_IDS = [ "gemini", "kilo", "opencode", + "openclaw", "pi", ] as const satisfies ReadonlyArray; diff --git a/apps/server/src/provider/runtimeLayer.ts b/apps/server/src/provider/runtimeLayer.ts index b7277bbb..57a3eaf2 100644 --- a/apps/server/src/provider/runtimeLayer.ts +++ b/apps/server/src/provider/runtimeLayer.ts @@ -3,6 +3,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ServerConfig } from "../config"; +import { ServerSecretStore } from "../auth/Services/ServerSecretStore"; +import { ServerSettingsService } from "../serverSettings"; import { AnalyticsService } from "../telemetry/Services/AnalyticsService"; import { ProviderUnsupportedError } from "./Errors"; import { makeClaudeAdapterLive } from "./Layers/ClaudeAdapter"; @@ -11,6 +13,7 @@ import { makeCursorAdapterLive } from "./Layers/CursorAdapter"; import { makeEventNdjsonLogger } from "./Layers/EventNdjsonLogger"; import { makeGeminiAdapterLive } from "./Layers/GeminiAdapter"; import { makeKiloAdapterLive, makeOpenCodeAdapterLive } from "./Layers/OpenCodeAdapter"; +import { makeOpenClawAdapterLive } from "./Layers/OpenClawAdapter"; import { makePiAdapterLive } from "./Layers/PiAdapter"; import { ProviderAdapterRegistryLive } from "./Layers/ProviderAdapterRegistry"; import { ProviderDiscoveryServiceLive } from "./Layers/ProviderDiscoveryService"; @@ -30,6 +33,8 @@ export function makeServerProviderLayer(): Layer.Layer< | ServerConfig | FileSystem.FileSystem | AnalyticsService + | ServerSecretStore + | ServerSettingsService | ChildProcessSpawner.ChildProcessSpawner > { return Effect.gen(function* () { @@ -56,6 +61,7 @@ export function makeServerProviderLayer(): Layer.Layer< const openCodeAdapterLayer = makeOpenCodeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const openClawAdapterLayer = makeOpenClawAdapterLive(); const kiloAdapterLayer = makeKiloAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); @@ -74,6 +80,7 @@ export function makeServerProviderLayer(): Layer.Layer< Layer.provide(geminiAdapterLayer), Layer.provide(kiloAdapterLayer), Layer.provide(openCodeAdapterLayer), + Layer.provide(openClawAdapterLayer), Layer.provide(piAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index fc98aa0d..d4c9c043 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,18 +1,37 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL_BY_PROVIDER } from "@jcode/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ServerSettingsPatch } from "@jcode/contracts"; import { Effect, FileSystem, Layer } from "effect"; +import { dirname } from "node:path"; import { describe, expect, it } from "vitest"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import { ServerConfig } from "./config"; +import { + readOpenClawSecretMetadata, + rotateOpenClawDeviceKey, + setOpenClawPairedToken, + setOpenClawPassword, + setOpenClawToken, +} from "./provider/openclawSecrets"; import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "jcode-settings-test-", }).pipe(Layer.provide(NodeServices.layer)); const makeTestLayer = Layer.merge(NodeServices.layer, serverConfigLayer); -const testLayer = Layer.merge(makeTestLayer, ServerSettingsLive.pipe(Layer.provide(makeTestLayer))); +const secretStoreLayer = ServerSecretStoreLive.pipe(Layer.provide(makeTestLayer)); +const serviceDependenciesLayer = Layer.merge(makeTestLayer, secretStoreLayer); +const testLayer = Layer.merge( + serviceDependenciesLayer, + ServerSettingsLive.pipe(Layer.provide(serviceDependenciesLayer)), +); const runWithSettings = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + ServerSettingsService | ServerConfig | FileSystem.FileSystem | ServerSecretStore + >, ) => Effect.runPromise(effect.pipe(Effect.provide(testLayer)) as Effect.Effect); describe("ServerSettingsService", () => { @@ -66,6 +85,52 @@ describe("ServerSettingsService", () => { }); }); + it("persists only non-secret OpenClaw settings metadata", async () => { + const result = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* service.start; + + const patch = { + providers: { + openclaw: { + gatewayUrl: "ws://user:pass@127.0.0.1:18789/path?token=secret#fragment", + authMode: "token", + hasSecret: true, + paired: false, + token: "token-secret", + password: "password-secret", + }, + }, + } as unknown as ServerSettingsPatch; + + const updated = yield* service.updateSettings(patch); + const raw = yield* fs.readFileString(settingsPath); + return { updated, raw, parsed: JSON.parse(raw) as unknown }; + }), + ); + + expect(result.updated.providers.openclaw).toEqual({ + enabled: true, + gatewayUrl: "ws://127.0.0.1:18789/path", + authMode: "token", + hasSecret: false, + paired: false, + }); + expect(result.raw).not.toContain("token-secret"); + expect(result.raw).not.toContain("password-secret"); + expect(result.parsed).toMatchObject({ + providers: { + openclaw: { + gatewayUrl: "ws://127.0.0.1:18789/path", + authMode: "token", + }, + }, + }); + }); + it("resolves text generation selection away from disabled providers", async () => { const settings = await Effect.runPromise( Effect.gen(function* () { @@ -90,6 +155,138 @@ describe("ServerSettingsService", () => { expect(settings.textGenerationModelSelection.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); }); + it("persists sanitized OpenClaw gateway URLs", async () => { + const result = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* service.start; + + const updated = yield* service.updateSettings({ + providers: { + openclaw: { + gatewayUrl: "https://user:secret@gateway.example.test/path?token=must-not-leak#hash", + }, + }, + }); + const raw = yield* fs.readFileString(settingsPath); + return { updated, raw }; + }), + ); + + expect(result.updated.providers.openclaw.gatewayUrl).toBe("https://gateway.example.test/path"); + expect(result.raw).toContain("https://gateway.example.test/path"); + expect(result.raw).not.toContain("user"); + expect(result.raw).not.toContain("secret"); + expect(result.raw).not.toContain("must-not-leak"); + }); + + it("sanitizes legacy credential-bearing OpenClaw gateway URLs when loading settings", async () => { + const settings = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(settingsPath), { + recursive: true, + }); + yield* fs.writeFileString( + settingsPath, + `${JSON.stringify({ + providers: { + openclaw: { + gatewayUrl: "ws://user:pass@gateway.example.test/path?token=must-not-leak#fragment", + authMode: "token", + hasSecret: true, + paired: true, + }, + }, + })}\n`, + ); + + yield* service.start; + return yield* service.getSettings; + }), + ); + + expect(settings.providers.openclaw).toEqual({ + enabled: true, + gatewayUrl: "ws://gateway.example.test/path", + authMode: "token", + hasSecret: false, + paired: false, + }); + }); + + it("clears OpenClaw secrets and metadata when the gateway URL changes", async () => { + const result = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + yield* service.start; + + yield* service.updateSettings({ + providers: { openclaw: { gatewayUrl: "https://gateway.example.test/path" } }, + }); + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("password-secret"); + yield* rotateOpenClawDeviceKey; + yield* setOpenClawPairedToken("device-token-secret"); + + yield* service.updateOpenClawSecretMetadata({ hasSecret: true, paired: true }); + const settings = yield* service.updateSettings({ + providers: { openclaw: { gatewayUrl: "https://other-gateway.example.test/path" } }, + }); + + const metadata = yield* readOpenClawSecretMetadata; + return { metadata, settings }; + }), + ); + + expect(result.metadata).toEqual({ + hasToken: false, + hasPassword: false, + hasDeviceKey: false, + hasDeviceToken: false, + paired: false, + }); + expect(result.settings.providers.openclaw.hasSecret).toBe(false); + expect(result.settings.providers.openclaw.paired).toBe(false); + }); + + it("rehydrates OpenClaw secret metadata from the server secret store on startup", async () => { + const settings = await runWithSettings( + Effect.gen(function* () { + yield* setOpenClawToken("token-secret"); + yield* rotateOpenClawDeviceKey; + yield* setOpenClawPairedToken("device-token-secret"); + + const service = yield* ServerSettingsService; + yield* service.start; + return yield* service.getSettings; + }), + ); + + expect(settings.providers.openclaw.hasSecret).toBe(true); + expect(settings.providers.openclaw.paired).toBe(true); + }); + + it("persists server-owned OpenClaw secret metadata", async () => { + const settings = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + yield* service.start; + return yield* service.updateOpenClawSecretMetadata({ + hasSecret: true, + paired: true, + }); + }), + ); + + expect(settings.providers.openclaw.hasSecret).toBe(true); + expect(settings.providers.openclaw.paired).toBe(true); + }); + it("persists OpenCode runtime profiles", async () => { const settings = await runWithSettings( Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index deacf863..6cfa59d5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -31,7 +31,13 @@ import { Stream, } from "effect"; import * as Semaphore from "effect/Semaphore"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import { ServerConfig } from "./config"; +import { + clearOpenClawAuthSecrets, + clearOpenClawDeviceIdentity, + readOpenClawSecretMetadata, +} from "./provider/openclawSecrets"; export interface ServerSettingsShape { readonly start: Effect.Effect; @@ -40,6 +46,10 @@ export interface ServerSettingsShape { readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect; + readonly updateOpenClawSecretMetadata: (metadata: { + readonly hasSecret: boolean; + readonly paired: boolean; + }) => Effect.Effect; readonly streamChanges: Stream.Stream; } @@ -71,6 +81,15 @@ export class ServerSettingsService extends ServiceMap.Service< Effect.tap(emitChange), Effect.map(resolveTextGenerationProvider), ), + updateOpenClawSecretMetadata: (metadata) => + Ref.get(currentSettingsRef).pipe( + Effect.map((currentSettings) => + withOpenClawSecretMetadata(currentSettings, metadata), + ), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.tap(emitChange), + Effect.map(resolveTextGenerationProvider), + ), get streamChanges() { return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); }, @@ -107,6 +126,22 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings }; } +function withOpenClawSecretMetadata( + settings: ServerSettings, + metadata: { readonly hasSecret: boolean; readonly paired: boolean }, +): ServerSettings { + return { + ...settings, + providers: { + ...settings.providers, + openclaw: { + ...settings.providers.openclaw, + ...metadata, + }, + }, + }; +} + function normalizeSettings( settingsPath: string, current: ServerSettings, @@ -143,6 +178,7 @@ function decodeSettingsFromJson(settingsPath: string, raw: string) { const makeServerSettings = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig; + const secretStore = yield* ServerSecretStore; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const writeSemaphore = yield* Semaphore.make(1); @@ -187,7 +223,7 @@ const makeServerSettings = Effect.gen(function* () { }); return DEFAULT_SERVER_SETTINGS; } - return decoded.value; + return applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, decoded.value as ServerSettingsPatch); }); const writeSettingsAtomically = (settings: ServerSettings) => { @@ -208,6 +244,27 @@ const makeServerSettings = Effect.gen(function* () { ); }; + const clearOpenClawSecretsIfGatewayChanged = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect< + { + readonly hasSecret: boolean; + readonly paired: boolean; + } | null, + unknown + > => { + if (current.providers.openclaw.gatewayUrl === next.providers.openclaw.gatewayUrl) { + return Effect.succeed(null); + } + return Effect.all([clearOpenClawAuthSecrets, clearOpenClawDeviceIdentity], { + discard: true, + }).pipe( + Effect.as({ hasSecret: false, paired: false }), + Effect.provideService(ServerSecretStore, secretStore), + ); + }; + const start = Effect.gen(function* () { const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]); if (!shouldStart) { @@ -225,7 +282,22 @@ const makeServerSettings = Effect.gen(function* () { }), ), ); - const settings = yield* loadSettingsFromDisk; + const loadedSettings = yield* loadSettingsFromDisk; + const metadata = yield* readOpenClawSecretMetadata.pipe( + Effect.provideService(ServerSecretStore, secretStore), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to read OpenClaw secret metadata", + cause, + }), + ), + ); + const settings = withOpenClawSecretMetadata(loadedSettings, { + hasSecret: metadata.hasToken || metadata.hasPassword, + paired: metadata.paired, + }); yield* Ref.set(settingsRef, settings); }); @@ -247,6 +319,29 @@ const makeServerSettings = Effect.gen(function* () { Effect.gen(function* () { const current = yield* Ref.get(settingsRef); const next = yield* normalizeSettings(settingsPath, current, patch); + const metadata = yield* clearOpenClawSecretsIfGatewayChanged(current, next).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to clear OpenClaw secrets after gateway URL change", + cause, + }), + ), + ); + const nextWithMetadata = + metadata !== null ? withOpenClawSecretMetadata(next, metadata) : next; + yield* writeSettingsAtomically(nextWithMetadata); + yield* Ref.set(settingsRef, nextWithMetadata); + yield* emitChange(nextWithMetadata); + return resolveTextGenerationProvider(nextWithMetadata); + }), + ), + updateOpenClawSecretMetadata: (metadata) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = withOpenClawSecretMetadata(current, metadata); yield* writeSettingsAtomically(next); yield* Ref.set(settingsRef, next); yield* emitChange(next); diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index abe04445..1d6ed9e8 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -39,6 +39,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { authErrorResponse, makeEffectAuthRequest } from "./auth/http"; import { BootstrapCredentialService } from "./auth/Services/BootstrapCredentialService"; import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import { SessionCredentialService } from "./auth/Services/SessionCredentialService"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { ServerConfig } from "./config"; @@ -61,6 +62,7 @@ import { OpenCodeRuntimeLive, } from "./provider/opencodeRuntime"; import { checkOpenCodeRuntimeHealth } from "./provider/openCodeRuntimeHealth"; +import { applyOpenClawSecretUpdate } from "./provider/openclawSecretUpdate"; import { getProviderUsageSnapshot } from "./providerUsageSnapshot"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; @@ -254,6 +256,7 @@ export const makeWsRpcLayer = () => const runtimeStartup = yield* ServerRuntimeStartup; const serverEnvironment = yield* ServerEnvironment; const serverSettings = yield* ServerSettingsService; + const serverSecretStore = yield* ServerSecretStore; const sessionCredentials = yield* SessionCredentialService; const terminalManager = yield* TerminalManager; const workspaceEntries = yield* WorkspaceEntries; @@ -649,6 +652,20 @@ export const makeWsRpcLayer = () => rpcEffect(serverSettings.getSettings, "Failed to load server settings"), [WS_METHODS.serverUpdateSettings]: (input) => rpcEffect(serverSettings.updateSettings(input), "Failed to update server settings"), + [WS_METHODS.serverUpdateOpenClawSecrets]: (input) => + rpcEffect( + Effect.gen(function* () { + const metadata = yield* applyOpenClawSecretUpdate(input).pipe( + Effect.provideService(ServerSecretStore, serverSecretStore), + ); + yield* serverSettings.updateOpenClawSecretMetadata({ + hasSecret: metadata.hasToken || metadata.hasPassword, + paired: metadata.paired, + }); + return metadata; + }), + "Failed to update OpenClaw secrets", + ), [WS_METHODS.serverRefreshProviders]: () => rpcEffect( providerHealth.refresh.pipe(Effect.map((providers) => ({ providers }))), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index b84012af..5532b84c 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -143,6 +143,18 @@ describe("getGitTextGenerationModelOptions", () => { isCustom: true, }); }); + + it("does not add OpenClaw gateway to git-writing model options", () => { + const options = getGitTextGenerationModelOptions({ + customCodexModels: [], + customKiloModels: [], + customOpenCodeModels: [], + textGenerationModel: "gateway", + textGenerationProvider: "openclaw", + }); + + expect(options.some((option) => option.provider === "openclaw")).toBe(false); + }); }); describe("resolveAppModelSelection", () => { @@ -157,6 +169,7 @@ describe("resolveAppModelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, "galapagos-alpha", @@ -168,7 +181,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "", ), ).toBe("gpt-5.5"); @@ -178,7 +200,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "GPT-5.3 Codex", ), ).toBe("gpt-5.3-codex"); @@ -188,7 +219,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "claudeAgent", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "sonnet", ), ).toBe("claude-sonnet-4-6"); @@ -198,7 +238,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "custom/selected-model", ), ).toBe("custom/selected-model"); @@ -298,6 +347,13 @@ describe("server-backed app settings", () => { activeRuntimeProfileId: "", customModels: [], }, + openclaw: { + enabled: true, + gatewayUrl: "ws://127.0.0.1:18789", + authMode: "device", + hasSecret: true, + paired: false, + }, pi: { enabled: true, binaryPath: "pi", agentDir: "", customModels: [] }, }, textGenerationModelSelection: { provider: "codex", model: "gpt-5.4" }, @@ -305,6 +361,10 @@ describe("server-backed app settings", () => { ), ).toMatchObject({ addProjectBaseDirectory: "/home/jay/code", + openClawAuthMode: "device", + openClawGatewayUrl: "ws://127.0.0.1:18789", + openClawHasSecret: true, + openClawPaired: false, }); }); @@ -341,6 +401,13 @@ describe("server-backed app settings", () => { activeRuntimeProfileId: "", customModels: [], }, + openclaw: { + enabled: true, + gatewayUrl: "", + authMode: "none", + hasSecret: false, + paired: false, + }, pi: { enabled: true, binaryPath: "pi", agentDir: "", customModels: [] }, }, textGenerationModelSelection: { provider: "codex", model: "gpt-5.4" }, @@ -354,6 +421,24 @@ describe("server-backed app settings", () => { appSettingsPatchToServerSettingsPatch({ addProjectBaseDirectory: "/home/jay/code" }), ).toEqual({ addProjectBaseDirectory: "/home/jay/code" }); }); + + it("does not map OpenClaw server-derived metadata to server settings patches", () => { + expect( + appSettingsPatchToServerSettingsPatch({ + openClawGatewayUrl: "https://gateway.example.test", + openClawAuthMode: "token", + openClawHasSecret: true, + openClawPaired: false, + }), + ).toEqual({ + providers: { + openclaw: { + gatewayUrl: "https://gateway.example.test", + authMode: "token", + }, + }, + }); + }); }); describe("provider-specific custom models", () => { @@ -381,6 +466,7 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + openClawGatewayUrl: "ws://127.0.0.1:18789", piAgentDir: "", piBinaryPath: "", }), @@ -402,6 +488,29 @@ describe("getProviderStartOptions", () => { }); }); + it("does not include only OpenClaw gateway URLs in provider start options", () => { + expect( + getProviderStartOptions({ + claudeBinaryPath: "", + codexBinaryPath: "", + codexHomePath: "", + codexLaunchArgs: "", + cursorApiEndpoint: "", + cursorBinaryPath: "", + geminiBinaryPath: "", + kiloBinaryPath: "", + kiloServerPassword: "", + kiloServerUrl: "", + openCodeBinaryPath: "", + openCodeServerPassword: "", + openCodeServerUrl: "", + openClawGatewayUrl: "https://gateway.example.test", + piAgentDir: "", + piBinaryPath: "", + }), + ).toBeUndefined(); + }); + it("returns undefined when no provider overrides are configured", () => { expect( getProviderStartOptions({ @@ -418,6 +527,7 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + openClawGatewayUrl: "", piAgentDir: "", piBinaryPath: "", }), @@ -436,7 +546,7 @@ describe("provider-indexed custom model settings", () => { customPiModels: ["anthropic/custom-pi"], } as const; - it("exports one provider config per provider", () => { + it("exports custom model configs only for providers that support custom models", () => { expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ "codex", "claudeAgent", @@ -446,6 +556,9 @@ describe("provider-indexed custom model settings", () => { "opencode", "pi", ]); + expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider as string)).not.toContain( + "openclaw", + ); }); it("reads custom models for each provider", () => { @@ -530,6 +643,7 @@ describe("provider-indexed custom model settings", () => { gemini: ["gemini/custom-flash"], kilo: ["kilo/kilo-auto/free"], opencode: ["openrouter/gpt-oss-120b"], + openclaw: [], pi: ["anthropic/custom-pi"], }); }); @@ -604,6 +718,7 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.opencode.filter((option) => option.slug === "openrouter/gpt-oss-120b"), ).toHaveLength(1); + expect(modelOptionsByProvider.openclaw).toEqual([]); expect( modelOptionsByProvider.pi.filter((option) => option.slug === "anthropic/custom-pi"), ).toHaveLength(1); @@ -642,6 +757,10 @@ describe("AppSettingsSchema", () => { customKiloModels: [], customOpenCodeModels: [], customPiModels: [], + openClawAuthMode: "none", + openClawGatewayUrl: "", + openClawHasSecret: false, + openClawPaired: false, addProjectBaseDirectory: "", }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index a0d38acf..b4c79c60 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -4,6 +4,7 @@ import { Option, Schema } from "effect"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_SERVER_SETTINGS, + OpenClawAuthMode, TrimmedNonEmptyString, ProviderKind, type ProviderStartOptions, @@ -30,6 +31,7 @@ import { serverQueryKeys, serverSettingsQueryOptions } from "./lib/serverReactQu const APP_SETTINGS_STORAGE_KEY = "jcode:app-settings:v1"; const SERVER_SETTINGS_MIGRATION_STORAGE_KEY = "t3code:server-settings-migrated:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; +const GIT_TEXT_GENERATION_PROVIDERS = new Set(["codex", "kilo", "opencode"]); export const MAX_CUSTOM_MODEL_LENGTH = 256; export const MIN_CHAT_FONT_SIZE_PX = 11; export const MAX_CHAT_FONT_SIZE_PX = 18; @@ -60,8 +62,9 @@ type CustomModelSettingsKey = | "customKiloModels" | "customOpenCodeModels" | "customPiModels"; +export type CustomModelProviderKind = Exclude; export type ProviderCustomModelConfig = { - provider: ProviderKind; + provider: CustomModelProviderKind; settingsKey: CustomModelSettingsKey; defaultSettingsKey: CustomModelSettingsKey; title: string; @@ -77,6 +80,7 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record gemini: new Set(getModelOptions("gemini").map((option) => option.slug)), kilo: new Set(getModelOptions("kilo").map((option) => option.slug)), opencode: new Set(getModelOptions("opencode").map((option) => option.slug)), + openclaw: new Set(["gateway"]), pi: new Set(getModelOptions("pi").map((option) => option.slug)), }; @@ -116,6 +120,11 @@ export const AppSettingsSchema = Schema.Struct({ openCodeServerPassword: Schema.String.check(Schema.isMaxLength(4096)).pipe( withDefaults(() => ""), ), + openClawGatewayUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + openClawEnabled: Schema.Boolean.pipe(withDefaults(() => true)), + openClawAuthMode: OpenClawAuthMode.pipe(withDefaults(() => "none" as const)), + openClawHasSecret: Schema.Boolean.pipe(withDefaults(() => false)), + openClawPaired: Schema.Boolean.pipe(withDefaults(() => false)), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), confirmThreadArchive: Schema.Boolean.pipe(withDefaults(() => false)), @@ -172,7 +181,7 @@ export interface AppModelOption extends ProviderModelOption { const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); let serverSettingsMigrationInFlight = false; -const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { +const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { codex: { provider: "codex", settingsKey: "customCodexModels", @@ -312,6 +321,11 @@ export function serverSettingsToAppSettings(settings: ServerSettings): Partial, - provider: ProviderKind, + provider: CustomModelProviderKind, ): readonly string[] { return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; } export function getDefaultCustomModelsForProvider( defaults: Pick, - provider: ProviderKind, + provider: CustomModelProviderKind, ): readonly string[] { return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; } export function patchCustomModels( - provider: ProviderKind, + provider: CustomModelProviderKind, models: string[], ): Partial> { return { @@ -553,6 +583,7 @@ export function getCustomModelsByProvider( gemini: getCustomModelsForProvider(settings, "gemini"), kilo: getCustomModelsForProvider(settings, "kilo"), opencode: getCustomModelsForProvider(settings, "opencode"), + openclaw: [], pi: getCustomModelsForProvider(settings, "pi"), }; } @@ -636,7 +667,11 @@ export function getGitTextGenerationModelOptions( const selectedProvider = settings.textGenerationProvider ?? resolveTextGenerationProvider(selectedModel !== undefined ? { model: selectedModel } : {}); - if (selectedModel && !seen.has(`${selectedProvider}:${selectedModel}`)) { + if ( + selectedModel && + GIT_TEXT_GENERATION_PROVIDERS.has(selectedProvider) && + !seen.has(`${selectedProvider}:${selectedModel}`) + ) { deduped.push({ provider: selectedProvider, slug: selectedModel, @@ -671,6 +706,7 @@ export function getCustomModelOptionsByProvider( gemini: getAppModelOptions("gemini", customModelsByProvider.gemini), kilo: getAppModelOptions("kilo", customModelsByProvider.kilo), opencode: getAppModelOptions("opencode", customModelsByProvider.opencode), + openclaw: [], pi: getAppModelOptions("pi", customModelsByProvider.pi), }; } @@ -691,6 +727,7 @@ export function getProviderStartOptions( | "openCodeBinaryPath" | "openCodeServerPassword" | "openCodeServerUrl" + | "openClawGatewayUrl" | "piAgentDir" | "piBinaryPath" >, @@ -786,6 +823,8 @@ export function getCustomBinaryPathForProvider( return settings.kiloBinaryPath; case "opencode": return settings.openCodeBinaryPath; + case "openclaw": + return ""; case "pi": return settings.piBinaryPath; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f74151c8..318798e2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2025,7 +2025,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("does not re-arm tail follow when a detached user manually reaches the bottom", async () => { + it("re-arms tail follow when a detached user manually reaches the bottom", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithTailAssistantText("short tail response"), @@ -2086,10 +2086,10 @@ describe("ChatView timeline estimator parity (full app)", () => { async () => { const layout = await mounted.measureLayout(); expect(layout.scrollHeightPx).toBeGreaterThan(bottomLayout.scrollHeightPx); - expect(layout.distanceFromBottomPx).toBeGreaterThan(AUTO_SCROLL_BOTTOM_THRESHOLD_PX); + expect(layout.distanceFromBottomPx).toBeLessThanOrEqual(AUTO_SCROLL_BOTTOM_THRESHOLD_PX); expect( document.querySelector('button[aria-label="Scroll to bottom"]'), - ).toBeTruthy(); + ).toBeNull(); }, { timeout: 8_000, interval: 16 }, ); @@ -2429,8 +2429,9 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const composerEditor = await waitForComposerEditor(); - const dispatchComposerKey = (key: "ArrowDown" | "ArrowUp") => { - composerEditor.dispatchEvent( + const dispatchComposerKey = async (key: "ArrowDown" | "ArrowUp") => { + const currentComposerEditor = await waitForComposerEditor(); + currentComposerEditor.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, @@ -2442,7 +2443,7 @@ describe("ChatView timeline estimator parity (full app)", () => { composerEditor.focus(); expect(composerEditor.textContent).toBe(""); - dispatchComposerKey("ArrowUp"); + await dispatchComposerKey("ArrowUp"); await vi.waitFor( () => { expect(composerEditor.textContent).toBe("filler user message 21"); @@ -2450,7 +2451,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - dispatchComposerKey("ArrowUp"); + await dispatchComposerKey("ArrowUp"); await vi.waitFor( () => { expect(composerEditor.textContent).toBe("filler user message 20"); @@ -2458,7 +2459,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - dispatchComposerKey("ArrowDown"); + await dispatchComposerKey("ArrowDown"); await vi.waitFor( () => { expect(composerEditor.textContent).toBe("filler user message 21"); @@ -2466,7 +2467,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - dispatchComposerKey("ArrowDown"); + await dispatchComposerKey("ArrowDown"); await vi.waitFor( () => { expect(composerEditor.textContent).toBe(""); @@ -3138,7 +3139,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("starts stale branchless worktree drafts as local threads on first Enter", async () => { + it("rejects stale branchless worktree drafts on first Enter", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { @@ -3172,15 +3173,9 @@ describe("ChatView timeline estimator parity (full app)", () => { try { useComposerDraftStore.getState().setPrompt(THREAD_ID, "test"); - const editor = await waitForComposerEditor(); - editor.focus(); - editor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Enter", - bubbles: true, - cancelable: true, - }), - ); + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + await sendButton.click(); await vi.waitFor( () => { @@ -3192,13 +3187,7 @@ describe("ChatView timeline estimator parity (full app)", () => { "type" in request.command && request.command.type === "thread.create", ); - expect(createThreadRequest?.command).toMatchObject({ - projectId: HOMEASSIST_PROJECT_ID, - threadId: THREAD_ID, - envMode: "local", - branch: null, - worktreePath: null, - }); + expect(createThreadRequest).toBeUndefined(); const turnStartRequest = wsRequests.find( (request) => @@ -3208,12 +3197,7 @@ describe("ChatView timeline estimator parity (full app)", () => { "type" in request.command && request.command.type === "thread.turn.start", ); - expect(turnStartRequest?.command).toMatchObject({ - threadId: THREAD_ID, - message: { - text: "test", - }, - }); + expect(turnStartRequest).toBeUndefined(); }, { timeout: 1_000, interval: 16 }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 20d64f8a..f2a69929 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -377,6 +377,7 @@ import { buildNextProviderOptions, formatProviderModelOptionName, type ProviderModelOption, + type ProviderOptions, } from "../providerModelOptions"; import { isDuplicateProjectCreateError, @@ -471,6 +472,8 @@ function getProviderStartOptionsCustomBinaryPath( return normalizeCustomBinaryPath(providerOptions?.cursor?.binaryPath); case "pi": return normalizeCustomBinaryPath(providerOptions?.pi?.binaryPath); + case "openclaw": + return null; } } @@ -487,6 +490,14 @@ function getProviderHealthBannerDismissalKey(status: ServerProviderStatus | null ].join("\u001f"); } +function getModelSelectionOptions( + selection: ModelSelection | null | undefined, +): ProviderOptions | undefined { + return selection !== null && selection !== undefined && "options" in selection + ? (selection.options as ProviderOptions | undefined) + : undefined; +} + function getRateLimitBannerDismissalKey( status: RateLimitStatus | null, threadId: Thread["id"] | null, @@ -1448,6 +1459,7 @@ export default function ChatView({ gemini: resolveHint("gemini"), kilo: resolveHint("kilo"), opencode: resolveHint("opencode"), + openclaw: resolveHint("openclaw"), pi: resolveHint("pi"), }; }, [ @@ -1583,6 +1595,7 @@ export default function ChatView({ customModelsByProvider.opencode, composerModelHintByProvider.opencode, ), + openclaw: getAppModelOptions("openclaw", [], composerModelHintByProvider.openclaw), pi: getAppModelOptions("pi", customModelsByProvider.pi, composerModelHintByProvider.pi), }; const result: Record< @@ -1600,6 +1613,7 @@ export default function ChatView({ gemini: geminiModelsQuery.data, kilo: kiloDynamicModelsQuery.data, opencode: openCodeDynamicModelsQuery.data, + openclaw: undefined, pi: piDynamicModelsQuery.data, }; @@ -1660,6 +1674,7 @@ export default function ChatView({ gemini: geminiModelsQuery.data?.models ?? [], kilo: kiloDynamicModelsQuery.data?.models ?? [], opencode: openCodeDynamicModelsQuery.data?.models ?? [], + openclaw: [], pi: piDynamicModelsQuery.data?.models ?? [], }), [ @@ -1679,6 +1694,7 @@ export default function ChatView({ gemini: geminiModelsQuery, kilo: kiloDynamicModelsQuery, opencode: openCodeDynamicModelsQuery, + openclaw: undefined, pi: piDynamicModelsQuery, } as const; const selectedRuntimeModel = useMemo( @@ -2534,7 +2550,8 @@ export default function ChatView({ [selectedModel, selectedProvider], ); const supportsFastSlashCommand = selectedModelCaps.supportsFastMode; - const currentProviderModelOptions = composerModelOptions?.[selectedProvider]; + const currentProviderModelOptions = + selectedProvider === "openclaw" ? undefined : composerModelOptions?.[selectedProvider]; const fastModeEnabled = supportsFastSlashCommand && (currentProviderModelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; @@ -3870,8 +3887,8 @@ export default function ChatView({ input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || input.modelSelection.provider !== serverThread.modelSelection.provider || - JSON.stringify(input.modelSelection.options ?? null) !== - JSON.stringify(serverThread.modelSelection.options ?? null)) + JSON.stringify(getModelSelectionOptions(input.modelSelection) ?? null) !== + JSON.stringify(getModelSelectionOptions(serverThread.modelSelection) ?? null)) ) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", @@ -3998,33 +4015,35 @@ export default function ChatView({ return `${lastMessage.id}:${lastMessage.text.length}`; }, [timelineMessages]); - const onIsAtEndChange = useCallback((reportedIsAtEnd: boolean) => { - let isAtEnd = reportedIsAtEnd; - if (reportedIsAtEnd) { - const scrollContainer = legendListRef.current?.getScrollableNode?.(); - if (scrollContainer instanceof HTMLElement) { - isAtEnd = isScrollContainerNearBottom({ - scrollTop: scrollContainer.scrollTop, - clientHeight: scrollContainer.clientHeight, - scrollHeight: scrollContainer.scrollHeight, - }); + const onIsAtEndChange = useCallback( + (reportedIsAtEnd: boolean) => { + let isAtEnd = reportedIsAtEnd; + if (reportedIsAtEnd) { + const scrollContainer = legendListRef.current?.getScrollableNode?.(); + if (scrollContainer instanceof HTMLElement) { + isAtEnd = isScrollContainerNearBottom({ + scrollTop: scrollContainer.scrollTop, + clientHeight: scrollContainer.clientHeight, + scrollHeight: scrollContainer.scrollHeight, + }); + } } - } - if (isAtEndRef.current === isAtEnd) return; - if (!isAtEnd && performance.now() < programmaticScrollUntilRef.current) return; + if (isAtEndRef.current === isAtEnd) return; + if (!isAtEnd && performance.now() < programmaticScrollUntilRef.current) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else if (tailFollowEnabledRef.current) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - showScrollDebouncer.current.maybeExecute(); - } - }, []); + isAtEndRef.current = isAtEnd; + if (isAtEnd) { + enableTailFollow(); + } else if (tailFollowEnabledRef.current) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + showScrollDebouncer.current.maybeExecute(); + } + }, + [enableTailFollow], + ); const cancelPendingInteractionAnchorAdjustment = useCallback(() => { const pendingFrame = pendingInteractionAnchorFrameRef.current; if (pendingFrame === null) return; @@ -5648,22 +5667,6 @@ export default function ChatView({ nextThreadWorktreePath = null; } - if ( - isFirstMessage && - nextThreadEnvMode === "worktree" && - !nextThreadBranch && - !nextThreadWorktreePath - ) { - nextThreadEnvMode = "local"; - if (isLocalDraftThread) { - setDraftThreadContext(threadIdForSend, { - envMode: "local", - branch: null, - worktreePath: null, - }); - } - } - const baseBranchForWorktree = isFirstMessage && nextThreadEnvMode === "worktree" && !nextThreadWorktreePath ? nextThreadBranch @@ -5674,10 +5677,7 @@ export default function ChatView({ const shouldCreateWorktree = isFirstMessage && nextThreadEnvMode === "worktree" && !nextThreadWorktreePath; if (shouldCreateWorktree && !nextThreadBranch) { - setStoreThreadError( - threadIdForSend, - "Select a base branch before sending in New worktree mode.", - ); + setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return false; } @@ -5839,7 +5839,7 @@ export default function ChatView({ : selectedModelForSend || targetProjectDefaultModelSelectionForSend?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - selectedModelSelectionForSend.options, + getModelSelectionOptions(selectedModelSelectionForSend), ); if (isLocalDraftThread) { @@ -6650,10 +6650,7 @@ export default function ChatView({ return; } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); - const nextModelSelection: ModelSelection = { - provider, - model: resolvedModel, - }; + const nextModelSelection: ModelSelection = buildModelSelection(provider, resolvedModel); setComposerDraftModelSelection(activeThread.id, nextModelSelection); if (provider === "cursor" && !showExpandedCursorModelVariants) { setComposerDraftProviderModelOptions(activeThread.id, provider, undefined, { @@ -6691,7 +6688,8 @@ export default function ChatView({ }, [scheduleComposerFocus, setPrompt], ); - const selectedProviderModelOptions = composerModelOptions?.[selectedProvider]; + const selectedProviderModelOptions = + selectedProvider === "openclaw" ? undefined : composerModelOptions?.[selectedProvider]; const composerTraitSelection = getComposerTraitSelection( selectedProvider, selectedModel, diff --git a/apps/web/src/components/PluginLibrary.tsx b/apps/web/src/components/PluginLibrary.tsx index acb1837f..7d8c1168 100644 --- a/apps/web/src/components/PluginLibrary.tsx +++ b/apps/web/src/components/PluginLibrary.tsx @@ -84,6 +84,7 @@ const PROVIDER_ICON: Record gemini: Gemini, kilo: KiloIcon, opencode: OpenCodeIcon, + openclaw: PlugIcon, pi: PiIcon, }; const PROVIDER_DISCOVERY_ORDER: ReadonlyArray = [ @@ -417,6 +418,10 @@ export function PluginLibrary() { plugins: supportsPluginDiscovery(openCodeCapabilitiesQuery.data), skills: supportsSkillDiscovery(openCodeCapabilitiesQuery.data), }, + openclaw: { + plugins: false, + skills: false, + }, pi: { plugins: supportsPluginDiscovery(piCapabilitiesQuery.data), skills: supportsSkillDiscovery(piCapabilitiesQuery.data), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0b68fb83..c390d1e2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -118,6 +118,7 @@ import { dispatchThreadRename } from "../lib/threadRename"; import { quotePosixShellArgument } from "../lib/shellQuote"; import { DEFAULT_THREAD_TERMINAL_ID, type SidebarThreadSummary, type Thread } from "../types"; import { shouldRenderTerminalWorkspace } from "./ChatView.logic"; +import { buildModelSelection } from "../providerModelOptions"; import { ClaudeAI, CursorIcon, Gemini, KiloIcon, OpenAI, OpenCodeIcon, PiIcon } from "./Icons"; import { AppNavigationButtons } from "./AppNavigationButtons"; import { SidebarHeaderNavigationControls } from "./SidebarHeaderNavigationControls"; @@ -2182,10 +2183,7 @@ export default function Sidebar() { activeProject.defaultModelSelection?.provider === provider ? activeProject.defaultModelSelection : providerDefaultModel - ? { - provider, - model: providerDefaultModel, - } + ? buildModelSelection(provider, providerDefaultModel) : null; if (!modelSelection) { throw new Error("Select a Pi model before importing a Pi thread."); diff --git a/apps/web/src/components/SkillLibrarySettingsPanel.test.ts b/apps/web/src/components/SkillLibrarySettingsPanel.test.ts new file mode 100644 index 00000000..d777873d --- /dev/null +++ b/apps/web/src/components/SkillLibrarySettingsPanel.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSkillLibraryProviderQueryMap, + buildSkillLibraryProviderStatusMap, +} from "./SkillLibrarySettingsPanel"; + +describe("SkillLibrarySettingsPanel provider maps", () => { + it("does not map OpenClaw to Pi skill queries", () => { + const skillQueries = buildSkillLibraryProviderQueryMap({ + codex: "codex-skills", + claudeAgent: "claude-skills", + cursor: "cursor-skills", + gemini: "gemini-skills", + kilo: "kilo-skills", + opencode: "opencode-skills", + pi: "pi-skills", + }); + + expect(Object.keys(skillQueries)).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", + "pi", + ]); + expect("openclaw" in skillQueries).toBe(false); + expect(skillQueries.pi).toBe("pi-skills"); + }); + + it("does not map OpenClaw to Pi provider status", () => { + const providerStatus = buildSkillLibraryProviderStatusMap({ + codex: { capability: "codex-capability", skills: "codex-skills" }, + claudeAgent: { capability: "claude-capability", skills: "claude-skills" }, + cursor: { capability: "cursor-capability", skills: "cursor-skills" }, + gemini: { capability: "gemini-capability", skills: "gemini-skills" }, + kilo: { capability: "kilo-capability", skills: "kilo-skills" }, + opencode: { capability: "opencode-capability", skills: "opencode-skills" }, + pi: { capability: "pi-capability", skills: "pi-skills" }, + }); + + expect(Object.keys(providerStatus)).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", + "pi", + ]); + expect("openclaw" in providerStatus).toBe(false); + expect(providerStatus.pi).toEqual({ capability: "pi-capability", skills: "pi-skills" }); + }); +}); diff --git a/apps/web/src/components/SkillLibrarySettingsPanel.tsx b/apps/web/src/components/SkillLibrarySettingsPanel.tsx index e391ffa7..326a513f 100644 --- a/apps/web/src/components/SkillLibrarySettingsPanel.tsx +++ b/apps/web/src/components/SkillLibrarySettingsPanel.tsx @@ -57,7 +57,23 @@ import { import { Input } from "./ui/input"; import { Switch } from "./ui/switch"; -const PROVIDERS: readonly ProviderKind[] = [ +type SkillLibraryDiscoveryProvider = Exclude; + +type SkillLibraryDiscoveryProviderMap = Record; + +export function buildSkillLibraryProviderQueryMap( + queries: SkillLibraryDiscoveryProviderMap, +): SkillLibraryDiscoveryProviderMap { + return queries; +} + +export function buildSkillLibraryProviderStatusMap( + statuses: SkillLibraryDiscoveryProviderMap, +): SkillLibraryDiscoveryProviderMap { + return statuses; +} + +const PROVIDERS: readonly SkillLibraryDiscoveryProvider[] = [ "codex", "claudeAgent", "cursor", @@ -67,6 +83,12 @@ const PROVIDERS: readonly ProviderKind[] = [ "pi", ]; +function isSkillLibraryDiscoveryProvider( + provider: SkillLibraryProviderFilter, +): provider is SkillLibraryDiscoveryProvider { + return provider !== "all" && provider !== "openclaw"; +} + const MAX_COLLAPSED_PROVIDER_ROWS = 48; function getSkillTitle(row: SkillLibraryRow): string { @@ -529,6 +551,7 @@ export function SkillLibrarySettingsPanel() { gemini: supportsSkillDiscovery(geminiCapabilitiesQuery.data), kilo: supportsSkillDiscovery(kiloCapabilitiesQuery.data), opencode: supportsSkillDiscovery(openCodeCapabilitiesQuery.data), + openclaw: false, pi: supportsSkillDiscovery(piCapabilitiesQuery.data), }), [ @@ -616,15 +639,16 @@ export function SkillLibrarySettingsPanel() { ); const skillQueries = useMemo( - () => ({ - codex: codexSkillsQuery, - claudeAgent: claudeSkillsQuery, - cursor: cursorSkillsQuery, - gemini: geminiSkillsQuery, - kilo: kiloSkillsQuery, - opencode: openCodeSkillsQuery, - pi: piSkillsQuery, - }), + () => + buildSkillLibraryProviderQueryMap({ + codex: codexSkillsQuery, + claudeAgent: claudeSkillsQuery, + cursor: cursorSkillsQuery, + gemini: geminiSkillsQuery, + kilo: kiloSkillsQuery, + opencode: openCodeSkillsQuery, + pi: piSkillsQuery, + }), [ claudeSkillsQuery, codexSkillsQuery, @@ -659,7 +683,12 @@ export function SkillLibrarySettingsPanel() { ); const groupedFilteredRows = useMemo( () => - (providerFilter === "all" ? PROVIDERS : [providerFilter]).map((provider) => ({ + (providerFilter === "all" + ? PROVIDERS + : isSkillLibraryDiscoveryProvider(providerFilter) + ? [providerFilter] + : [] + ).map((provider) => ({ provider, rows: filteredRows.filter((row) => row.provider === provider), })), @@ -684,15 +713,16 @@ export function SkillLibrarySettingsPanel() { const isSkillLoading = activeProviders.some((provider) => skillQueries[provider].isLoading); const providerStatus = useMemo( - () => ({ - codex: { capability: codexCapabilitiesQuery, skills: codexSkillsQuery }, - claudeAgent: { capability: claudeCapabilitiesQuery, skills: claudeSkillsQuery }, - cursor: { capability: cursorCapabilitiesQuery, skills: cursorSkillsQuery }, - gemini: { capability: geminiCapabilitiesQuery, skills: geminiSkillsQuery }, - kilo: { capability: kiloCapabilitiesQuery, skills: kiloSkillsQuery }, - opencode: { capability: openCodeCapabilitiesQuery, skills: openCodeSkillsQuery }, - pi: { capability: piCapabilitiesQuery, skills: piSkillsQuery }, - }), + () => + buildSkillLibraryProviderStatusMap({ + codex: { capability: codexCapabilitiesQuery, skills: codexSkillsQuery }, + claudeAgent: { capability: claudeCapabilitiesQuery, skills: claudeSkillsQuery }, + cursor: { capability: cursorCapabilitiesQuery, skills: cursorSkillsQuery }, + gemini: { capability: geminiCapabilitiesQuery, skills: geminiSkillsQuery }, + kilo: { capability: kiloCapabilitiesQuery, skills: kiloSkillsQuery }, + opencode: { capability: openCodeCapabilitiesQuery, skills: openCodeSkillsQuery }, + pi: { capability: piCapabilitiesQuery, skills: piSkillsQuery }, + }), [ claudeCapabilitiesQuery, claudeSkillsQuery, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 5a3df7d1..bd7e6484 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { buildModelSelection } from "../../providerModelOptions"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -17,12 +18,12 @@ async function mountMenu(props?: { prompt?: string; }) { const threadId = ThreadId.makeUnsafe("thread-compact-menu"); - const provider = props?.modelSelection?.provider ?? "claudeAgent"; + const modelSelection = props?.modelSelection; + const provider = modelSelection?.provider ?? "claudeAgent"; const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; - const model = - props?.modelSelection?.model ?? getDefaultModel(provider) ?? getDefaultModel("codex"); + const model = modelSelection?.model ?? getDefaultModel(provider) ?? getDefaultModel("codex"); draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", @@ -33,11 +34,11 @@ async function mountMenu(props?: { terminalContexts: [], queuedTurns: [], modelSelectionByProvider: { - [provider]: { + [provider]: buildModelSelection( provider, model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), - }, + modelSelection && "options" in modelSelection ? modelSelection.options : undefined, + ), }, activeProvider: provider, runtimeMode: null, @@ -51,7 +52,8 @@ async function mountMenu(props?: { const host = document.createElement("div"); document.body.append(host); const onPromptChange = vi.fn(); - const providerOptions = props?.modelSelection?.options; + const providerOptions = + modelSelection && "options" in modelSelection ? modelSelection.options : undefined; const screen = await render( = { gemini: Gemini, kilo: KiloIcon, opencode: OpenCodeIcon, + openclaw: PlugIcon, pi: PiIcon, }; diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 90943dea..7094648d 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -46,6 +46,7 @@ function ClaudeTraitsPickerHarness(props: { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, }); @@ -575,6 +576,7 @@ function OpenCodeTraitsPickerHarness(props: { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index e87c3822..216193df 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -30,6 +30,8 @@ import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; import { getComposerTraitSelection, hasVisibleComposerTraitControls } from "./composerTraits"; import { getRuntimeAwareModelCapabilities } from "./runtimeModelCapabilities"; +type ComposerProviderModelOptions = ProviderModelOptions[keyof ProviderModelOptions]; + export type ComposerProviderStateInput = { provider: ProviderKind; model: ModelSlug; @@ -41,7 +43,7 @@ export type ComposerProviderStateInput = { export type ComposerProviderState = { provider: ProviderKind; promptEffort: string | null; - modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; + modelOptionsForDispatch: ComposerProviderModelOptions | undefined; composerFrameClassName?: string; composerSurfaceClassName?: string; modelPickerIconClassName?: string; @@ -53,7 +55,7 @@ type ProviderTraitRenderInput = { runtimeModel?: ProviderModelDescriptor | undefined; runtimeModels?: ReadonlyArray | null | undefined; runtimeAgents?: ReadonlyArray | null | undefined; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; + modelOptions: ComposerProviderModelOptions | undefined; prompt: string; includeFastMode?: boolean; onPromptChange: (prompt: string) => void; @@ -121,7 +123,7 @@ function getProviderStateFromCapabilities( const caps = getRuntimeAwareModelCapabilities({ provider, model, runtimeModel }); let rawEffort: string | null = null; - let normalizedOptions: ProviderModelOptions[ProviderKind] | undefined; + let normalizedOptions: ComposerProviderModelOptions | undefined; switch (provider) { case "codex": { @@ -240,6 +242,18 @@ function getProviderStateFromCapabilities( }; } +function getOpenClawProviderState(input: ComposerProviderStateInput): ComposerProviderState { + return { + provider: input.provider, + promptEffort: null, + modelOptionsForDispatch: undefined, + }; +} + +function renderNoTraits(): ReactNode { + return null; +} + const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), @@ -271,6 +285,11 @@ const composerProviderRegistry: Record = { renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("opencode", input), renderTraitsPicker: (input) => renderTraitsPickerForProvider("opencode", input), }, + openclaw: { + getState: getOpenClawProviderState, + renderTraitsMenuContent: renderNoTraits, + renderTraitsPicker: renderNoTraits, + }, pi: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("pi", input), @@ -289,7 +308,7 @@ export function renderProviderTraitsMenuContent(input: { runtimeModel?: ProviderModelDescriptor | undefined; runtimeModels?: ReadonlyArray | null | undefined; runtimeAgents?: ReadonlyArray | null | undefined; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; + modelOptions: ComposerProviderModelOptions | undefined; prompt: string; includeFastMode?: boolean; onPromptChange: (prompt: string) => void; @@ -321,7 +340,7 @@ export function renderProviderTraitsPicker(input: { runtimeModel?: ProviderModelDescriptor | undefined; runtimeModels?: ReadonlyArray | null | undefined; runtimeAgents?: ReadonlyArray | null | undefined; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; + modelOptions: ComposerProviderModelOptions | undefined; prompt: string; includeFastMode?: boolean; open?: boolean; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 0d5fae2e..4079a06c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -128,7 +128,7 @@ function resetComposerDraftStore() { function modelSelection( provider: ModelSelection["provider"], model: string, - options?: ModelSelection["options"], + options?: ProviderModelOptions[keyof ProviderModelOptions], ): ModelSelection { return { provider, @@ -137,6 +137,12 @@ function modelSelection( } as ModelSelection; } +function modelSelectionOptions(selection: ModelSelection | null | undefined) { + return selection !== null && selection !== undefined && "options" in selection + ? selection.options + : undefined; +} + function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { return options; } @@ -162,6 +168,21 @@ describe("resolvePreferredComposerModelSelection", () => { }), ); }); + + it("preserves persisted OpenClaw gateway selections", () => { + expect( + resolvePreferredComposerModelSelection({ + draft: { + modelSelectionByProvider: { + openclaw: modelSelection("openclaw", "gateway"), + }, + activeProvider: "openclaw", + }, + threadModelSelection: modelSelection("codex", "gpt-5"), + projectModelSelection: null, + }), + ).toEqual(modelSelection("openclaw", "gateway")); + }); }); describe("composerDraftStore addImages", () => { @@ -1080,8 +1101,12 @@ describe("composerDraftStore modelSelection", () => { store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.codex)).toEqual({ + reasoningEffort: "xhigh", + }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.claudeAgent)).toEqual({ + effort: "max", + }); }); it("preserves other provider options when switching the active model selection", () => { @@ -1101,7 +1126,9 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.codex)).toEqual({ + fastMode: true, + }); expect(draft?.activeProvider).toBe("claudeAgent"); }); @@ -1144,6 +1171,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1170,6 +1198,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1201,6 +1230,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1232,6 +1262,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1483,7 +1514,9 @@ describe("composerDraftStore provider-scoped option updates", () => { expect(draft?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), ); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.claudeAgent)).toEqual({ + effort: "max", + }); expect(draft?.activeProvider).toBe("codex"); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 7a6cc478..bfca9192 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -50,6 +50,7 @@ import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; export const COMPOSER_DRAFT_STORAGE_KEY = "jcode:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 4; +type ProviderModelOptionValue = ProviderModelOptions[keyof ProviderModelOptions]; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; const DraftThreadEntryPointSchema = Schema.Literals(["chat", "terminal"]); @@ -696,6 +697,7 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { value === "gemini" || value === "kilo" || value === "opencode" || + value === "openclaw" || value === "pi" ? value : null; @@ -712,7 +714,7 @@ function trimStringOrUndefined(value: unknown): string | undefined { function makeModelSelection( provider: ProviderKind, model: string, - options?: ProviderModelOptions[ProviderKind], + options?: ProviderModelOptionValue, ): ModelSelection { switch (provider) { case "codex": @@ -773,9 +775,24 @@ function makeModelSelection( ? { options: options as Extract["options"] } : {}), }; + case "openclaw": + return { provider, model: "gateway" }; } } +function getModelSelectionOptions( + selection: ModelSelection | null, +): ProviderModelOptionValue | undefined { + return selection !== null && "options" in selection ? selection.options : undefined; +} + +function getProviderModelOptions( + modelOptions: ProviderModelOptions | null | undefined, + provider: ProviderKind, +): ProviderModelOptionValue | undefined { + return provider === "openclaw" ? undefined : modelOptions?.[provider]; +} + function normalizeProviderModelOptions( value: unknown, provider?: ProviderKind | null, @@ -1036,7 +1053,7 @@ function legacySyncModelSelectionOptions( if (modelSelection === null) { return null; } - const options = modelOptions?.[modelSelection.provider]; + const options = getProviderModelOptions(modelOptions, modelSelection.provider); return makeModelSelection(modelSelection.provider, modelSelection.model, options); } @@ -1044,13 +1061,17 @@ function legacyMergeModelSelectionIntoProviderModelOptions( modelSelection: ModelSelection | null, currentModelOptions: ProviderModelOptions | null | undefined, ): ProviderModelOptions | null { - if (modelSelection?.options === undefined) { + if (modelSelection === null) { + return normalizeProviderModelOptions(currentModelOptions); + } + const selectionOptions = getModelSelectionOptions(modelSelection); + if (!modelSelection || selectionOptions === undefined) { return normalizeProviderModelOptions(currentModelOptions); } return legacyReplaceProviderModelOptions( normalizeProviderModelOptions(currentModelOptions), modelSelection.provider, - modelSelection.options, + selectionOptions, ); } @@ -1215,8 +1236,10 @@ export function resolvePreferredComposerModelSelection(input: { (input.projectModelSelection?.provider === preferredProvider ? input.projectModelSelection : null) ?? { - provider: preferredProvider === "pi" ? "codex" : preferredProvider, - model: getDefaultModel(preferredProvider === "pi" ? "codex" : preferredProvider), + ...makeModelSelection( + preferredProvider === "pi" ? "codex" : preferredProvider, + getDefaultModel(preferredProvider === "pi" ? "codex" : preferredProvider) ?? "gateway", + ), } ); } @@ -2600,10 +2623,11 @@ export const useComposerDraftStore = create()( for (const [provider, selection] of Object.entries(stickyMap)) { if (selection) { const current = nextMap[provider as ProviderKind]; - nextMap[provider as ProviderKind] = { - ...selection, - model: current?.model ?? selection.model, - }; + nextMap[provider as ProviderKind] = makeModelSelection( + selection.provider, + current?.model ?? selection.model, + "options" in selection ? selection.options : undefined, + ); } } if ( diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 28e0b988..c795f9bd 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -3,6 +3,7 @@ import { getDefaultModel } from "@jcode/shared/model"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { useAppSettings } from "../appSettings"; +import { buildModelSelection } from "../providerModelOptions"; import { type ComposerThreadDraftState, type DraftThreadState, @@ -47,10 +48,7 @@ export function useHandleNewThread() { if (!defaultModel) { return; } - setModelSelection(threadId, { - provider: options.provider, - model: defaultModel, - }); + setModelSelection(threadId, buildModelSelection(options.provider, defaultModel)); }; const restoreComposerDraft = ( threadId: ThreadId, diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index e656afbd..5a847086 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -13,6 +13,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("claudeAgent")).toEqual([ @@ -21,6 +22,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("cursor")).toEqual([ @@ -29,6 +31,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("gemini")).toEqual([ @@ -37,6 +40,7 @@ describe("threadHandoff", () => { "cursor", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("kilo")).toEqual([ @@ -45,6 +49,7 @@ describe("threadHandoff", () => { "cursor", "gemini", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("opencode")).toEqual([ @@ -53,6 +58,16 @@ describe("threadHandoff", () => { "cursor", "gemini", "kilo", + "openclaw", + "pi", + ]); + expect(resolveAvailableHandoffTargetProviders("openclaw")).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", "pi", ]); expect(resolveAvailableHandoffTargetProviders("pi")).toEqual([ @@ -62,6 +77,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", ]); }); @@ -109,4 +125,50 @@ describe("threadHandoff", () => { model: "gpt-5.5", }); }); + + it("falls back to the fixed OpenClaw gateway model for handoff targets", () => { + expect( + resolveThreadHandoffModelSelection({ + sourceThread: { + modelSelection: { + provider: "gemini", + model: "gemini-2.5-pro", + }, + }, + targetProvider: "openclaw", + projectDefaultModelSelection: null, + stickyModelSelectionByProvider: {}, + }), + ).toEqual({ + provider: "openclaw", + model: "gateway", + }); + }); + + it("ignores non-gateway sticky and project defaults for OpenClaw handoff", () => { + expect( + resolveThreadHandoffModelSelection({ + sourceThread: { + modelSelection: { + provider: "gemini", + model: "gemini-2.5-pro", + }, + }, + targetProvider: "openclaw", + projectDefaultModelSelection: { + provider: "openclaw", + model: "custom-model", + } as unknown as ModelSelection, + stickyModelSelectionByProvider: { + openclaw: { + provider: "openclaw", + model: "another-model", + } as unknown as ModelSelection, + }, + }), + ).toEqual({ + provider: "openclaw", + model: "gateway", + }); + }); }); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 4801bbb3..46b8e49b 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -8,6 +8,7 @@ import { type ThreadHandoffImportedMessage, } from "@jcode/contracts"; import { getDefaultModel } from "@jcode/shared/model"; +import { buildModelSelection } from "../providerModelOptions"; import { type Thread } from "../types"; import { stripEmbeddedAssistantSelections } from "./assistantSelections"; import { randomUUID } from "./utils"; @@ -19,6 +20,7 @@ const HANDOFF_PROVIDER_ORDER: ReadonlyArray = [ "gemini", "kilo", "opencode", + "openclaw", "pi", ]; const IMPORTABLE_THREAD_ACTIVITY_KINDS = new Set([ @@ -154,6 +156,10 @@ export function resolveThreadHandoffModelSelection(input: { return input.targetProvider !== "kilo" || selection.model.startsWith("kilo/"); }; + if (input.targetProvider === "openclaw") { + return buildModelSelection("openclaw", "gateway"); + } + const stickySelection = input.stickyModelSelectionByProvider[input.targetProvider]; if (isCompatibleSelection(stickySelection)) { return stickySelection; @@ -165,8 +171,5 @@ export function resolveThreadHandoffModelSelection(input: { if (!defaultModel) { throw new Error("Select a Pi model before handing off to Pi."); } - return { - provider: input.targetProvider, - model: defaultModel, - }; + return buildModelSelection(input.targetProvider, defaultModel); } diff --git a/apps/web/src/providerModelOptions.ts b/apps/web/src/providerModelOptions.ts index 0b3bfbf0..49d7b312 100644 --- a/apps/web/src/providerModelOptions.ts +++ b/apps/web/src/providerModelOptions.ts @@ -12,6 +12,7 @@ import type { ModelSelection, OpenCodeModelOptions, OpenCodeModelSelection, + OpenClawModelOptions, PiModelOptions, PiModelSelection, ProviderKind, @@ -230,6 +231,11 @@ export function buildModelSelection( model: string, options?: PiModelOptions | null | undefined, ): PiModelSelection; +export function buildModelSelection( + provider: "openclaw", + model: string, + options?: OpenClawModelOptions | null | undefined, +): Extract; export function buildModelSelection( provider: ProviderKind, model: string, @@ -297,5 +303,7 @@ export function buildModelSelection( options: options as PiModelOptions, } : { provider, model }; + case "openclaw": + return { provider, model: "gateway" }; } } diff --git a/apps/web/src/providerOrdering.test.ts b/apps/web/src/providerOrdering.test.ts index 38226984..9cf969e1 100644 --- a/apps/web/src/providerOrdering.test.ts +++ b/apps/web/src/providerOrdering.test.ts @@ -1,6 +1,50 @@ import { describe, expect, it } from "vitest"; -import { filterVisibleProviderItems } from "./providerOrdering"; +import { + DEFAULT_PROVIDER_ORDER, + filterVisibleProviderItems, + normalizeHiddenProviders, + normalizeProviderOrder, +} from "./providerOrdering"; + +describe("DEFAULT_PROVIDER_ORDER", () => { + it("orders OpenClaw after OpenCode and before Pi", () => { + expect(DEFAULT_PROVIDER_ORDER).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", + "openclaw", + "pi", + ]); + }); +}); + +describe("normalizeProviderOrder", () => { + it("keeps OpenClaw and Pi in normalized provider order", () => { + expect(normalizeProviderOrder(["opencode", "openclaw", "unknown", "pi", "openclaw"])).toEqual([ + "opencode", + "openclaw", + "pi", + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + ]); + }); +}); + +describe("normalizeHiddenProviders", () => { + it("keeps OpenClaw and Pi as valid hidden providers", () => { + expect(normalizeHiddenProviders(["openclaw", "pi", "unknown", "openclaw"])).toEqual([ + "openclaw", + "pi", + ]); + }); +}); describe("filterVisibleProviderItems", () => { it("removes hidden providers while preserving visible provider order", () => { diff --git a/apps/web/src/providerOrdering.ts b/apps/web/src/providerOrdering.ts index d56e3a2e..cb6fa728 100644 --- a/apps/web/src/providerOrdering.ts +++ b/apps/web/src/providerOrdering.ts @@ -12,6 +12,8 @@ export const DEFAULT_PROVIDER_ORDER: readonly ProviderKind[] = [ "gemini", "kilo", "opencode", + "openclaw", + "pi", ]; const PROVIDER_KIND_SET: ReadonlySet = new Set(DEFAULT_PROVIDER_ORDER); diff --git a/apps/web/src/routes/-_chat.settings.install.test.ts b/apps/web/src/routes/-_chat.settings.install.test.ts index bda49c02..3b561974 100644 --- a/apps/web/src/routes/-_chat.settings.install.test.ts +++ b/apps/web/src/routes/-_chat.settings.install.test.ts @@ -2,8 +2,19 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const settingsRouteSource = readFileSync(new URL("./_chat.settings.tsx", import.meta.url), "utf8"); +const defaultProviderStart = settingsRouteSource.indexOf('title="Default provider"'); +const newThreadsStart = settingsRouteSource.indexOf('title="New threads"'); +const defaultProviderSectionSource = + defaultProviderStart >= 0 && newThreadsStart >= 0 + ? settingsRouteSource.slice(defaultProviderStart, newThreadsStart) + : ""; describe("settings install provider contracts", () => { + it("keeps the default provider section before new thread settings", () => { + expect(defaultProviderStart).toBeGreaterThanOrEqual(0); + expect(newThreadsStart).toBeGreaterThan(defaultProviderStart); + }); + it("keeps Codex launch arguments wired into install settings", () => { expect(settingsRouteSource).toContain('launchArgsKey?: "codexLaunchArgs"'); expect(settingsRouteSource).toContain('launchArgsKey: "codexLaunchArgs"'); @@ -14,4 +25,32 @@ describe("settings install provider contracts", () => { expect(settingsRouteSource).toContain("value={codexLaunchArgs}"); expect(settingsRouteSource).toContain("codexLaunchArgs: event.target.value"); }); + + it("keeps OpenClaw gateway settings non-secret in install settings", () => { + expect(settingsRouteSource).toContain('provider: "openclaw"'); + expect(settingsRouteSource).toContain('gatewayUrlKey: "openClawGatewayUrl"'); + expect(settingsRouteSource).toContain('authModeKey: "openClawAuthMode"'); + expect(settingsRouteSource).toContain('aria-label="Enable OpenClaw gateway"'); + expect(settingsRouteSource).toContain('aria-label="OpenClaw gateway URL"'); + expect(settingsRouteSource).toContain("openClawGatewayUrl: event.target.value"); + expect(settingsRouteSource).toContain("openClawAuthMode: value"); + expect(settingsRouteSource).not.toContain("openClawSecret"); + }); +}); + +describe("settings default provider contracts", () => { + it("keeps OpenClaw accepted and visible in the default provider select", () => { + expect(defaultProviderSectionSource).toContain('value !== "openclaw"'); + expect(defaultProviderSectionSource).toContain('settings.defaultProvider === "openclaw"'); + expect(defaultProviderSectionSource).toContain(''); + expect(defaultProviderSectionSource.indexOf('value="kilo"')).toBeLessThan( + defaultProviderSectionSource.indexOf('value="opencode"'), + ); + expect(defaultProviderSectionSource.indexOf('value="opencode"')).toBeLessThan( + defaultProviderSectionSource.indexOf('value="openclaw"'), + ); + expect(defaultProviderSectionSource.indexOf('value="openclaw"')).toBeLessThan( + defaultProviderSectionSource.indexOf('value="pi"'), + ); + }); }); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 494d2fde..b3bd46e1 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -7,6 +7,7 @@ import { PROVIDER_DISPLAY_NAMES, type ProviderKind, type ServerProviderStatus, + type ServerUpdateOpenClawSecretsInput, type ThreadId, DEFAULT_GIT_TEXT_GENERATION_MODEL, } from "@jcode/contracts"; @@ -37,6 +38,7 @@ import { MAX_CUSTOM_MODEL_LENGTH, MIN_CHAT_FONT_SIZE_PX, MODEL_PROVIDER_SETTINGS, + type CustomModelProviderKind, normalizeChatFontSizePx, patchCustomModels, useAppSettings, @@ -165,9 +167,9 @@ type InstallProviderSettings = { label: string; href: string; }>; - binaryPathKey: InstallBinarySettingsKey; - binaryPlaceholder: string; - binaryDescription: ReactNode; + binaryPathKey?: InstallBinarySettingsKey; + binaryPlaceholder?: string; + binaryDescription?: ReactNode; homePathKey?: "codexHomePath"; homePlaceholder?: string; homeDescription?: ReactNode; @@ -183,6 +185,10 @@ type InstallProviderSettings = { serverPasswordKey?: "kiloServerPassword" | "openCodeServerPassword"; serverPasswordPlaceholder?: string; serverPasswordDescription?: ReactNode; + gatewayUrlKey?: "openClawGatewayUrl"; + gatewayUrlPlaceholder?: string; + gatewayUrlDescription?: ReactNode; + authModeKey?: "openClawAuthMode"; agentDirKey?: "piAgentDir"; agentDirPlaceholder?: string; agentDirDescription?: ReactNode; @@ -195,6 +201,8 @@ const PROVIDER_VISIBILITY_OPTIONS: ReadonlyArray<{ provider: ProviderKind; title { provider: "gemini", title: PROVIDER_DISPLAY_NAMES.gemini }, { provider: "kilo", title: PROVIDER_DISPLAY_NAMES.kilo }, { provider: "opencode", title: PROVIDER_DISPLAY_NAMES.opencode }, + { provider: "openclaw", title: PROVIDER_DISPLAY_NAMES.openclaw }, + { provider: "pi", title: PROVIDER_DISPLAY_NAMES.pi }, ]; // Pure helper kept at module scope so the toggle handler stays trivial and the @@ -231,7 +239,7 @@ function SortableProviderVisibilityRow(props: { transition, }} className={cn( - "flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-[var(--color-background-elevated-secondary)]/40 px-3 py-2.5", + "flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-(--color-background-elevated-secondary)/40 px-3 py-2.5", isDragging && "z-10 opacity-80 shadow-lg", )} > @@ -239,7 +247,7 @@ function SortableProviderVisibilityRow(props: { + + + + + ) : openClawAuthMode === "password" ? ( +
+
+
+
+ OpenClaw password +
+
+ {openClawHasSecret + ? "A gateway secret is saved in native storage." + : "No gateway secret is saved."} +
+
+
+
+ +
+ + +
+
+
+ ) : openClawAuthMode === "device" ? ( +
+
+
+ OpenClaw device identity +
+
+ {openClawPaired + ? "Device identity is paired." + : "Device identity is not paired."} +
+
+
+ +
+ + +
+
+
+ ) : ( +
+ No OpenClaw gateway secret is required for this auth mode. +
+ )} + {openClawCredentialError ? ( +
+ {openClawCredentialError} +
+ ) : null} + {openClawCredentialStatus ? ( +
+ {openClawCredentialStatus} +
+ ) : null} + + ) : null} {providerSettings.homePathKey ? (