@bitkyc08/opencodex 2.68.0 → 2.69.0-preview.20260928
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +25 -17
- package/gui/dist/assets/App-cDPyCb_V.js +51 -0
- package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-H1IAB8mN.js} +1 -1
- package/gui/dist/assets/index-BF9QnKO_.css +1 -0
- package/gui/dist/assets/index-CI6YTp8z.js +86 -0
- package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-CutOlPu2.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/factory-droid.svg +8 -0
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +109 -11
- package/src/adapters/coding-agent/protocol.ts +106 -11
- package/src/adapters/coding-agent/turn.ts +23 -18
- package/src/adapters/google-errors.ts +16 -1
- package/src/adapters/google-http.ts +14 -0
- package/src/adapters/openai-chat/passthrough.ts +2 -0
- package/src/adapters/openai-chat/summary-budget.ts +72 -0
- package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
- package/src/adapters/openai-chat.ts +6 -9
- package/src/bridge/response-json.ts +3 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/desktop-system-proxy.ts +155 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
- package/src/claude/intercept/picker-ca.ts +283 -83
- package/src/claude/intercept/picker-models.ts +4 -1
- package/src/claude/intercept/picker-trust.ts +15 -2
- package/src/claude/intercept/runtime.ts +99 -32
- package/src/cli/account-api.ts +5 -2
- package/src/cli/account-extended.ts +47 -11
- package/src/cli/account-target.ts +12 -7
- package/src/cli/account.ts +47 -3
- package/src/cli/capabilities.ts +33 -7
- package/src/cli/claude-agent-startup-sync.ts +2 -0
- package/src/cli/claude-desktop.ts +37 -13
- package/src/cli/cross-home-owner.ts +138 -0
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +18 -8
- package/src/cli/ensure-desired-integrations.ts +5 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +15 -18
- package/src/cli/integrations.ts +8 -1
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +106 -4
- package/src/cli/models.ts +6 -1
- package/src/cli/opencode.ts +2 -83
- package/src/cli/registry.ts +3 -2
- package/src/cli/status.ts +86 -7
- package/src/client/connect.ts +24 -10
- package/src/client/link-ingress.ts +3 -1
- package/src/client/link-join.ts +76 -9
- package/src/client/link-relay-transport.ts +184 -0
- package/src/client/link-relay.ts +16 -4
- package/src/client/machine-listener.ts +2 -0
- package/src/clients/config-export/constants.ts +8 -0
- package/src/clients/config-export/contracts.ts +13 -1
- package/src/clients/config-export/droid.ts +71 -0
- package/src/clients/config-export/kilo.ts +116 -0
- package/src/clients/config-export.ts +33 -2
- package/src/codex/auth-api/routes.ts +1 -1
- package/src/codex/catalog/access-programs.ts +14 -2
- package/src/codex/catalog/provider-models.ts +1 -1
- package/src/codex/catalog-auto-refresh.ts +122 -0
- package/src/codex/config-drift-heal.ts +80 -0
- package/src/codex/desktop-switches.ts +7 -5
- package/src/codex/inject/bounded-config-reader.ts +69 -0
- package/src/codex/inject/config-toml.ts +31 -3
- package/src/codex/inject/routing-target.ts +6 -1
- package/src/codex/inject.ts +4 -0
- package/src/codex/model-entitlements.ts +32 -5
- package/src/codex/native-profile-startup.ts +17 -5
- package/src/codex/project-config-warnings.ts +72 -21
- package/src/codex/quota-auto-refresh-state.ts +8 -2
- package/src/codex/quota-auto-refresh.ts +80 -9
- package/src/codex/sync.ts +10 -0
- package/src/combos/failover.ts +4 -1
- package/src/combos/jev.ts +15 -4
- package/src/combos/types.ts +16 -0
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +29 -0
- package/src/config/macos-system-proxy.ts +114 -0
- package/src/config/proxy-env.ts +67 -20
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +23 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +220 -144
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +12 -3
- package/src/integrations/droid-settings.ts +65 -0
- package/src/integrations/kilo-candidates.ts +30 -0
- package/src/integrations/mutation-plan.ts +62 -7
- package/src/integrations/native/ownership-preflight.ts +46 -12
- package/src/integrations/registry.ts +136 -7
- package/src/integrations/state.ts +52 -4
- package/src/integrations/target.ts +3 -0
- package/src/integrations/writer.ts +55 -14
- package/src/lib/account-selection-events.ts +14 -0
- package/src/lib/jsonc.ts +110 -0
- package/src/lib/standalone.ts +9 -1
- package/src/lib/windows-system-proxy.ts +101 -1
- package/src/link/relay-auth.ts +29 -0
- package/src/link/ssh-argv.ts +6 -3
- package/src/link/ssh-runner.ts +5 -5
- package/src/oauth/generic-account-failover.ts +52 -8
- package/src/oauth/index.ts +37 -14
- package/src/oauth/store.ts +96 -11
- package/src/oauth/token-guardian.ts +7 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +5 -3
- package/src/providers/fastwire.ts +3 -0
- package/src/providers/key-failover.ts +13 -5
- package/src/providers/kiro-model-catalog.ts +4 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-tiers-destination.ts +9 -0
- package/src/providers/quota/account-cache.ts +17 -3
- package/src/providers/quota/vendor-probes-oauth.ts +2 -0
- package/src/providers/quota.ts +2 -2
- package/src/responses/code-mode-helper-compat.ts +14 -1
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/images.ts +70 -20
- package/src/server/index/link-listener.ts +20 -3
- package/src/server/index/link-relay-sessions.ts +138 -0
- package/src/server/index/optional-listeners.ts +11 -1
- package/src/server/index/serve-options.ts +12 -0
- package/src/server/inference/client-encoder-delivery.ts +1 -0
- package/src/server/local-management-read-client.ts +21 -2
- package/src/server/management/agent-settings-routes.ts +19 -5
- package/src/server/management/combo-routes.ts +5 -3
- package/src/server/management/config-routes.ts +32 -6
- package/src/server/management/model-routes.ts +232 -7
- package/src/server/management/model-rows.ts +199 -1
- package/src/server/management/oauth-account-routes.ts +63 -3
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/port-reclaim.ts +170 -32
- package/src/server/responses/adapter-delivery.ts +6 -4
- package/src/server/responses/adapter-dispatch.ts +72 -2
- package/src/server/responses/core-combo.ts +6 -1
- package/src/server/responses/core-normalize.ts +19 -4
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/memory-models.ts +169 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +10 -0
- package/src/server/responses/request-prepare.ts +86 -4
- package/src/server/responses/request-transport.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +5 -2
- package/src/server/responses/shadow-target-availability.ts +17 -4
- package/src/server/responses/sidecar-execution.ts +18 -2
- package/src/server/responses/skills-snapshot.ts +239 -0
- package/src/server/responses-undeclared-tool-guard.ts +43 -6
- package/src/server/search.ts +1 -1
- package/src/service/guards.ts +4 -5
- package/src/service/state.ts +39 -2
- package/src/service.ts +2 -2
- package/src/types/config.ts +46 -4
- package/src/types/provider.ts +9 -0
- package/src/types/request.ts +6 -0
- package/src/types/tools.ts +38 -8
- package/src/types.ts +3 -0
- package/src/update/async-check.ts +5 -1
- package/src/update/index.ts +30 -12
- package/src/update/job.ts +3 -1
- package/src/update/pnpm-read-policy.d.mts +4 -0
- package/src/update/pnpm-read-policy.mjs +44 -0
- package/src/update/worker-launch.ts +152 -11
- package/src/usage/cost.ts +6 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/backends.ts +2 -2
- package/src/web-search/devin-executor.ts +1 -1
- package/src/web-search/sidecar-providers.ts +5 -5
- package/gui/dist/assets/App-mpsibsFj.js +0 -51
- package/gui/dist/assets/index-BDUBS8PW.css +0 -1
- package/gui/dist/assets/index-kUE8tuAK.js +0 -86
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
/** Refresh only previously connected clients; a refused file never blocks its peers. */
|
|
12
12
|
export async function refreshOwnedCatalogIntegrations(
|
|
13
13
|
input: Omit<OwnedIntegrationRefreshInput, "clientId">,
|
|
14
|
-
clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo"],
|
|
14
|
+
clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid"],
|
|
15
15
|
): Promise<OwnedIntegrationRefreshOutcome[]> {
|
|
16
16
|
// Client files are shared with the live proxy a sibling instance runs beside; their entries
|
|
17
17
|
// point at the owner's port, and refreshing them here would re-point them at this one.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
11
11
|
import type { ConfigFormat } from "../clients/config-export";
|
|
12
|
+
import { canonicalizeJsonc } from "../lib/jsonc";
|
|
12
13
|
import { MAX_JSON_NESTING } from "./serialize";
|
|
13
14
|
import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write";
|
|
14
15
|
import type { JournalEntry } from "./journal";
|
|
@@ -132,7 +133,11 @@ function jsonTextSafeToRewrite(text: string): boolean {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/** Parse a client config, tolerating absence. PARSE_FAILED on garbage. */
|
|
135
|
-
export function parseConfig(
|
|
136
|
+
export function parseConfig(
|
|
137
|
+
text: string | null,
|
|
138
|
+
format: ConfigFormat,
|
|
139
|
+
options?: { jsonc?: boolean },
|
|
140
|
+
): unknown | typeof PARSE_FAILED {
|
|
136
141
|
if (text === null || text.trim().length === 0) return {};
|
|
137
142
|
try {
|
|
138
143
|
switch (format) {
|
|
@@ -143,9 +148,13 @@ export function parseConfig(text: string | null, format: ConfigFormat): unknown
|
|
|
143
148
|
* would otherwise cap the rewrite only after JSON.parse had already
|
|
144
149
|
* built the 50k-deep object graph. The outcome is unchanged: invalid
|
|
145
150
|
* JSON still returns PARSE_FAILED, from the catch below.
|
|
151
|
+
*
|
|
152
|
+
* Kilo's global file is JSONC. Comments and trailing commas are
|
|
153
|
+
* stripped first so the rewrite-safety scan sees JSON, not comment text.
|
|
146
154
|
*/
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
const source = options?.jsonc ? canonicalizeJsonc(text) : text;
|
|
156
|
+
if (!jsonTextSafeToRewrite(source)) return PARSE_FAILED;
|
|
157
|
+
return JSON.parse(source);
|
|
149
158
|
}
|
|
150
159
|
case "json5": return Bun.JSON5.parse(text);
|
|
151
160
|
case "yaml": return Bun.YAML.parse(text);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Read-only guard for competing Factory settings before a managed write. */
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, win32 } from "node:path";
|
|
4
|
+
|
|
5
|
+
const isWindowsRoot = (path: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(path);
|
|
6
|
+
|
|
7
|
+
function endpointKey(value: string): string | null {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
11
|
+
const host = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname.toLowerCase())
|
|
12
|
+
? "127.0.0.1" : url.hostname.toLowerCase();
|
|
13
|
+
return `${url.protocol}//${host}:${url.port}${url.pathname.replace(/\/+$/, "") || "/"}`;
|
|
14
|
+
} catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Other Factory settings can take priority over the managed personal rows. */
|
|
18
|
+
export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, modelIds: readonly string[] = []): void {
|
|
19
|
+
const managedEndpoint = baseUrl === undefined ? null : endpointKey(baseUrl);
|
|
20
|
+
const managedModels = new Set(modelIds);
|
|
21
|
+
try {
|
|
22
|
+
const directory = lstatSync(root);
|
|
23
|
+
if (!directory.isDirectory()) throw new Error("Unsafe Factory settings directory");
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
26
|
+
}
|
|
27
|
+
for (const name of ["config.json", "settings.local.json"]) {
|
|
28
|
+
const path = isWindowsRoot(root) ? win32.join(root, name) : join(root, name);
|
|
29
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
30
|
+
try { stat = lstatSync(path); }
|
|
31
|
+
catch (error) {
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
33
|
+
throw new Error(`Cannot inspect Factory ${name}`);
|
|
34
|
+
}
|
|
35
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error(`Unsafe Factory ${name}`);
|
|
36
|
+
let value: unknown;
|
|
37
|
+
let fd: number | undefined;
|
|
38
|
+
try {
|
|
39
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
40
|
+
const opened = fstatSync(fd);
|
|
41
|
+
if (!opened.isFile() || opened.size > 1024 * 1024 || opened.ino !== stat.ino || opened.dev !== stat.dev) {
|
|
42
|
+
throw new Error("file changed during inspection");
|
|
43
|
+
}
|
|
44
|
+
value = JSON.parse(readFileSync(fd, "utf8"));
|
|
45
|
+
} catch { throw new Error(`Cannot safely parse Factory ${name}`); }
|
|
46
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Unsafe Factory ${name}`);
|
|
48
|
+
const rows = name === "config.json"
|
|
49
|
+
? (value as Record<string, unknown>).custom_models
|
|
50
|
+
: (value as Record<string, unknown>).customModels;
|
|
51
|
+
if (name === "settings.local.json" && rows !== undefined) {
|
|
52
|
+
throw new Error("Factory settings.local.json overrides customModels; resolve its precedence before enabling Droid");
|
|
53
|
+
}
|
|
54
|
+
if (name === "config.json" && Array.isArray(rows) && rows.some(row => {
|
|
55
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return false;
|
|
56
|
+
const legacy = row as Record<string, unknown>;
|
|
57
|
+
return (typeof legacy.display_name === "string" && legacy.display_name.startsWith("OpenCodex:"))
|
|
58
|
+
|| (typeof legacy.model === "string" && managedModels.has(legacy.model))
|
|
59
|
+
|| (managedEndpoint !== null && typeof legacy.base_url === "string"
|
|
60
|
+
&& endpointKey(legacy.base_url) === managedEndpoint);
|
|
61
|
+
})) {
|
|
62
|
+
throw new Error("Factory config.json already defines OpenCodex models; resolve its precedence before enabling Droid");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { KILO_CONFIG_CANDIDATES, kiloCandidatePath, kiloHomeDir } from "../clients/config-export";
|
|
3
|
+
import { loadTarget, parseConfig, PARSE_FAILED, type IntegrationIO } from "./config-io";
|
|
4
|
+
|
|
5
|
+
/** Kilo deep-merges every global candidate, so an off-target provider can override ours. */
|
|
6
|
+
export function inspectKiloCandidates(input: {
|
|
7
|
+
io: IntegrationIO;
|
|
8
|
+
selectedPath: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
home?: string;
|
|
11
|
+
}): { kind: "ok" } | { kind: "unsafe"; path: string; why: "unparseable" | "not-regular-file" }
|
|
12
|
+
| { kind: "conflict"; paths: string[] } {
|
|
13
|
+
const dir = kiloHomeDir(input.env ?? process.env, input.home ?? homedir());
|
|
14
|
+
const conflictPaths: string[] = [];
|
|
15
|
+
for (const name of KILO_CONFIG_CANDIDATES) {
|
|
16
|
+
const path = kiloCandidatePath(dir, name);
|
|
17
|
+
const loaded = loadTarget(input.io, path);
|
|
18
|
+
if (!loaded.ok) return { kind: "unsafe", path, why: loaded.why === "read-failed" ? "unparseable" : "not-regular-file" };
|
|
19
|
+
if (loaded.before === null) continue;
|
|
20
|
+
const parsed = parseConfig(loaded.before, "json", { jsonc: true });
|
|
21
|
+
if (parsed === PARSE_FAILED) return { kind: "unsafe", path, why: "unparseable" };
|
|
22
|
+
if (path !== input.selectedPath && typeof parsed === "object" && parsed !== null
|
|
23
|
+
&& !Array.isArray(parsed) && Object.hasOwn(parsed, "provider")) {
|
|
24
|
+
const provider = (parsed as Record<string, unknown>).provider;
|
|
25
|
+
if (typeof provider === "object" && provider !== null && !Array.isArray(provider)
|
|
26
|
+
&& Object.hasOwn(provider, "opencodex")) conflictPaths.push(path);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return conflictPaths.length > 0 ? { kind: "conflict", paths: conflictPaths } : { kind: "ok" };
|
|
30
|
+
}
|
|
@@ -26,13 +26,18 @@ import { parseClineDocument } from "./cline-document";
|
|
|
26
26
|
import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
|
|
27
27
|
import {
|
|
28
28
|
INTEGRATION_CLIENTS,
|
|
29
|
+
boundIntegrationConfigPath,
|
|
30
|
+
assertDroidPathsUnambiguous,
|
|
31
|
+
assertDroidRecordedSettingsUnambiguous,
|
|
29
32
|
isLoopbackOnly,
|
|
30
33
|
resolveIntegrationPaths,
|
|
34
|
+
restoreOwnershipCollision,
|
|
31
35
|
type IntegrationClientId,
|
|
32
36
|
} from "./registry";
|
|
33
37
|
import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
|
|
34
38
|
import { shouldInjectApiAuthHeader } from "../codex/inject";
|
|
35
39
|
import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
|
|
40
|
+
import { inspectKiloCandidates } from "./kilo-candidates";
|
|
36
41
|
import { InvalidSelectorError } from "./merge";
|
|
37
42
|
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
38
43
|
import type { OcxConfig } from "../types";
|
|
@@ -144,6 +149,8 @@ const CLIENT_MANAGED_PATHS = {
|
|
|
144
149
|
["settings", "providers", OPENCODE_PROVIDER_ID],
|
|
145
150
|
["catalog", "providers", OPENCODE_PROVIDER_ID],
|
|
146
151
|
],
|
|
152
|
+
kilo: [["provider", OPENCODE_PROVIDER_ID]],
|
|
153
|
+
droid: [["customModels", DYNAMIC_SEGMENT]],
|
|
147
154
|
} satisfies Record<IntegrationClientId, readonly (readonly string[])[]>;
|
|
148
155
|
|
|
149
156
|
/** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */
|
|
@@ -339,6 +346,7 @@ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
|
|
|
339
346
|
function applyOutcome(input: PlanInput): PlanOutcome {
|
|
340
347
|
if (input.installKind !== "dir") return deny("not_installed");
|
|
341
348
|
if (input.admissionBlocked) return deny("non_loopback");
|
|
349
|
+
if (input.clientId === "droid" && input.contribution?.fragments.length === 0) return deny("unsafe");
|
|
342
350
|
/*
|
|
343
351
|
* Before any file state. The document may be perfectly writable and our block
|
|
344
352
|
* may already be current in it; neither says anything about whether the
|
|
@@ -551,7 +559,9 @@ export function observeRestore(
|
|
|
551
559
|
const clientId = input.clientId;
|
|
552
560
|
let resolved: { configPath: string; detectDir: string };
|
|
553
561
|
try {
|
|
554
|
-
|
|
562
|
+
const context = exportContextOf(input);
|
|
563
|
+
resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home, context);
|
|
564
|
+
if (clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(resolved.detectDir, context);
|
|
555
565
|
} catch (error) {
|
|
556
566
|
if (!(error instanceof ClientPathError)) throw error;
|
|
557
567
|
return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
|
|
@@ -582,6 +592,22 @@ export function observeRestore(
|
|
|
582
592
|
failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
|
|
583
593
|
} as const;
|
|
584
594
|
}
|
|
595
|
+
/*
|
|
596
|
+
* A legal historical path is not enough. Another candidate can already own
|
|
597
|
+
* the single record, and committing this row's prior record would orphan the
|
|
598
|
+
* block that candidate still holds. Direct restore asks the same question.
|
|
599
|
+
*/
|
|
600
|
+
const currentOwner = store.readRecords()[clientId] ?? null;
|
|
601
|
+
const collision = restoreOwnershipCollision({
|
|
602
|
+
clientId,
|
|
603
|
+
journaledPath: configPath,
|
|
604
|
+
currentPath: currentOwner && currentOwner.clientId === clientId ? currentOwner.configPath : null,
|
|
605
|
+
env: input.env,
|
|
606
|
+
home: input.home,
|
|
607
|
+
});
|
|
608
|
+
if (collision !== null) {
|
|
609
|
+
return { failed: observationFailure("conflict", "conflict", collision) } as const;
|
|
610
|
+
}
|
|
585
611
|
if (clientId === "cline") {
|
|
586
612
|
try { io = createClineIO(io, configPath, store, effects.recover); }
|
|
587
613
|
catch (error) {
|
|
@@ -640,7 +666,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe
|
|
|
640
666
|
// Restore never reaches the general observation, because the writer's undo path never parses
|
|
641
667
|
// or classifies and a preview that did would answer a different question.
|
|
642
668
|
if (request.operation === "restore") return previewRestore(input, request);
|
|
643
|
-
const observed = observeIntegration(input, { maintenance: false, recover: false });
|
|
669
|
+
const observed = observeIntegration(input, { maintenance: false, recover: false }, request.operation);
|
|
644
670
|
if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId);
|
|
645
671
|
|
|
646
672
|
const shared = {
|
|
@@ -743,7 +769,7 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest):
|
|
|
743
769
|
? {}
|
|
744
770
|
: observed.clientId === "cline"
|
|
745
771
|
? parseClineDocument(observed.before)
|
|
746
|
-
: parseConfig(observed.before, observed.format),
|
|
772
|
+
: parseConfig(observed.before, observed.format, EXPORT_CLIENTS[observed.clientId].jsonc ? { jsonc: true } : undefined),
|
|
747
773
|
restore: {
|
|
748
774
|
opId: observed.entry.opId,
|
|
749
775
|
entry: observed.entry,
|
|
@@ -812,7 +838,11 @@ export interface ObservationEffects {
|
|
|
812
838
|
* classification rather than two independent reads that can disagree. The ordering of refusals is
|
|
813
839
|
* load-bearing and is preserved exactly as the writer had it.
|
|
814
840
|
*/
|
|
815
|
-
export function observeIntegration(
|
|
841
|
+
export function observeIntegration(
|
|
842
|
+
input: IntegrationWriteInput,
|
|
843
|
+
effects: ObservationEffects,
|
|
844
|
+
operation: Exclude<IntegrationPlanOperation, "restore">,
|
|
845
|
+
) {
|
|
816
846
|
const store = input.store ?? createIntegrationStateStore();
|
|
817
847
|
let io = input.io ?? defaultIntegrationIO(store);
|
|
818
848
|
const clientId = input.clientId;
|
|
@@ -839,7 +869,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
839
869
|
* an Aside account switch land between the two, so a direct apply could
|
|
840
870
|
* verify account 1 was installed and then write account 0's catalog.
|
|
841
871
|
*/
|
|
842
|
-
const
|
|
872
|
+
const context = exportContextOf(input);
|
|
873
|
+
const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home, context);
|
|
874
|
+
if (clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(resolved.detectDir, context);
|
|
843
875
|
detectDir = resolved.detectDir;
|
|
844
876
|
if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover);
|
|
845
877
|
/*
|
|
@@ -850,6 +882,10 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
850
882
|
* target is known, because that is the path it has to match.
|
|
851
883
|
*/
|
|
852
884
|
stored = store.readRecords()[clientId] ?? null;
|
|
885
|
+
const recordedPath = boundIntegrationConfigPath({
|
|
886
|
+
clientId, record: stored, resolvedPath: resolved.configPath,
|
|
887
|
+
statKind: io.statKind, env: input.env, home: input.home,
|
|
888
|
+
});
|
|
853
889
|
/*
|
|
854
890
|
* Inside the same guard as resolution, because this resolver can refuse the
|
|
855
891
|
* same way: the store is named by a client env var, and a relative one is a
|
|
@@ -857,7 +893,7 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
857
893
|
* collection route.
|
|
858
894
|
*/
|
|
859
895
|
effective = resolveIntegrationTarget({
|
|
860
|
-
clientId, configPath:
|
|
896
|
+
clientId, configPath: recordedPath, io, record: stored, env: input.env, home: input.home,
|
|
861
897
|
});
|
|
862
898
|
configPath = effective.configPath;
|
|
863
899
|
} catch (error) {
|
|
@@ -868,6 +904,13 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
868
904
|
return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
|
|
869
905
|
}
|
|
870
906
|
// Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
|
|
907
|
+
if (clientId === "kilo" && operation !== "disable") {
|
|
908
|
+
const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
|
|
909
|
+
if (candidates.kind !== "ok") return { failed: candidates.kind === "conflict"
|
|
910
|
+
? observationFailure("conflict", "conflict", `${configPath} cannot be managed while ${candidates.paths.join(", ")} also defines provider.opencodex`)
|
|
911
|
+
: observationFailure("unsafe", "unsafe", `${candidates.path} cannot be inspected safely (${candidates.why})`),
|
|
912
|
+
} as const;
|
|
913
|
+
}
|
|
871
914
|
if (effects.maintenance) store.retryPendingPrunes();
|
|
872
915
|
|
|
873
916
|
const loaded = loadTarget(io, configPath);
|
|
@@ -880,7 +923,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
880
923
|
} as const;
|
|
881
924
|
}
|
|
882
925
|
const before = loaded.before;
|
|
883
|
-
const parsed = clientId === "cline"
|
|
926
|
+
const parsed = clientId === "cline"
|
|
927
|
+
? parseClineDocument(before)
|
|
928
|
+
: parseConfig(before, effective.format, exportSpec.jsonc ? { jsonc: true } : undefined);
|
|
884
929
|
if (parsed === PARSE_FAILED) {
|
|
885
930
|
return { failed: observationFailure("unsafe", "unsafe",
|
|
886
931
|
`${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
|
|
@@ -898,6 +943,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
898
943
|
const record = stored && stored.clientId === clientId && stored.configPath === configPath
|
|
899
944
|
? stored
|
|
900
945
|
: null;
|
|
946
|
+
if (clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && !record) {
|
|
947
|
+
return { failed: observationFailure("unsafe", "unsafe", "Factory Droid has no addressable models in the selected catalog") } as const;
|
|
948
|
+
}
|
|
901
949
|
// `configPath`/`clientId` are load-bearing, not decoration: a record proves
|
|
902
950
|
// ownership of ONE file, and the writer mutates whatever path resolves NOW.
|
|
903
951
|
// Without them a record written for another home directory would grant
|
|
@@ -906,6 +954,13 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
906
954
|
fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
|
|
907
955
|
format: effective.format,
|
|
908
956
|
});
|
|
957
|
+
if (clientId === "droid" && record && (classified.state === "current" || classified.state === "stale")) {
|
|
958
|
+
try { assertDroidRecordedSettingsUnambiguous(detectDir, parsed, record); }
|
|
959
|
+
catch (error) {
|
|
960
|
+
if (!(error instanceof ClientPathError)) throw error;
|
|
961
|
+
return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
909
964
|
return {
|
|
910
965
|
failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir,
|
|
911
966
|
/*
|
|
@@ -18,9 +18,11 @@ import {
|
|
|
18
18
|
import {
|
|
19
19
|
currentServiceHomes,
|
|
20
20
|
inspectServiceStateEvidence,
|
|
21
|
-
|
|
21
|
+
compareServicePathToInstall,
|
|
22
22
|
type ServiceStateEvidence,
|
|
23
|
+
type ServicePathComparison,
|
|
23
24
|
} from "../../service";
|
|
25
|
+
import type { CodexHomeDeps } from "../../codex/home";
|
|
24
26
|
import {
|
|
25
27
|
createWindowsTaskListingCache,
|
|
26
28
|
inspectServiceManagerInstallation,
|
|
@@ -70,15 +72,25 @@ export interface OwnershipInspection {
|
|
|
70
72
|
readonly reason: string;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
function
|
|
75
|
+
function claimComparesToCurrentHomes(
|
|
74
76
|
claim: ServiceManagerClaim,
|
|
75
77
|
current: { codexHome: string; opencodexHome: string },
|
|
76
|
-
|
|
78
|
+
deps: CodexHomeDeps,
|
|
79
|
+
): ServicePathComparison {
|
|
77
80
|
// A definition that OMITS a home is not a definition that disagrees about it:
|
|
78
81
|
// an install run without CODEX_HOME set writes no such key at all.
|
|
79
|
-
|
|
80
|
-
if (claim.homes.
|
|
81
|
-
|
|
82
|
+
let indeterminate = false;
|
|
83
|
+
if (claim.homes.codexHome !== null) {
|
|
84
|
+
const verdict = compareServicePathToInstall(claim.homes.codexHome, current.codexHome, deps);
|
|
85
|
+
if (verdict === "different") return "different";
|
|
86
|
+
indeterminate ||= verdict === "unknown";
|
|
87
|
+
}
|
|
88
|
+
if (claim.homes.opencodexHome !== null) {
|
|
89
|
+
const verdict = compareServicePathToInstall(claim.homes.opencodexHome, current.opencodexHome, deps);
|
|
90
|
+
if (verdict === "different") return "different";
|
|
91
|
+
indeterminate ||= verdict === "unknown";
|
|
92
|
+
}
|
|
93
|
+
return indeterminate ? "unknown" : "same";
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
/**
|
|
@@ -109,6 +121,8 @@ export interface OwnershipDeps extends ProbeDeps {
|
|
|
109
121
|
*/
|
|
110
122
|
readonly statePaths?: readonly string[];
|
|
111
123
|
readonly currentHomes?: { codexHome: string; opencodexHome: string };
|
|
124
|
+
/** Test seam for resolving recorded home aliases to their physical directory. */
|
|
125
|
+
readonly realpathSync?: (path: string) => string;
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): OwnershipInspection {
|
|
@@ -130,22 +144,35 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
|
|
|
130
144
|
// Mirrors that disagree with each other are not a majority vote.
|
|
131
145
|
for (const one of valid) {
|
|
132
146
|
for (const other of valid) {
|
|
133
|
-
if (
|
|
134
|
-
||
|
|
147
|
+
if (compareServicePathToInstall(one.state.codexHome, other.state.codexHome, deps) !== "same"
|
|
148
|
+
|| compareServicePathToInstall(one.state.opencodexHome, other.state.opencodexHome, deps) !== "same") {
|
|
135
149
|
return { ownership: "unknown", reason: "two service state files disagree about which homes are installed" };
|
|
136
150
|
}
|
|
137
151
|
}
|
|
138
152
|
}
|
|
139
153
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
||
|
|
154
|
+
const evidenceComparesDifferent = (e: Extract<ServiceStateEvidence, { kind: "valid" }>): boolean =>
|
|
155
|
+
compareServicePathToInstall(e.state.codexHome, current.codexHome, deps) === "different"
|
|
156
|
+
|| compareServicePathToInstall(e.state.opencodexHome, current.opencodexHome, deps) === "different";
|
|
157
|
+
const evidenceComparesIndeterminate = (e: Extract<ServiceStateEvidence, { kind: "valid" }>): boolean =>
|
|
158
|
+
compareServicePathToInstall(e.state.codexHome, current.codexHome, deps) === "unknown"
|
|
159
|
+
|| compareServicePathToInstall(e.state.opencodexHome, current.opencodexHome, deps) === "unknown";
|
|
160
|
+
const foreign = valid.find(evidenceComparesDifferent);
|
|
143
161
|
if (foreign) {
|
|
144
162
|
return {
|
|
145
163
|
ownership: "foreign",
|
|
146
164
|
reason: `a service is installed for CODEX_HOME=${foreign.state.codexHome} / OPENCODEX_HOME=${foreign.state.opencodexHome}`,
|
|
147
165
|
};
|
|
148
166
|
}
|
|
167
|
+
// A resolution that could not run (EACCES, EPERM, a vanished directory, transient I/O)
|
|
168
|
+
// proves neither same nor different — report it as unknown, never as foreign.
|
|
169
|
+
const indeterminateEvidence = valid.find(evidenceComparesIndeterminate);
|
|
170
|
+
if (indeterminateEvidence) {
|
|
171
|
+
return {
|
|
172
|
+
ownership: "unknown",
|
|
173
|
+
reason: `a recorded home in ${indeterminateEvidence.path} could not be resolved for comparison`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
149
176
|
|
|
150
177
|
// The manager assets live under the effective OPENCODEX_HOME. Production
|
|
151
178
|
// callers do not inject ProbeDeps.configDir, so derive it from the same
|
|
@@ -162,7 +189,7 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
|
|
|
162
189
|
return { ownership: "unknown", reason: "more than one service manager holds a registration for this proxy" };
|
|
163
190
|
}
|
|
164
191
|
if (manager.kind === "present") {
|
|
165
|
-
const disagreeing = manager.claims.find(claim =>
|
|
192
|
+
const disagreeing = manager.claims.find(claim => claimComparesToCurrentHomes(claim, current, deps) === "different");
|
|
166
193
|
if (disagreeing) {
|
|
167
194
|
/*
|
|
168
195
|
* The state file says this home and the definition says another. An
|
|
@@ -175,6 +202,13 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
|
|
|
175
202
|
reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`,
|
|
176
203
|
};
|
|
177
204
|
}
|
|
205
|
+
const indeterminateClaim = manager.claims.find(claim => claimComparesToCurrentHomes(claim, current, deps) === "unknown");
|
|
206
|
+
if (indeterminateClaim) {
|
|
207
|
+
return {
|
|
208
|
+
ownership: "unknown",
|
|
209
|
+
reason: `the homes recorded in ${indeterminateClaim.definitionPath} could not be resolved for comparison`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
178
212
|
// A manager backend that disagrees with the recorded state (e.g. state says
|
|
179
213
|
// native/WinSW but a scheduler task is found) is an interrupted backend
|
|
180
214
|
// switch: it does not prove which manager owns the installation. v1 state
|
|
@@ -9,11 +9,17 @@
|
|
|
9
9
|
* Design of record: devlog/_fin/260802_client_toggle_api/021 §1.
|
|
10
10
|
*/
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
+
import { assertDroidSettingsUnambiguous } from "./droid-settings";
|
|
13
|
+
import { readPath } from "./merge";
|
|
14
|
+
import type { OwnershipRecord } from "./ownership";
|
|
12
15
|
import { join } from "node:path";
|
|
13
16
|
import {
|
|
14
17
|
ClientPathError,
|
|
18
|
+
buildDroidContribution,
|
|
15
19
|
clineConfigPath,
|
|
16
20
|
clineSettingsDir,
|
|
21
|
+
droidConfigPath,
|
|
22
|
+
droidHomeDir,
|
|
17
23
|
EXPORT_CLIENTS,
|
|
18
24
|
asideAccountDir,
|
|
19
25
|
asideConfigPath,
|
|
@@ -48,7 +54,13 @@ import {
|
|
|
48
54
|
zcodeStoreSchemaEstablished,
|
|
49
55
|
type BuildContribution,
|
|
50
56
|
type ConfigFormat,
|
|
57
|
+
kiloConfigPath,
|
|
58
|
+
kiloHomeDir,
|
|
59
|
+
kiloCandidatePath,
|
|
60
|
+
KILO_CONFIG_CANDIDATES,
|
|
51
61
|
type ExportClientId,
|
|
62
|
+
type DroidModelEntry,
|
|
63
|
+
type ExportContext,
|
|
52
64
|
} from "../clients/config-export";
|
|
53
65
|
|
|
54
66
|
/**
|
|
@@ -93,13 +105,12 @@ export interface IntegrationClientSpec {
|
|
|
93
105
|
* Derive the config path AND the detect directory from one resolution, for a
|
|
94
106
|
* client whose paths depend on mutable state rather than only env and home.
|
|
95
107
|
*
|
|
96
|
-
*
|
|
97
|
-
* `accounts.json
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* removes the window instead of narrowing it.
|
|
108
|
+
* Aside needs this because both paths come from the account id in
|
|
109
|
+
* `accounts.json`: one read prevents an account switch between resolutions.
|
|
110
|
+
* Droid uses the same seam to check competing settings against the export
|
|
111
|
+
* context before status, preview, or mutation proceeds.
|
|
101
112
|
*/
|
|
102
|
-
resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string };
|
|
113
|
+
resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string, exportContext?: ExportContext) => { configPath: string; detectDir: string };
|
|
103
114
|
/**
|
|
104
115
|
* Where the client's config WOULD live, for a client whose real path cannot
|
|
105
116
|
* be resolved yet.
|
|
@@ -115,6 +126,19 @@ export interface IntegrationClientSpec {
|
|
|
115
126
|
* catalog. `resolveIntegrationPaths` still throws for callers that mutate.
|
|
116
127
|
*/
|
|
117
128
|
unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
129
|
+
/**
|
|
130
|
+
* Recognize a resolution drift that is still THIS client's own file, for a
|
|
131
|
+
* client whose config path depends on mutable world state rather than only
|
|
132
|
+
* env and home.
|
|
133
|
+
*
|
|
134
|
+
* Kilo resolves to the first EXISTING candidate, so a candidate created
|
|
135
|
+
* after apply moves resolution while the owned file still holds our block.
|
|
136
|
+
* While this predicate accepts the recorded path, reads and mutations stay
|
|
137
|
+
* bound to it instead of silently re-homing onto the newcomer. A client
|
|
138
|
+
* without this hook never binds: a record from another home stays a refusal
|
|
139
|
+
* ("a record for one home cannot authorize a write to another").
|
|
140
|
+
*/
|
|
141
|
+
bindsDriftedRecord?: (recordPath: string, env?: NodeJS.ProcessEnv, home?: string) => boolean;
|
|
118
142
|
}
|
|
119
143
|
|
|
120
144
|
/**
|
|
@@ -129,12 +153,38 @@ export function resolveIntegrationPaths(
|
|
|
129
153
|
clientId: IntegrationClientId,
|
|
130
154
|
env: NodeJS.ProcessEnv = process.env,
|
|
131
155
|
home: string = homedir(),
|
|
156
|
+
exportContext?: ExportContext,
|
|
132
157
|
): { configPath: string; detectDir: string } {
|
|
133
158
|
const spec = INTEGRATION_CLIENTS[clientId];
|
|
134
|
-
if (spec.resolvePaths) return spec.resolvePaths(env, home);
|
|
159
|
+
if (spec.resolvePaths) return spec.resolvePaths(env, home, exportContext);
|
|
135
160
|
return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) };
|
|
136
161
|
}
|
|
137
162
|
|
|
163
|
+
export function assertDroidPathsUnambiguous(root: string, exportContext?: ExportContext): void {
|
|
164
|
+
try {
|
|
165
|
+
const generated = exportContext ? buildDroidContribution(exportContext).fragments : [];
|
|
166
|
+
assertDroidSettingsUnambiguous(root, exportContext?.baseUrl, generated.map(fragment => (fragment.value as DroidModelEntry).model));
|
|
167
|
+
}
|
|
168
|
+
catch (error) { throw new ClientPathError((error as Error).message); }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Check the identities still owned on disk even after they leave the catalog. */
|
|
172
|
+
export function assertDroidRecordedSettingsUnambiguous(root: string, parsed: unknown, record: OwnershipRecord): void {
|
|
173
|
+
try {
|
|
174
|
+
const byEndpoint = new Map<string, Set<string>>();
|
|
175
|
+
for (const path of record.fragmentPaths) {
|
|
176
|
+
const row = readPath(parsed, path) as Partial<DroidModelEntry> | undefined;
|
|
177
|
+
if (typeof row?.baseUrl !== "string" || typeof row.model !== "string") {
|
|
178
|
+
throw new Error("Cannot verify recorded Factory Droid rows");
|
|
179
|
+
}
|
|
180
|
+
const models = byEndpoint.get(row.baseUrl) ?? new Set<string>();
|
|
181
|
+
models.add(row.model);
|
|
182
|
+
byEndpoint.set(row.baseUrl, models);
|
|
183
|
+
}
|
|
184
|
+
for (const [baseUrl, models] of byEndpoint) assertDroidSettingsUnambiguous(root, baseUrl, [...models]);
|
|
185
|
+
} catch (error) { throw new ClientPathError((error as Error).message); }
|
|
186
|
+
}
|
|
187
|
+
|
|
138
188
|
/**
|
|
139
189
|
* The location to name when resolution refused, or `""` when there is none.
|
|
140
190
|
*
|
|
@@ -345,11 +395,90 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
|
|
|
345
395
|
detectDir: (env = process.env, home = homedir()) => clineSettingsDir(env, home),
|
|
346
396
|
writerLock: { suffix: ".lock" },
|
|
347
397
|
},
|
|
398
|
+
kilo: {
|
|
399
|
+
id: "kilo",
|
|
400
|
+
configPath: (env = process.env, home = homedir()) => kiloConfigPath(env, home),
|
|
401
|
+
detectDir: (env = process.env, home = homedir()) => kiloHomeDir(env, home),
|
|
402
|
+
bindsDriftedRecord: (recordPath, env = process.env, home = homedir()) =>
|
|
403
|
+
KILO_CONFIG_CANDIDATES.some(name => recordPath === kiloCandidatePath(kiloHomeDir(env, home), name)),
|
|
404
|
+
},
|
|
405
|
+
droid: {
|
|
406
|
+
id: "droid",
|
|
407
|
+
configPath: (env = process.env, home = homedir()) => droidConfigPath(env, home),
|
|
408
|
+
detectDir: (env = process.env, home = homedir()) => droidHomeDir(env, home),
|
|
409
|
+
resolvePaths: (env = process.env, home = homedir(), exportContext) => {
|
|
410
|
+
const detectDir = droidHomeDir(env, home);
|
|
411
|
+
assertDroidPathsUnambiguous(detectDir, exportContext);
|
|
412
|
+
return { configPath: droidConfigPath(env, home), detectDir };
|
|
413
|
+
},
|
|
414
|
+
},
|
|
348
415
|
};
|
|
349
416
|
|
|
350
417
|
export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
|
|
351
418
|
Object.keys(INTEGRATION_CLIENTS) as IntegrationClientId[];
|
|
352
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The effective config path for a read or mutation, given the ownership record.
|
|
422
|
+
*
|
|
423
|
+
* One implementation for status AND the mutation planner: when only one side
|
|
424
|
+
* carried the binding, the two could disagree again and status would report a
|
|
425
|
+
* file the writer never touches. Binds only while the client's own
|
|
426
|
+
* `bindsDriftedRecord` accepts the recorded path (still one of that client's
|
|
427
|
+
* candidates under the CURRENT env and home) and the file still exists; a
|
|
428
|
+
* record from another home never binds and keeps its refusal contract.
|
|
429
|
+
*/
|
|
430
|
+
export function boundIntegrationConfigPath(input: {
|
|
431
|
+
clientId: IntegrationClientId;
|
|
432
|
+
record: { clientId: IntegrationClientId; configPath: string } | null;
|
|
433
|
+
resolvedPath: string;
|
|
434
|
+
statKind: (path: string) => string;
|
|
435
|
+
env?: NodeJS.ProcessEnv;
|
|
436
|
+
home?: string;
|
|
437
|
+
}): string {
|
|
438
|
+
const record = input.record;
|
|
439
|
+
if (
|
|
440
|
+
record && record.clientId === input.clientId &&
|
|
441
|
+
record.configPath !== input.resolvedPath &&
|
|
442
|
+
input.statKind(record.configPath) === "file" &&
|
|
443
|
+
INTEGRATION_CLIENTS[input.clientId].bindsDriftedRecord?.(record.configPath, input.env, input.home) === true
|
|
444
|
+
) {
|
|
445
|
+
return record.configPath;
|
|
446
|
+
}
|
|
447
|
+
return input.resolvedPath;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Why a historical restore must not run, or null when it may.
|
|
452
|
+
*
|
|
453
|
+
* Kilo keeps one ownership record and may legally have written more than one
|
|
454
|
+
* candidate. Treating every same-home journaled path as a restore target lets
|
|
455
|
+
* an undo of an older file commit that file's prior record over the candidate
|
|
456
|
+
* that owns the integration now. The managed block in the current file stays
|
|
457
|
+
* on disk, the record points at the old file, and a later disable drops the
|
|
458
|
+
* record and orphans the newcomer.
|
|
459
|
+
*
|
|
460
|
+
* A missing current record is not a collision: undoing the disable that
|
|
461
|
+
* dropped it still restores the journaled file. A client without
|
|
462
|
+
* bindsDriftedRecord is unchanged, because that seam is what made the second
|
|
463
|
+
* candidate a legal target. Direct restore and its preview both ask here, so
|
|
464
|
+
* they cannot admit different answers.
|
|
465
|
+
*/
|
|
466
|
+
export function restoreOwnershipCollision(input: {
|
|
467
|
+
clientId: IntegrationClientId;
|
|
468
|
+
journaledPath: string;
|
|
469
|
+
currentPath: string | null;
|
|
470
|
+
env?: NodeJS.ProcessEnv;
|
|
471
|
+
home?: string;
|
|
472
|
+
}): string | null {
|
|
473
|
+
const currentPath = input.currentPath;
|
|
474
|
+
if (currentPath === null || currentPath === input.journaledPath) return null;
|
|
475
|
+
const binds = INTEGRATION_CLIENTS[input.clientId].bindsDriftedRecord;
|
|
476
|
+
if (!binds) return null;
|
|
477
|
+
if (binds(input.journaledPath, input.env, input.home) !== true) return null;
|
|
478
|
+
if (binds(currentPath, input.env, input.home) !== true) return null;
|
|
479
|
+
return `that operation was recorded for ${input.journaledPath}, but ${currentPath} currently owns this integration`;
|
|
480
|
+
}
|
|
481
|
+
|
|
353
482
|
export function isIntegrationClientId(value: string): value is IntegrationClientId {
|
|
354
483
|
return Object.prototype.hasOwnProperty.call(INTEGRATION_CLIENTS, value);
|
|
355
484
|
}
|