Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,11 @@ ocx integration client enable --client commandcode
ocx export --client commandcode --out ./providers.json
```

`ocx commandcode restore --op <opId>` 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
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/ko/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ ocx integration client history --client hermes
ocx integration client restore --op <opId> [--confirm-drift]
```

`ocx commandcode restore --op <opId>`는 Command Code 작업만 복원합니다.
`--preview`와 `--plan-fingerprint`에도 같은 조건이 적용되며, 클라이언트나
프로필을 바꾸는 옵션은 받지 않습니다. 일반 작업 기록은 위의
`ocx integration client restore`를, Aside 작업은 아래의 프로필 전용 명령을 사용하세요.

`--overwrite-conflict`는 **Replace**의 터미널 명령입니다.

```bash
Expand Down
8 changes: 7 additions & 1 deletion src/cli/integration-preview.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -186,9 +187,14 @@ function reportError(error: unknown): number {
return 1;
}

export async function handleIntegrationPreviewCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
export async function handleIntegrationPreviewCommand(argv: string[], deps: RuntimeApiDeps = {}, expectedClientId?: IntegrationClientId): Promise<number> {
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),
Expand Down
11 changes: 8 additions & 3 deletions src/cli/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -215,7 +216,11 @@ function singleClientStatusLines(result: unknown): string[] {
export async function handleClientIntegrationCommand(
argv: string[],
deps: RuntimeApiDeps = {},
expectedClientId?: IntegrationClientId,
): Promise<number> {
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);
Expand All @@ -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];
Expand Down Expand Up @@ -291,7 +296,7 @@ export async function handleClientIntegrationCommand(
if (!opId) throw new CliUsageError("--op <opId> 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 }) }),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, deps);
printData(result, wantsJson, [String((result as Record<string, unknown>).message ?? "Restored.")]);
return;
Expand Down Expand Up @@ -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.");
}
Expand Down
18 changes: 18 additions & 0 deletions src/server/management/integration-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& Object.values(value).every(item => typeof item === "string");
Expand Down Expand Up @@ -786,6 +800,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
return jsonResponse({ error: "confirmDrift must be a boolean", code: "invalid_confirm_drift" }, 400, req, ctx.config);
}
const opId = parsed.opId.trim();
const scopeResponse = restoreClientScopeResponse(ctx, opId, parsed.expectedClientId);
if (scopeResponse) return scopeResponse;
try {
const store = integrationStore();
const operation = store.findOperation(opId);
Expand Down Expand Up @@ -837,6 +853,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
}

const opId = parsed.opId.trim();
const scopeResponse = restoreClientScopeResponse(ctx, opId, parsed.expectedClientId);
if (scopeResponse) return scopeResponse;
const confirmDrift = parsed.confirmDrift ?? false;
const restoreBinding = planBindingOf(parsed);
if (restoreBinding === "half") return halfBoundResponse(ctx);
Expand Down
1 change: 1 addition & 0 deletions structure/clients/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ resolves `env.HOME ?? env.USERPROFILE` plus that path with no relocation variabl
`commandCodeProviderRoot` mirrors that against the target and writes under the existing root
(singular for fresh files); both roots are in `CLIENT_MANAGED_PATHS` so Disable removes either.
Uses keyless `apiKey: false`; no service-token is read. Contract: `tests/clients/command-code-client-contract.test.ts`.
`src/cli/integrations.ts` pins Command Code restore requests to `expectedClientId: commandcode`, including preview and fingerprint-bound calls through `src/cli/integration-preview.ts`. The management restore and restore-preview endpoints validate this optional identity against the journal before profile delegation or mutation; invalid identities return 400, absent root operations 404, and different clients 409. Generic calls without the field retain their existing restore semantics. Client-specific restore rejects client/profile selector overrides. `tests/server/management-integration-routes.test.ts` and `tests/cli/cli-integration-preview.test.ts` cover the binding and unchanged state on refusal.

## Cline paired files

Expand Down
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ The Factory Droid integration page edits the
is included in the existing preview and confirmation request, with editing
disabled while confirmation is open. Changing API target, client or profile discards drafts and confirmation state. The page reloads committed values after a
successful mutation or restore. No separate settings-save endpoint bypasses the
integration writer or its ownership checks.
integration writer or its ownership checks. Client-specific restore requests retain their [expected client identity](clients/integrations.md#command-code) through preview and commit; unscoped generic restore remains available.

Account refresh actions follow the [credential refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a held unreadable lock is distinct from one this process may release, and path-probe errors preserve the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts
the proxy when needed and opens `http://localhost:<port>`, or `http://127.0.0.1:<management port>` when `hub.managementIngress.enabled` is true — see [the hub management dashboard address](runtime.md#hub-management-dashboard-address).
Expand Down
35 changes: 32 additions & 3 deletions tests/cli/cli-integration-preview.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Loading
Loading