From e295c2334e50c9cc3e5aac6fc8f0feb7be9db145 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 17:55:55 +0900 Subject: [PATCH 1/4] fix(commandcode): bind restore requests to the selected client --- .../src/content/docs/guides/integrations.md | 5 + .../content/docs/ko/guides/integrations.md | 5 + src/cli/integration-preview.ts | 8 +- src/cli/integrations.ts | 11 +- src/server/management/integration-routes.ts | 18 +++ structure/clients/integrations.md | 1 + structure/gui-and-management-api.md | 2 +- tests/cli/cli-integration-preview.test.ts | 35 +++++- .../management-integration-routes.test.ts | 117 +++++++++++++++++- 9 files changed, 193 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 40357f7717e..b0ddcf19b29 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -775,6 +775,11 @@ ocx integration client enable --client commandcode ocx export --client commandcode --out ./providers.json ``` +`ocx commandcode restore --op ` accepts only Command Code operations, including +`--preview` and `--plan-fingerprint` requests. It rejects client/profile overrides. +Use `ocx integration client restore` for the generic journal and the explicit Aside +profile commands above for Aside operations. + ## Factory Droid Run Droid once to create `~/.factory`, then explicitly enable this integration with diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index bb738f1d0fd..2dec872ba1b 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -130,6 +130,11 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +`ocx commandcode restore --op `는 Command Code 작업만 복원합니다. +`--preview`와 `--plan-fingerprint`에도 같은 조건이 적용되며, 클라이언트나 +프로필을 바꾸는 옵션은 받지 않습니다. 일반 작업 기록은 위의 +`ocx integration client restore`를, Aside 작업은 아래의 프로필 전용 명령을 사용하세요. + `--overwrite-conflict`는 **Replace**의 터미널 명령입니다. ```bash diff --git a/src/cli/integration-preview.ts b/src/cli/integration-preview.ts index 1fcb52a66b5..182deff32d3 100644 --- a/src/cli/integration-preview.ts +++ b/src/cli/integration-preview.ts @@ -1,5 +1,6 @@ /** Preview and explicitly bound mutations use the existing management authority. */ import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../integrations/mutation-plan"; +import type { IntegrationClientId } from "../integrations/registry"; import { redactSecretString, redactUserPath } from "../lib/redact"; import { CliUsageError, RuntimeApiError, printData, runtimeRequest, terminalSafeText, @@ -186,9 +187,14 @@ function reportError(error: unknown): number { return 1; } -export async function handleIntegrationPreviewCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { +export async function handleIntegrationPreviewCommand(argv: string[], deps: RuntimeApiDeps = {}, expectedClientId?: IntegrationClientId): Promise { try { const intent = parseIntent(argv); + if (expectedClientId !== undefined && intent.operation === "restore") { + if (intent.client !== undefined || intent.profile !== undefined) throw new CliUsageError("client-specific restore does not accept --client or --profile", USAGE); + intent.client = expectedClientId; + intent.body.expectedClientId = expectedClientId; + } const result = await runtimeRequest(intent.path, { method: intent.preview || intent.operation === "restore" ? "POST" : "PUT", redirect: "error", body: JSON.stringify(intent.body), diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index b373e93e4bc..bc1efcc851e 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -13,6 +13,7 @@ import { type RuntimeApiDeps, } from "./runtime-api"; import { clientIntegrationPath, validateAsideProfile } from "./integration-input"; +import type { IntegrationClientId } from "../integrations/registry"; const CLAUDE_USAGE = `Usage: ocx claude config [status] [--json] @@ -215,7 +216,11 @@ function singleClientStatusLines(result: unknown): string[] { export async function handleClientIntegrationCommand( argv: string[], deps: RuntimeApiDeps = {}, + expectedClientId?: IntegrationClientId, ): Promise { + if (expectedClientId !== undefined && argv.some(arg => ["--client", "--profile"].some(flag => arg === flag || arg.startsWith(`${flag}=`)))) { + return runCliAction(async () => { throw new CliUsageError("client-specific restore does not accept --client or --profile", CLIENT_USAGE); }); + } if ((argv[0] === "history" || argv[0] === "journal") && argv[1] === "remove") { const { handleIntegrationJournalRemove } = await import("./integration-journal"); return handleIntegrationJournalRemove(argv.slice(2), deps); @@ -227,7 +232,7 @@ export async function handleClientIntegrationCommand( if (argv[0] === "preview" || argv.some(arg => ["--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults"] .some(flag => arg === flag || arg.startsWith(`${flag}=`)))) { const { handleIntegrationPreviewCommand } = await import("./integration-preview"); - return handleIntegrationPreviewCommand(argv, deps); + return handleIntegrationPreviewCommand(argv, deps, expectedClientId); } return runCliAction(async () => { const args = [...argv]; @@ -291,7 +296,7 @@ export async function handleClientIntegrationCommand( if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); const result = await runtimeRequest(profile === undefined ? "/api/client-integrations/restore" : `${clientIntegrationPath("aside", profile)}/restore`, { method: "POST", - body: JSON.stringify({ opId, confirmDrift }), + body: JSON.stringify({ opId, confirmDrift, ...(expectedClientId === undefined ? {} : { expectedClientId }) }), }, deps); printData(result, wantsJson, [String((result as Record).message ?? "Restored.")]); return; @@ -402,7 +407,7 @@ export async function handleCommandcodeCommand(argv: string[], deps: RuntimeApiD } const rest = verbIndex === -1 ? args : [...args.slice(0, verbIndex), ...args.slice(verbIndex + 1)]; const forwarded = action === "restore" ? [action, ...rest] : [action, ...rest, "--client", "commandcode"]; - const code = await handleClientIntegrationCommand(forwarded, deps); + const code = await handleClientIntegrationCommand(forwarded, deps, action === "restore" ? "commandcode" : undefined); if (code === 0 && (action === "enable" || action === "disable")) { console.error("Command Code reads providers on startup."); } diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 2eece64a5f9..cb28b843191 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -318,6 +318,20 @@ function invalidClientResponse(ctx: ManagementContext): Response { }, 400, ctx.req, ctx.config); } +/** Bind an explicitly scoped restore before profile delegation or mutation. */ +function restoreClientScopeResponse(ctx: ManagementContext, opId: string, expectedClientId: unknown): Response | null { + if (expectedClientId === undefined) return null; + if (typeof expectedClientId !== "string" || !isIntegrationClientId(expectedClientId)) return invalidClientResponse(ctx); + try { + const operation = integrationStore().findOperation(opId); + if (!operation) return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId }, 404, ctx.req, ctx.config); + if (operation.clientId !== expectedClientId) return jsonResponse({ error: "integration operation belongs to a different client", code: "integration_client_mismatch" }, 409, ctx.req, ctx.config); + return null; + } catch (error) { + return internalErrorResponse(error, ctx); + } +} + function isStringRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every(item => typeof item === "string"); @@ -786,6 +800,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise`, or `http://127.0.0.1:` when `hub.managementIngress.enabled` is true — see [the hub management dashboard address](runtime.md#hub-management-dashboard-address). diff --git a/tests/cli/cli-integration-preview.test.ts b/tests/cli/cli-integration-preview.test.ts index d74b1f02b1e..1e4489b45a4 100644 --- a/tests/cli/cli-integration-preview.test.ts +++ b/tests/cli/cli-integration-preview.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { handleClientIntegrationCommand as command } from "../../src/cli/integrations"; +import { handleClientIntegrationCommand as command, handleCommandcodeCommand } from "../../src/cli/integrations"; import { handleIntegrationPreviewCommand as leaf } from "../../src/cli/integration-preview"; import { decodeIntegrationPlan, FILE_INTEGRATION_CLIENTS } from "../../src/cli/integration-plan-dto"; import { parseIntegrationMutationPlan as guiDecode } from "../../gui/src/pages/integrations/integration-api"; @@ -37,13 +37,13 @@ beforeEach(() => { network = spyOn(globalThis, "fetch").mockImplementation(async () => { throw new Error("Network denied"); }); }); afterEach(() => { network.mockRestore(); out.mockRestore(); err.mockRestore(); home.remove(); }); -function fixture(response: unknown = plan, status = 200) { +function fixture(response: unknown = plan, status = 200, expectRedirectGuard = true) { const calls: Array<{ path: string; body: unknown; method?: string }> = []; let probes = 0; const deps: RuntimeApiDeps = { findLiveProxy: async () => { probes++; return { port: 10100, hostname: "127.0.0.1", pid: 1, source: "runtime" }; }, fetchImpl: (async (input, init) => { - expect(init?.redirect).toBe("error"); + if (expectRedirectGuard) expect(init?.redirect).toBe("error"); calls.push({ path: new URL(String(input)).pathname, method: init?.method, body: JSON.parse(String(init?.body)) }); return Response.json(response, { status }); }) as typeof fetch, @@ -54,6 +54,35 @@ function result() { expect(out.mock.calls.length).toBe(1); return JSON.parse(out function clearOutput() { out.mockClear(); err.mockClear(); } const previewArgs = ["preview", "--client", "pi", "--operation", "apply"]; +describe("commandcode restore owner scope", () => { + test.each(["plain", "preview", "bound"])("fixed commandcode identity reaches the restore request: %s", async mode => { + const preview = mode === "preview"; + const f = fixture(preview ? { ...plan, clientId: "commandcode", operation: "restore" } + : { ok: true, clientId: "commandcode", state: "absent", changed: true }, 200, mode !== "plain"); + const flags = preview ? ["--preview"] : mode === "bound" ? ["--plan-fingerprint", token] : []; + expect(await handleCommandcodeCommand(["restore", "--op", "op-one", "--confirm-drift", ...flags, "--json"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ path: `/api/client-integrations/restore${preview ? "/preview" : ""}`, method: "POST", + body: { opId: "op-one", confirmDrift: true, expectedClientId: "commandcode", + ...(mode === "bound" ? { operation: "restore", planFingerprint: token } : {}) } }]); + }); + test.each(["plain", "preview", "bound"])("generic restore omits the wrapper identity: %s", async mode => { + const preview = mode === "preview"; + const f = fixture(preview ? { ...plan, operation: "restore" } : { ok: true, clientId: "pi", state: "absent", changed: true }, 200, mode !== "plain"); + const flags = preview ? ["--preview"] : mode === "bound" ? ["--plan-fingerprint", token] : []; + expect(await command(["restore", "--op", "op-one", ...flags, "--json"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ path: `/api/client-integrations/restore${preview ? "/preview" : ""}`, method: "POST", + body: { opId: "op-one", confirmDrift: false, ...(mode === "bound" ? { operation: "restore", planFingerprint: token } : {}) } }]); + }); + for (const flags of [[], ["--preview"], ["--plan-fingerprint", token]]) { + test.each([["--client", "aside", "--profile", "1"], ["--client=aside", "--profile=1"], ["--client", "hermes"], ["--profile", "1"]]) + (`wrapper scope cannot be overridden ${flags.join(" ")}: %j`, async (...selectors) => { + const f = fixture({ ...plan, clientId: "aside", profileId: 1, operation: "restore" }); + expect(await handleCommandcodeCommand(["restore", "--op", "op-one", ...flags, ...selectors, "--json"], f.deps)).toBe(2); + expect(f.probes()).toBe(0); expect(f.calls).toEqual([]); + }); + } +}); + describe("preview wire and failure contracts", () => { test("one closed JSON plan, no-op and refused unbound inspection", async () => { const f = fixture(); expect(await command([...previewArgs, "--json"], f.deps)).toBe(0); diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 23d3c4b2ab0..b7b31073637 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -11,6 +11,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../s import { applyIntegration } from "../../src/integrations/writer"; import type { IntegrationWriterLockSeams } from "../../src/integrations/writer-lock"; import { handleManagementAPI } from "../../src/server/management-api"; +import * as asideRoutes from "../../src/server/management/aside-profile-routes"; import { loadExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; import { setIntegrationMutationFlightTestHooks, @@ -791,6 +792,120 @@ describe("refusals", () => { }); }); +describe("restore expected-client boundary", () => { + const endpoints = ["/api/client-integrations/restore", "/api/client-integrations/restore/preview"]; + beforeEach(async () => { resetExportSnapshotForTests(); await loadExportModels(config, []); }); + afterEach(() => { resetExportSnapshotForTests(); }); + + function witness() { + return { home: storeContentWitness(home), store: storeContentWitness(storeRoot), + records: store.readRecords(), journal: store.listOperations(), + snapshots: store.listOperations().map(entry => store.readSnapshot(entry)) }; + } + + for (const endpoint of endpoints) { + test.each([null, 7, false, {}, [], "", "unknown", " commandcode "].map(expectedClientId => ({ expectedClientId })))(`invalid expectedClientId is rejected before lookup at ${endpoint}: %j`, async ({ expectedClientId }) => { + const before = witness(); + const response = await previewApi(endpoint, { opId: "missing", expectedClientId }); + expect({ status: response.status, code: (await response.json() as { code: string }).code, after: witness() }) + .toEqual({ status: 400, code: "invalid_integration_client", after: before }); + }); + + test(`unknown scoped operation stays not found at ${endpoint}`, async () => { + const before = witness(); + const delegate = spyOn(asideRoutes, "asideRestoreResponse"); + try { + const response = await previewApi(endpoint, { opId: "missing", expectedClientId: "commandcode" }); + expect({ status: response.status, code: (await response.json() as { code: string }).code, + delegates: delegate.mock.calls.length, after: witness() }) + .toEqual({ status: 404, code: "integration_operation_not_found", delegates: 0, after: before }); + } finally { delegate.mockRestore(); } + }); + + test.each(["plain", "confirmed drift", "valid fingerprint"])(`foreign restore preserves config, ownership, snapshots and journal at ${endpoint}: %s`, async mode => { + const path = installHermes(); + writeFileSync(path, "providers: {}\n# original\n"); + expect((await put("hermes", true)).status).toBe(200); + const opId = store.listOperations("hermes")[0]!.opId; + const confirmDrift = mode !== "plain"; + if (confirmDrift) writeFileSync(path, `${readFileSync(path, "utf8")}# foreign edit\n`); + const body: Record = { opId, confirmDrift, expectedClientId: "commandcode" }; + if (mode === "valid fingerprint") { + const preview = await previewApi(endpoints[1]!, { opId, confirmDrift }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + body.operation = "restore"; body.planFingerprint = plan.fingerprint; + } + const before = witness(); + const delegate = spyOn(asideRoutes, "asideRestoreResponse"); + try { + const response = await previewApi(endpoint, body); + expect({ status: response.status, code: (await response.json() as { code: string }).code, + delegates: delegate.mock.calls.length, after: witness() }) + .toEqual({ status: 409, code: "integration_client_mismatch", delegates: 0, after: before }); + } finally { delegate.mockRestore(); } + }); + + test.each(["root", "profile-only"])(`Aside operation cannot escape the expected client at ${endpoint}: %s`, async location => { + mkdirSync(join(home, ".aside/u/1"), { recursive: true }); + writeFileSync(join(home, ".aside/accounts.json"), JSON.stringify({ currentAccountId: 1, accounts: [{ id: 1, name: "Fixture" }] })); + const path = join(home, ".aside/u/1/models.json"); + writeFileSync(path, '{"providers":{}}\n'); + const applied = await putOverwrite("aside/profiles/1", { enabled: true }); + expect(applied.status).toBe(200); + const profileStore = createIntegrationStateStore(join(storeRoot, "aside-profiles/1")); + const operation = profileStore.listOperations("aside")[0]!; + expect(operation).toBeDefined(); + expect(store.findOperation(operation.opId)).toBeNull(); + if (location === "root") store.appendJournal(operation); + const before = witness(); + const delegate = spyOn(asideRoutes, "asideRestoreResponse"); + try { + const response = await previewApi(endpoint, { opId: operation.opId, expectedClientId: "commandcode", confirmDrift: true }); + expect({ status: response.status, code: (await response.json() as { code: string }).code, + delegates: delegate.mock.calls.length, after: witness() }) + .toEqual({ status: location === "root" ? 409 : 404, + code: location === "root" ? "integration_client_mismatch" : "integration_operation_not_found", delegates: 0, after: before }); + } finally { delegate.mockRestore(); } + }); + } + + test.each(["plain", "bound"])("same-client commandcode restore succeeds: %s", async mode => { + const spec = INTEGRATION_CLIENTS.commandcode; + mkdirSync(spec.detectDir(routeEnv, home), { recursive: true }); + const path = spec.configPath(routeEnv, home); + const original = '{"providers":{}}\n'; + writeFileSync(path, original); + expect((await put("commandcode", true)).status).toBe(200); + const opId = store.listOperations("commandcode")[0]!.opId; + const body: Record = { opId, expectedClientId: "commandcode" }; + const before = witness(); + const preview = await previewApi(endpoints[1]!, body); + expect(preview.status).toBe(200); + const plan = await preview.json() as { clientId: string; canApply: boolean; fingerprint: string }; + expect(plan.clientId).toBe("commandcode"); expect(plan.canApply).toBe(true); + expect(witness()).toEqual(before); + if (mode === "bound") { body.operation = "restore"; body.planFingerprint = plan.fingerprint; } + const response = await restore(body); + expect(response.status).toBe(200); + expect(readFileSync(path, "utf8")).toBe(original); + expect(store.listOperations("commandcode")[0]!.kind).toBe("restore"); + }); + + test("generic unscoped preview and restore retain foreign-client compatibility", async () => { + const path = installHermes(); + writeFileSync(path, "providers: {}\n"); + expect((await put("hermes", true)).status).toBe(200); + const opId = store.listOperations("hermes")[0]!.opId; + const preview = await previewApi(endpoints[1]!, { opId }); + expect(preview.status).toBe(200); + expect((await preview.json() as { clientId: string }).clientId).toBe("hermes"); + expect((await restore({ opId })).status).toBe(200); + expect(readFileSync(path, "utf8")).toBe("providers: {}\n"); + }); +}); + describe("POST /api/client-integrations/restore", () => { test("requires explicit drift confirmation and preserves current bytes before overwrite", async () => { const configPath = installHermes(); From 806aeb8200c08693167c2a8d9c0cba8ec0f4d0a3 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 18:09:37 +0900 Subject: [PATCH 2/4] fix(commandcode): refuse unscoped restore on legacy proxies --- .../src/content/docs/guides/integrations.md | 2 + .../content/docs/ko/guides/integrations.md | 3 +- src/cli/integration-preview.ts | 1 + src/cli/integrations.ts | 5 ++- src/server/management/integration-routes.ts | 15 +++++--- src/server/management/route-registry.ts | 2 + structure/clients/integrations.md | 2 +- tests/cli/cli-integration-preview.test.ts | 35 ++++++++++++++++- .../management-integration-routes.test.ts | 38 +++++++++++++++---- 9 files changed, 85 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index b0ddcf19b29..8234a6fdc92 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -777,6 +777,8 @@ ocx export --client commandcode --out ./providers.json `ocx commandcode restore --op ` accepts only Command Code operations, including `--preview` and `--plan-fingerprint` requests. It rejects client/profile overrides. +The running proxy must support client-scoped restore; older proxies return an error +without falling back to the generic restore endpoint. Use `ocx integration client restore` for the generic journal and the explicit Aside profile commands above for Aside operations. diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index 2dec872ba1b..69ef5b6646e 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -132,7 +132,8 @@ ocx integration client restore --op [--confirm-drift] `ocx commandcode restore --op `는 Command Code 작업만 복원합니다. `--preview`와 `--plan-fingerprint`에도 같은 조건이 적용되며, 클라이언트나 -프로필을 바꾸는 옵션은 받지 않습니다. 일반 작업 기록은 위의 +프로필을 바꾸는 옵션은 받지 않습니다. 실행 중인 프록시가 이 전용 복원 경로를 +지원하지 않으면 일반 복원으로 재시도하지 않고 오류를 반환합니다. 일반 작업 기록은 위의 `ocx integration client restore`를, Aside 작업은 아래의 프로필 전용 명령을 사용하세요. `--overwrite-conflict`는 **Replace**의 터미널 명령입니다. diff --git a/src/cli/integration-preview.ts b/src/cli/integration-preview.ts index 182deff32d3..f5037eb4862 100644 --- a/src/cli/integration-preview.ts +++ b/src/cli/integration-preview.ts @@ -193,6 +193,7 @@ export async function handleIntegrationPreviewCommand(argv: string[], deps: Runt if (expectedClientId !== undefined && intent.operation === "restore") { if (intent.client !== undefined || intent.profile !== undefined) throw new CliUsageError("client-specific restore does not accept --client or --profile", USAGE); intent.client = expectedClientId; + intent.path = `${clientIntegrationPath(expectedClientId)}/restore${intent.preview ? "/preview" : ""}`; intent.body.expectedClientId = expectedClientId; } const result = await runtimeRequest(intent.path, { diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index bc1efcc851e..016e50003ac 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -294,8 +294,11 @@ export async function handleClientIntegrationCommand( if (client !== undefined && profile === undefined) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); - const result = await runtimeRequest(profile === undefined ? "/api/client-integrations/restore" : `${clientIntegrationPath("aside", profile)}/restore`, { + const restorePath = expectedClientId !== undefined ? `${clientIntegrationPath(expectedClientId)}/restore` + : profile === undefined ? "/api/client-integrations/restore" : `${clientIntegrationPath("aside", profile)}/restore`; + const result = await runtimeRequest(restorePath, { method: "POST", + ...(expectedClientId === undefined ? {} : { redirect: "error" as const }), body: JSON.stringify({ opId, confirmDrift, ...(expectedClientId === undefined ? {} : { expectedClientId }) }), }, deps); printData(result, wantsJson, [String((result as Record).message ?? "Restored.")]); diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index cb28b843191..02908a8ca62 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -319,7 +319,12 @@ function invalidClientResponse(ctx: ManagementContext): Response { } /** Bind an explicitly scoped restore before profile delegation or mutation. */ -function restoreClientScopeResponse(ctx: ManagementContext, opId: string, expectedClientId: unknown): Response | null { +function restoreClientScopeResponse(ctx: ManagementContext, opId: string, expectedClientId: unknown, pathClientId?: IntegrationClientId): Response | null { + if (pathClientId !== undefined) { + if (expectedClientId !== undefined && (typeof expectedClientId !== "string" || !isIntegrationClientId(expectedClientId))) return invalidClientResponse(ctx); + if (expectedClientId !== undefined && expectedClientId !== pathClientId) return jsonResponse({ error: "restore identity does not match the client path", code: "integration_client_mismatch" }, 409, ctx.req, ctx.config); + expectedClientId = pathClientId; + } if (expectedClientId === undefined) return null; if (typeof expectedClientId !== "string" || !isIntegrationClientId(expectedClientId)) return invalidClientResponse(ctx); try { @@ -789,7 +794,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { test.each(["plain", "preview", "bound"])("fixed commandcode identity reaches the restore request: %s", async mode => { const preview = mode === "preview"; const f = fixture(preview ? { ...plan, clientId: "commandcode", operation: "restore" } - : { ok: true, clientId: "commandcode", state: "absent", changed: true }, 200, mode !== "plain"); + : { ok: true, clientId: "commandcode", state: "absent", changed: true }); const flags = preview ? ["--preview"] : mode === "bound" ? ["--plan-fingerprint", token] : []; expect(await handleCommandcodeCommand(["restore", "--op", "op-one", "--confirm-drift", ...flags, "--json"], f.deps)).toBe(0); - expect(f.calls).toEqual([{ path: `/api/client-integrations/restore${preview ? "/preview" : ""}`, method: "POST", + expect(f.calls).toEqual([{ path: `/api/client-integrations/commandcode/restore${preview ? "/preview" : ""}`, method: "POST", body: { opId: "op-one", confirmDrift: true, expectedClientId: "commandcode", ...(mode === "bound" ? { operation: "restore", planFingerprint: token } : {}) } }]); }); @@ -73,6 +73,37 @@ describe("commandcode restore owner scope", () => { expect(f.calls).toEqual([{ path: `/api/client-integrations/restore${preview ? "/preview" : ""}`, method: "POST", body: { opId: "op-one", confirmDrift: false, ...(mode === "bound" ? { operation: "restore", planFingerprint: token } : {}) } }]); }); + test.each(["plain", "preview", "bound"])("old proxy rejects the scoped path without a generic fallback: %s", async mode => { + const preview = mode === "preview"; + const calls: string[] = []; + let mutations = 0; + // Model the legacy server's closed pathname dispatch and ignored JSON identity field. + // A generic restore succeeds and mutates even when expectedClientId is supplied. + const legacyFetch = (async (input, init) => { + const path = new URL(String(input)).pathname; + calls.push(path); + if (path === "/api/client-integrations/restore") { + mutations++; + return Response.json({ ok: true, clientId: "hermes", state: "absent", changed: true }); + } + if (path === "/api/client-integrations/restore/preview") { + return Response.json({ ...plan, clientId: "hermes", operation: "restore" }); + } + return Response.json({ error: "Not found" }, { status: 404 }); + }) as typeof fetch; + const probe = await legacyFetch("http://127.0.0.1:10100/api/client-integrations/restore", { + method: "POST", body: JSON.stringify({ opId: "foreign-op", expectedClientId: "commandcode" }), + }); + expect(probe.status).toBe(200); expect(mutations).toBe(1); + calls.length = 0; mutations = 0; + const flags = preview ? ["--preview"] : mode === "bound" ? ["--plan-fingerprint", token] : []; + const exit = await handleCommandcodeCommand(["restore", "--op", "foreign-op", ...flags, "--json"], { + baseUrl: "http://127.0.0.1:10100", fetchImpl: legacyFetch, + }); + expect({ exit, calls, mutations, stdout: out.mock.calls }).toEqual({ + exit: 4, calls: [`/api/client-integrations/commandcode/restore${preview ? "/preview" : ""}`], mutations: 0, stdout: [], + }); + }); for (const flags of [[], ["--preview"], ["--plan-fingerprint", token]]) { test.each([["--client", "aside", "--profile", "1"], ["--client=aside", "--profile=1"], ["--client", "hermes"], ["--profile", "1"]]) (`wrapper scope cannot be overridden ${flags.join(" ")}: %j`, async (...selectors) => { diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index b7b31073637..fbe8f3bcd92 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -793,7 +793,8 @@ describe("refusals", () => { }); describe("restore expected-client boundary", () => { - const endpoints = ["/api/client-integrations/restore", "/api/client-integrations/restore/preview"]; + const endpoints = ["/api/client-integrations/restore", "/api/client-integrations/restore/preview", + "/api/client-integrations/commandcode/restore", "/api/client-integrations/commandcode/restore/preview"]; beforeEach(async () => { resetExportSnapshotForTests(); await loadExportModels(config, []); }); afterEach(() => { resetExportSnapshotForTests(); }); @@ -804,6 +805,8 @@ describe("restore expected-client boundary", () => { } for (const endpoint of endpoints) { + // Dedicated paths must enforce the client even when older/custom callers omit the body field. + const identity = endpoint.includes("/commandcode/") ? {} : { expectedClientId: "commandcode" }; test.each([null, 7, false, {}, [], "", "unknown", " commandcode "].map(expectedClientId => ({ expectedClientId })))(`invalid expectedClientId is rejected before lookup at ${endpoint}: %j`, async ({ expectedClientId }) => { const before = witness(); const response = await previewApi(endpoint, { opId: "missing", expectedClientId }); @@ -815,7 +818,7 @@ describe("restore expected-client boundary", () => { const before = witness(); const delegate = spyOn(asideRoutes, "asideRestoreResponse"); try { - const response = await previewApi(endpoint, { opId: "missing", expectedClientId: "commandcode" }); + const response = await previewApi(endpoint, { opId: "missing", ...identity }); expect({ status: response.status, code: (await response.json() as { code: string }).code, delegates: delegate.mock.calls.length, after: witness() }) .toEqual({ status: 404, code: "integration_operation_not_found", delegates: 0, after: before }); @@ -829,7 +832,7 @@ describe("restore expected-client boundary", () => { const opId = store.listOperations("hermes")[0]!.opId; const confirmDrift = mode !== "plain"; if (confirmDrift) writeFileSync(path, `${readFileSync(path, "utf8")}# foreign edit\n`); - const body: Record = { opId, confirmDrift, expectedClientId: "commandcode" }; + const body: Record = { opId, confirmDrift, ...identity }; if (mode === "valid fingerprint") { const preview = await previewApi(endpoints[1]!, { opId, confirmDrift }); expect(preview.status).toBe(200); @@ -862,7 +865,7 @@ describe("restore expected-client boundary", () => { const before = witness(); const delegate = spyOn(asideRoutes, "asideRestoreResponse"); try { - const response = await previewApi(endpoint, { opId: operation.opId, expectedClientId: "commandcode", confirmDrift: true }); + const response = await previewApi(endpoint, { opId: operation.opId, ...identity, confirmDrift: true }); expect({ status: response.status, code: (await response.json() as { code: string }).code, delegates: delegate.mock.calls.length, after: witness() }) .toEqual({ status: location === "root" ? 409 : 404, @@ -871,7 +874,25 @@ describe("restore expected-client boundary", () => { }); } - test.each(["plain", "bound"])("same-client commandcode restore succeeds: %s", async mode => { + for (const endpoint of endpoints.slice(2)) { + test(`conflicting body identity cannot replace the path owner at ${endpoint}`, async () => { + const path = installHermes(); + writeFileSync(path, "providers: {}\n"); + expect((await put("hermes", true)).status).toBe(200); + const opId = store.listOperations("hermes")[0]!.opId; + const before = witness(); + const delegate = spyOn(asideRoutes, "asideRestoreResponse"); + try { + const response = await previewApi(endpoint, { opId, expectedClientId: "hermes", confirmDrift: true }); + expect({ status: response.status, code: (await response.json() as { code: string }).code, + delegates: delegate.mock.calls.length, after: witness() }) + .toEqual({ status: 409, code: "integration_client_mismatch", delegates: 0, after: before }); + } finally { delegate.mockRestore(); } + }); + } + + for (const scopedPath of [false, true]) { + test.each(["plain", "bound"])(`same-client commandcode restore succeeds (scoped path ${scopedPath}): %s`, async mode => { const spec = INTEGRATION_CLIENTS.commandcode; mkdirSync(spec.detectDir(routeEnv, home), { recursive: true }); const path = spec.configPath(routeEnv, home); @@ -879,19 +900,20 @@ describe("restore expected-client boundary", () => { writeFileSync(path, original); expect((await put("commandcode", true)).status).toBe(200); const opId = store.listOperations("commandcode")[0]!.opId; - const body: Record = { opId, expectedClientId: "commandcode" }; + const body: Record = { opId, ...(scopedPath ? {} : { expectedClientId: "commandcode" }) }; const before = witness(); - const preview = await previewApi(endpoints[1]!, body); + const preview = await previewApi(endpoints[scopedPath ? 3 : 1]!, body); expect(preview.status).toBe(200); const plan = await preview.json() as { clientId: string; canApply: boolean; fingerprint: string }; expect(plan.clientId).toBe("commandcode"); expect(plan.canApply).toBe(true); expect(witness()).toEqual(before); if (mode === "bound") { body.operation = "restore"; body.planFingerprint = plan.fingerprint; } - const response = await restore(body); + const response = await previewApi(endpoints[scopedPath ? 2 : 0]!, body); expect(response.status).toBe(200); expect(readFileSync(path, "utf8")).toBe(original); expect(store.listOperations("commandcode")[0]!.kind).toBe("restore"); }); + } test("generic unscoped preview and restore retain foreign-client compatibility", async () => { const path = installHermes(); From f3ddd45c3d19bb3995d487c81ea209c7049b52b9 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 18:30:40 +0900 Subject: [PATCH 3/4] fix(cli): expose client-bound Command Code restore capability --- scripts/generate-ocx-skill-surface.ts | 2 +- .../ocx/references/01_management_surface.md | 10 +++++-- .../ocx/references/01_surface_integrations.md | 29 ++++++++++++++++++- src/cli/capabilities-integrations.ts | 16 ++++++++++ structure/cli-management.md | 2 ++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/scripts/generate-ocx-skill-surface.ts b/scripts/generate-ocx-skill-surface.ts index dee26200c61..e024ba8a4e9 100644 --- a/scripts/generate-ocx-skill-surface.ts +++ b/scripts/generate-ocx-skill-surface.ts @@ -20,7 +20,7 @@ const DOMAINS = [ { name: "providers-models", roots: ["provider", "models", "alias"] }, { name: "accounts", roots: ["account", "auth", "login", "logout"] }, { name: "agents-routing", roots: ["agent", "combo", "route", "v2", "effort", "memory", "message"] }, - { name: "integrations", roots: ["claude", "integration", "grok", "codex-shim"] }, + { name: "integrations", roots: ["claude", "integration", "commandcode", "grok", "codex-shim"] }, { name: "observe-system", roots: ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"] }, { name: "access-remote", roots: ["link", "remote-workspace", "hub", "connect", "api", "access"] }, { name: "lab", roots: ["lab"] }, diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index df2f4705c7d..a7ee4e51093 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -29,7 +29,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [providers-models](01_surface_providers-models.md) | 47 | | [accounts](01_surface_accounts.md) | 40 | | [agents-routing](01_surface_agents-routing.md) | 50 | -| [integrations](01_surface_integrations.md) | 40 | +| [integrations](01_surface_integrations.md) | 41 | | [observe-system](01_surface_observe-system.md) | 92 | | [access-remote](01_surface_access-remote.md) | 28 | | [lab](01_surface_lab.md) | 21 | @@ -827,6 +827,10 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_integrations.md#ocx-integration-client-restore) +### `ocx commandcode restore` + +[State-changing task](01_surface_integrations.md#ocx-commandcode-restore) + ### `ocx claude config status` [Read-oriented task](01_surface_integrations.md#ocx-claude-config-status) @@ -1369,6 +1373,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 330 -- of those, state-changing: 199 +- declared capabilities: 331 +- of those, state-changing: 200 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_integrations.md b/skills/ocx/references/01_surface_integrations.md index b8688d3a5c0..dce2495042c 100644 --- a/skills/ocx/references/01_surface_integrations.md +++ b/skills/ocx/references/01_surface_integrations.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 40. +Declared capabilities: 41. ### `ocx claude config` @@ -517,6 +517,33 @@ JSON mode: `payload`. - Preview preserves exact opId/profile/confirm-drift intent. Generic restore derives the client on the server; the returned plan does not embed opId or complete command input. - A refused or no-op preview is completed inspection, not applied work. A stale bound mutation exits5 with a fixed re-preview hint; no replacement token is automatically adopted. +### `ocx commandcode restore` + +Usage: `ocx commandcode restore --op [--confirm-drift] [--preview | --plan-fingerprint ] [--json]` + +Restore a Command Code rollback operation, retaining client ownership and drift checks. + +State-changing: yes. + +| Method | Route | +|---|---| +| POST | `/api/client-integrations/commandcode/restore` | +| POST | `/api/client-integrations/commandcode/restore/preview` | + +| Flag | Value | Meaning | +|---|---|---| +| `--op` | string | Command Code operation ID; --op-id is an alias. | +| `--confirm-drift` | boolean | Explicitly allow replacing edits made after the snapshot. | +| `--preview` | boolean | Inspect restoration without mutating; exclusive with a commit fingerprint. | +| `--plan-fingerprint` | string | Optional versioned token from a matching preview; stale intent is refused without retry. | +| `--json` | boolean | Emit the result as JSON. | + +JSON mode: `payload`. + +- cmd restore is an alias. --client and --profile are rejected; the dedicated route binds the operation to Command Code. +- Older proxies without the dedicated route fail without a generic restore fallback. Redirects are refused. +- Preview preserves the operation and drift intent. A stale bound mutation requires a new explicit preview. + ### `ocx claude config status` Usage: `ocx claude config status [--json]` diff --git a/src/cli/capabilities-integrations.ts b/src/cli/capabilities-integrations.ts index f1969293be3..ba27f091265 100644 --- a/src/cli/capabilities-integrations.ts +++ b/src/cli/capabilities-integrations.ts @@ -111,6 +111,22 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["With --client, restore requires --profile; only Aside profiles accept that selector. No automatic drift confirmation or retry.","Preview preserves exact opId/profile/confirm-drift intent. Generic restore derives the client on the server; the returned plan does not embed opId or complete command input.","A refused or no-op preview is completed inspection, not applied work. A stale bound mutation exits5 with a fixed re-preview hint; no replacement token is automatically adopted."], }, + { + command: ["commandcode", "restore"], + usage: "ocx commandcode restore --op [--confirm-drift] [--preview | --plan-fingerprint ] [--json]", + summary: "Restore a Command Code rollback operation, retaining client ownership and drift checks.", + routes: [{ method: "POST", path: "/api/client-integrations/commandcode/restore" }, { method: "POST", path: "/api/client-integrations/commandcode/restore/preview" }], + flags: [ + { name: "--op", value: "string", summary: "Command Code operation ID; --op-id is an alias.", required: true }, + { name: "--confirm-drift", value: "boolean", summary: "Explicitly allow replacing edits made after the snapshot." }, + { name: "--preview", value: "boolean", summary: "Inspect restoration without mutating; exclusive with a commit fingerprint." }, + { name: "--plan-fingerprint", value: "string", summary: "Optional versioned token from a matching preview; stale intent is refused without retry." }, + { name: "--json", value: "boolean", summary: "Emit the result as JSON." }, + ], + mutates: true, + json: "payload", + details: ["cmd restore is an alias. --client and --profile are rejected; the dedicated route binds the operation to Command Code.", "Older proxies without the dedicated route fail without a generic restore fallback. Redirects are refused.", "Preview preserves the operation and drift intent. A stale bound mutation requires a new explicit preview."], + }, { command: ["claude", "config", "status"], usage: "ocx claude config status [--json]", diff --git a/structure/cli-management.md b/structure/cli-management.md index 88477270ea6..86b37728cd0 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -72,6 +72,8 @@ Management route declarations describe actual HTTP calls only; local Lab automat ## Integration inspection, recovery and maintenance +`src/cli/capabilities-integrations.ts` declares `commandcode restore` and its dedicated restore/preview routes separately from generic integration restore. Its generated operating reference preserves the client-bound command, supported flags, and refusal on older proxies without those routes. + `src/cli/integration-input.ts` shares pure profile paths/validation and owns exact optional Droid-map/fingerprint grammar. `src/cli/integration-preview.ts` handles explicit preview and new-option writes; `src/cli/integration-plan-dto.ts` validates value-free plans without runtime imports from GUI/planner/writer code. The original direct mutation bodies remain when new options are absent. Refused and no-op previews are completed observations; stale commits return a re-preview instruction without adopting a replacement token. The server owns coordinated binding and writes. `src/cli/integration-journal.ts` addresses exact global/Aside/profile history with an explicit confirmation. It reports retired records separately from incomplete snapshot cleanup. `src/cli/integration-aside-sync.ts` retains the existing attested helper and its original dependency object rather than selecting a different transport through synthesized baseUrl. Empty, malformed and partial outcomes remain distinct. New fixed runtime requests reject redirects; this is not a global legacy transport rewrite. From f61e177290b469a673bea5f0db32c3fef6901cea Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 18:34:36 +0900 Subject: [PATCH 4/4] test(cli): assign Command Code capability to integration chapter --- tests/ci-workflows/skill-ocx-generated.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index c4b257fffa9..65a184fbdfc 100644 --- a/tests/ci-workflows/skill-ocx-generated.test.ts +++ b/tests/ci-workflows/skill-ocx-generated.test.ts @@ -13,7 +13,7 @@ const OWNER = "GENERATED by scripts/generate-ocx-skill-surface.ts"; const GROUPS: Record = { lifecycle: ["chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"], "providers-models": ["provider", "models", "alias"], accounts: ["account", "auth", "login", "logout"], - "agents-routing": ["agent", "combo", "route", "v2", "effort", "memory", "message"], integrations: ["claude", "integration", "grok", "codex-shim"], + "agents-routing": ["agent", "combo", "route", "v2", "effort", "memory", "message"], integrations: ["claude", "integration", "commandcode", "grok", "codex-shim"], "observe-system": ["companion", "usage", "logs", "storage", "inspect", "system", "observe", "debug", "export", "import", "cost", "update", "config", "tray"], "access-remote": ["link", "remote-workspace", "hub", "connect", "api", "access"], lab: ["lab"], };