@gajae-code/coding-agent 0.16.7 → 0.17.1
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/CHANGELOG.md +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
1
2
|
import * as nodeFs from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
@@ -44,6 +45,9 @@ import type {
|
|
|
44
45
|
GjcUninstallPreview,
|
|
45
46
|
GjcUpdateApplyResult,
|
|
46
47
|
GjcUpdatePreview,
|
|
48
|
+
LocalRestorePlanV1,
|
|
49
|
+
NormalizedGjcPluginBundle,
|
|
50
|
+
ReviewedRestoreTokenV1,
|
|
47
51
|
} from "./types";
|
|
48
52
|
import { GJC_PLUGIN_MANIFEST_FILENAME, GjcPluginLoadError } from "./types";
|
|
49
53
|
|
|
@@ -824,6 +828,161 @@ export async function applyGjcBundleUpdate(
|
|
|
824
828
|
});
|
|
825
829
|
}
|
|
826
830
|
|
|
831
|
+
async function restoreArtifactObservation(entry: GjcPluginRegistryEntry): Promise<LocalRestorePlanV1["artifact"]> {
|
|
832
|
+
const hash = crypto.createHash("sha256");
|
|
833
|
+
try {
|
|
834
|
+
for (const file of [...entry.copiedFiles].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
|
|
835
|
+
const target = path.join(entry.pluginRoot, file.relativePath);
|
|
836
|
+
const stat = await fs.lstat(target);
|
|
837
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { status: "unreadable" };
|
|
838
|
+
const bytes = await fs.readFile(target);
|
|
839
|
+
if (crypto.createHash("sha256").update(bytes).digest("hex") !== file.sha256) return { status: "unreadable" };
|
|
840
|
+
hash.update(file.relativePath);
|
|
841
|
+
hash.update("\0");
|
|
842
|
+
hash.update(file.sha256);
|
|
843
|
+
hash.update("\0");
|
|
844
|
+
}
|
|
845
|
+
return { status: "present", digest: hash.digest("hex") };
|
|
846
|
+
} catch (error) {
|
|
847
|
+
hash.digest();
|
|
848
|
+
return { status: isEnoent(error) ? "absent" : "unreadable" };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function bundleArtifactFingerprint(bundle: NormalizedGjcPluginBundle): string {
|
|
853
|
+
const hash = crypto.createHash("sha256");
|
|
854
|
+
for (const file of [...bundle.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
|
|
855
|
+
hash.update(file.relativePath);
|
|
856
|
+
hash.update("\0");
|
|
857
|
+
hash.update(file.sha256);
|
|
858
|
+
hash.update("\0");
|
|
859
|
+
}
|
|
860
|
+
return hash.digest("hex");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export async function previewGjcBundleRestore(
|
|
864
|
+
ctx: GjcLifecycleContext,
|
|
865
|
+
identity: GjcBundleIdentity,
|
|
866
|
+
): Promise<GjcLifecycleResult<LocalRestorePlanV1>> {
|
|
867
|
+
const registry = await readRegistry(identity.scope, ctx.cwd, { migrate: false });
|
|
868
|
+
const entry = registry.plugins.find(plugin => plugin.name === identity.name);
|
|
869
|
+
if (!entry) return { ok: false, error: notInstalled(identity) };
|
|
870
|
+
const effective = await readEffective(ctx.cwd);
|
|
871
|
+
return {
|
|
872
|
+
ok: true,
|
|
873
|
+
value: {
|
|
874
|
+
schemaVersion: 1,
|
|
875
|
+
kind: "gjc-plugin.restore-artifact",
|
|
876
|
+
identity,
|
|
877
|
+
baselineFingerprint: baselineFingerprint(entry),
|
|
878
|
+
decisionContextFingerprint: decisionContextFingerprint(identity, effective),
|
|
879
|
+
artifact: await restoreArtifactObservation(entry),
|
|
880
|
+
writes: false,
|
|
881
|
+
fetch: false,
|
|
882
|
+
locks: false,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export async function authorizeGjcBundleRestore(
|
|
888
|
+
ctx: GjcLifecycleContext,
|
|
889
|
+
plan: LocalRestorePlanV1,
|
|
890
|
+
authorizations: readonly string[],
|
|
891
|
+
): Promise<GjcLifecycleResult<ReviewedRestoreTokenV1>> {
|
|
892
|
+
if (plan.kind !== "gjc-plugin.restore-artifact" || plan.schemaVersion !== 1)
|
|
893
|
+
return { ok: false, error: fail("invalid_target", "Restore plan is invalid") };
|
|
894
|
+
if (!authorizations.includes("plugin-change") || !authorizations.includes("install-replace"))
|
|
895
|
+
return { ok: false, error: fail("invalid_target", "Restore authorization is missing") };
|
|
896
|
+
const registry = await readRegistry(plan.identity.scope, ctx.cwd, { migrate: false });
|
|
897
|
+
const entry = registry.plugins.find(plugin => plugin.name === plan.identity.name);
|
|
898
|
+
if (!entry) return { ok: false, error: notInstalled(plan.identity) };
|
|
899
|
+
if (baselineFingerprint(entry) !== plan.baselineFingerprint)
|
|
900
|
+
return { ok: false, error: fail("stale_baseline", "The installed bundle changed since it was reviewed") };
|
|
901
|
+
const effective = await readEffective(ctx.cwd);
|
|
902
|
+
if (decisionContextFingerprint(plan.identity, effective) !== plan.decisionContextFingerprint)
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
error: fail("stale_decision_context", "Installed bundles changed since restore was reviewed"),
|
|
906
|
+
};
|
|
907
|
+
return await withSourceAvailability(plan.identity, async () =>
|
|
908
|
+
resolveGjcBundleCandidate(storedSourceLocator(entry.source), async ({ bundle }) => {
|
|
909
|
+
if (bundle.name !== plan.identity.name)
|
|
910
|
+
return { ok: false, error: fail("identity_mismatch", "Stored source identity changed") };
|
|
911
|
+
const fingerprint = candidateFingerprint(plan.identity.scope, bundle);
|
|
912
|
+
return {
|
|
913
|
+
ok: true,
|
|
914
|
+
value: {
|
|
915
|
+
schemaVersion: 1,
|
|
916
|
+
kind: "gjc-plugin.restore-artifact",
|
|
917
|
+
purpose: "restore-artifact",
|
|
918
|
+
identity: plan.identity,
|
|
919
|
+
candidateFingerprint: fingerprint,
|
|
920
|
+
baselineFingerprint: plan.baselineFingerprint,
|
|
921
|
+
decisionContextFingerprint: plan.decisionContextFingerprint,
|
|
922
|
+
artifactFingerprint: bundleArtifactFingerprint(bundle),
|
|
923
|
+
reviewedAt: new Date().toISOString(),
|
|
924
|
+
},
|
|
925
|
+
};
|
|
926
|
+
}),
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
export async function applyGjcBundleRestore(
|
|
931
|
+
ctx: GjcLifecycleContext,
|
|
932
|
+
token: ReviewedRestoreTokenV1,
|
|
933
|
+
): Promise<GjcLifecycleResult<GjcUpdateApplyResult>> {
|
|
934
|
+
if (
|
|
935
|
+
token.kind !== "gjc-plugin.restore-artifact" ||
|
|
936
|
+
token.purpose !== "restore-artifact" ||
|
|
937
|
+
token.schemaVersion !== 1
|
|
938
|
+
)
|
|
939
|
+
return { ok: false, error: fail("invalid_target", "Restore token is invalid") };
|
|
940
|
+
const identity = token.identity;
|
|
941
|
+
const registry = await readRegistry(identity.scope, ctx.cwd, { migrate: false });
|
|
942
|
+
const stored = registry.plugins.find(plugin => plugin.name === identity.name);
|
|
943
|
+
if (!stored) return { ok: false, error: notInstalled(identity) };
|
|
944
|
+
return await withSourceAvailability(identity, async () => {
|
|
945
|
+
const result = await runGjcBundleTransaction(storedSourceLocator(stored.source), {
|
|
946
|
+
scope: identity.scope,
|
|
947
|
+
cwd: ctx.cwd,
|
|
948
|
+
decide: async ({ existing, effective, bundle, candidate }) => {
|
|
949
|
+
if (!existing) return { kind: "abort", error: notInstalled(identity) };
|
|
950
|
+
if (candidateFingerprint(identity.scope, bundle) !== token.candidateFingerprint)
|
|
951
|
+
return { kind: "abort", error: fail("stale_candidate", "The restore candidate changed since review") };
|
|
952
|
+
if (baselineFingerprint(existing) !== token.baselineFingerprint)
|
|
953
|
+
return { kind: "abort", error: fail("stale_baseline", "The installed bundle changed since review") };
|
|
954
|
+
if (decisionContextFingerprint(identity, effective) !== token.decisionContextFingerprint)
|
|
955
|
+
return {
|
|
956
|
+
kind: "abort",
|
|
957
|
+
error: fail("stale_decision_context", "Installed bundles changed since restore was reviewed"),
|
|
958
|
+
};
|
|
959
|
+
const observed = await restoreArtifactObservation(existing);
|
|
960
|
+
if (observed.status === "present" && observed.digest === bundleArtifactFingerprint(bundle))
|
|
961
|
+
return { kind: "noop", entry: existing };
|
|
962
|
+
const reconciled = reconcileEnablement(existing.disabledSurfaceIds, surfaceIdsOf(bundle.surfaces));
|
|
963
|
+
const next: GjcPluginRegistryEntry = {
|
|
964
|
+
...candidate,
|
|
965
|
+
enabled: existing.enabled,
|
|
966
|
+
installedAt: existing.installedAt,
|
|
967
|
+
disabledSurfaceIds: reconciled.disabledSurfaceIds,
|
|
968
|
+
};
|
|
969
|
+
if (reconciled.quarantine.length) next.quarantine = reconciled.quarantine;
|
|
970
|
+
else delete next.quarantine;
|
|
971
|
+
return { kind: "commit", entry: next };
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
if (result.status === "aborted") return { ok: false, error: result.error };
|
|
975
|
+
return {
|
|
976
|
+
ok: true,
|
|
977
|
+
value: {
|
|
978
|
+
status: result.status === "noop" ? "unchanged" : "updated",
|
|
979
|
+
summary: toBundleSummary(result.entry),
|
|
980
|
+
remnantCount: result.remnants.length,
|
|
981
|
+
},
|
|
982
|
+
};
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
827
986
|
async function mutateEntry(
|
|
828
987
|
ctx: GjcLifecycleContext,
|
|
829
988
|
identity: GjcBundleIdentity,
|
|
@@ -842,6 +1001,36 @@ async function mutateEntry(
|
|
|
842
1001
|
});
|
|
843
1002
|
}
|
|
844
1003
|
|
|
1004
|
+
/**
|
|
1005
|
+
* Doctor-only quarantine primitive. The expected baseline is compared while
|
|
1006
|
+
* holding the ordinary registry writer lock, so concurrent enable/disable or
|
|
1007
|
+
* update cannot be clobbered.
|
|
1008
|
+
*/
|
|
1009
|
+
export async function setGjcBundleQuarantineExpectedBaseline(
|
|
1010
|
+
ctx: GjcLifecycleContext,
|
|
1011
|
+
identity: GjcBundleIdentity,
|
|
1012
|
+
enabled: boolean,
|
|
1013
|
+
expectedBaseline: string,
|
|
1014
|
+
): Promise<GjcLifecycleResult<GjcToggleResult>> {
|
|
1015
|
+
return await mutateEntry(ctx, identity, entry => {
|
|
1016
|
+
if (baselineFingerprint(entry) !== expectedBaseline) {
|
|
1017
|
+
return { ok: false, error: fail("stale_baseline", "The installed bundle changed since it was reviewed") };
|
|
1018
|
+
}
|
|
1019
|
+
if (entry.enabled === enabled) return { ok: true, value: null };
|
|
1020
|
+
return { ok: true, value: { ...entry, enabled, updatedAt: new Date().toISOString() } };
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export async function getGjcBundleEnablementState(
|
|
1025
|
+
ctx: GjcLifecycleContext,
|
|
1026
|
+
identity: GjcBundleIdentity,
|
|
1027
|
+
): Promise<GjcLifecycleResult<{ enabled: boolean; baseline: string }>> {
|
|
1028
|
+
const registry = await readRegistry(identity.scope, ctx.cwd, { migrate: false });
|
|
1029
|
+
const entry = registry.plugins.find(p => p.name === identity.name);
|
|
1030
|
+
if (!entry) return { ok: false, error: notInstalled(identity) };
|
|
1031
|
+
return { ok: true, value: { enabled: entry.enabled, baseline: baselineFingerprint(entry) } };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
845
1034
|
/**
|
|
846
1035
|
* Enable or disable a whole bundle. Deterministic quarantine blocks enabling;
|
|
847
1036
|
* disabling is always allowed so operators can always de-escalate.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { getAgentDir, pathIsWithin } from "@gajae-code/utils";
|
|
4
|
+
import type { ReadFileOptions } from "../../capability/fs";
|
|
5
|
+
import { readFile } from "../../capability/fs";
|
|
4
6
|
import { GJC_PLUGIN_MANIFEST_FILENAME, GjcPluginLoadError } from "./types";
|
|
5
7
|
|
|
6
8
|
export function gjcPluginUserRoot(): string {
|
|
@@ -28,7 +30,8 @@ function isEnoent(error: unknown): boolean {
|
|
|
28
30
|
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
export async function rootContainsGjcManifest(dir: string): Promise<boolean> {
|
|
33
|
+
export async function rootContainsGjcManifest(dir: string, options?: ReadFileOptions): Promise<boolean> {
|
|
34
|
+
if (options?.isolatedHome) return (await readFile(path.join(dir, GJC_PLUGIN_MANIFEST_FILENAME), options)) !== null;
|
|
32
35
|
try {
|
|
33
36
|
await fs.access(path.join(dir, GJC_PLUGIN_MANIFEST_FILENAME));
|
|
34
37
|
return true;
|
|
@@ -459,6 +459,33 @@ export interface GjcReviewedUpdateToken {
|
|
|
459
459
|
reviewedAt: string;
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
+
export interface LocalRestorePlanV1 {
|
|
463
|
+
schemaVersion: 1;
|
|
464
|
+
kind: "gjc-plugin.restore-artifact";
|
|
465
|
+
identity: GjcBundleIdentity;
|
|
466
|
+
baselineFingerprint: string;
|
|
467
|
+
decisionContextFingerprint: string;
|
|
468
|
+
artifact: {
|
|
469
|
+
status: "present" | "absent" | "unreadable";
|
|
470
|
+
digest?: string;
|
|
471
|
+
};
|
|
472
|
+
writes: false;
|
|
473
|
+
fetch: false;
|
|
474
|
+
locks: false;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface ReviewedRestoreTokenV1 {
|
|
478
|
+
schemaVersion: 1;
|
|
479
|
+
kind: "gjc-plugin.restore-artifact";
|
|
480
|
+
purpose: "restore-artifact";
|
|
481
|
+
identity: GjcBundleIdentity;
|
|
482
|
+
candidateFingerprint: string;
|
|
483
|
+
baselineFingerprint: string;
|
|
484
|
+
decisionContextFingerprint: string;
|
|
485
|
+
artifactFingerprint: string;
|
|
486
|
+
reviewedAt: string;
|
|
487
|
+
}
|
|
488
|
+
|
|
462
489
|
export type GjcLifecycleErrorCode =
|
|
463
490
|
| "already_installed_use_upgrade"
|
|
464
491
|
| "not_installed"
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isEnoent,
|
|
11
11
|
logger,
|
|
12
12
|
} from "@gajae-code/utils";
|
|
13
|
+
import { withFileLock } from "../../config/file-lock";
|
|
13
14
|
import { extractPackageName, parsePluginSpec } from "./parser";
|
|
14
15
|
import type {
|
|
15
16
|
DoctorCheck,
|
|
@@ -48,6 +49,11 @@ function validatePackageName(name: string): void {
|
|
|
48
49
|
// Plugin Manager
|
|
49
50
|
// =============================================================================
|
|
50
51
|
|
|
52
|
+
export type PluginEnablementState =
|
|
53
|
+
| { status: "ok"; enabled: boolean; baseline: string }
|
|
54
|
+
| { status: "not_installed" }
|
|
55
|
+
| { status: "malformed" };
|
|
56
|
+
|
|
51
57
|
export class PluginManager {
|
|
52
58
|
#runtimeConfig: PluginRuntimeConfig | null = null;
|
|
53
59
|
#cwd: string;
|
|
@@ -56,19 +62,179 @@ export class PluginManager {
|
|
|
56
62
|
this.#cwd = cwd;
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Read-only durable-enablement snapshot for one npm plugin, scope-qualified.
|
|
67
|
+
* `"user"` reads the shared runtime lock file (~/.gjc/plugins/gjc-plugins.lock.json);
|
|
68
|
+
* `"project"` reads this project's `.gjc/plugin-overrides.json` disabled list.
|
|
69
|
+
* A malformed underlying file is reported distinctly from "not installed" —
|
|
70
|
+
* neither is silently treated as an empty/enabled state.
|
|
71
|
+
*/
|
|
72
|
+
async getEnablementState(name: string, scope: "user" | "project"): Promise<PluginEnablementState> {
|
|
73
|
+
if (scope === "user") {
|
|
74
|
+
let config: PluginRuntimeConfig;
|
|
75
|
+
try {
|
|
76
|
+
config = await this.#loadRuntimeConfigStrict();
|
|
77
|
+
} catch {
|
|
78
|
+
return { status: "malformed" };
|
|
79
|
+
}
|
|
80
|
+
const current = config.plugins[name];
|
|
81
|
+
if (!current) return { status: "not_installed" };
|
|
82
|
+
return { status: "ok", enabled: current.enabled !== false, baseline: JSON.stringify({ scope, current }) };
|
|
83
|
+
}
|
|
84
|
+
let overrides: ProjectPluginOverrides;
|
|
85
|
+
try {
|
|
86
|
+
overrides = await this.#loadProjectOverridesStrict();
|
|
87
|
+
} catch {
|
|
88
|
+
return { status: "malformed" };
|
|
89
|
+
}
|
|
90
|
+
// Project scope has no independent "installed" concept (it modifies the
|
|
91
|
+
// same npm-managed package the user scope tracks); absence of a user
|
|
92
|
+
// entry means there is nothing this project scope could be disabling.
|
|
93
|
+
let config: PluginRuntimeConfig;
|
|
94
|
+
try {
|
|
95
|
+
config = await this.#loadRuntimeConfigStrict();
|
|
96
|
+
} catch {
|
|
97
|
+
return { status: "malformed" };
|
|
98
|
+
}
|
|
99
|
+
if (!config.plugins[name]) return { status: "not_installed" };
|
|
100
|
+
const disabled = [...(overrides.disabled ?? [])].sort();
|
|
101
|
+
return { status: "ok", enabled: !disabled.includes(name), baseline: JSON.stringify({ scope, disabled }) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Durable enablement toggle used by doctor quarantine and the plugin CLI.
|
|
106
|
+
* No install/fetch/reinstall occurs — this only flips the durable enabled
|
|
107
|
+
* flag a future session-startup load (`getEnabledPlugins`) consults: the
|
|
108
|
+
* user-scope runtime lock file, or the project's disabled-list override.
|
|
109
|
+
* Serialized under the canonical native-identity-bound file lock shared
|
|
110
|
+
* with every other writer of the same file (bounded retry/timeout,
|
|
111
|
+
* owner-safe release — never an unbounded wait, never a blind `rm`).
|
|
112
|
+
* `expectedBaseline` is required: a caller with no prior
|
|
113
|
+
* {@link getEnablementState} read has no CAS authority to mutate.
|
|
114
|
+
*/
|
|
115
|
+
async setEnabled(
|
|
116
|
+
name: string,
|
|
117
|
+
enabled: boolean,
|
|
118
|
+
scope: "user" | "project",
|
|
119
|
+
expectedBaseline: string,
|
|
120
|
+
): Promise<{ status: "updated" | "not_needed"; enabled: boolean; version?: string }> {
|
|
121
|
+
if (scope === "project") return await this.#setProjectEnabled(name, enabled, expectedBaseline);
|
|
122
|
+
const lockfilePath = getPluginsLockfile();
|
|
123
|
+
return await withFileLock(lockfilePath, async () => {
|
|
124
|
+
this.#runtimeConfig = null;
|
|
125
|
+
let config: PluginRuntimeConfig;
|
|
126
|
+
try {
|
|
127
|
+
config = await this.#loadRuntimeConfigStrict();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw Object.assign(new Error(`Plugin runtime config is malformed at ${lockfilePath}`), {
|
|
130
|
+
code: "malformed_registry",
|
|
131
|
+
cause: error,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const current = config.plugins[name];
|
|
135
|
+
if (!current) throw Object.assign(new Error(`Plugin "${name}" is not installed`), { code: "not_installed" });
|
|
136
|
+
const baseline = JSON.stringify({ scope: "user" as const, current });
|
|
137
|
+
if (baseline !== expectedBaseline)
|
|
138
|
+
throw Object.assign(new Error("The installed plugin entry changed since it was reviewed"), {
|
|
139
|
+
code: "stale_baseline",
|
|
140
|
+
});
|
|
141
|
+
if (current.enabled === enabled) return { status: "not_needed" as const, enabled, version: current.version };
|
|
142
|
+
config.plugins[name] = { ...current, enabled };
|
|
143
|
+
this.#runtimeConfig = config;
|
|
144
|
+
await this.#saveRuntimeConfig();
|
|
145
|
+
return { status: "updated" as const, enabled, version: current.version };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Project-scoped disable: adds/removes `name` from the project's
|
|
151
|
+
* `.gjc/plugin-overrides.json` disabled list. Serialized under that exact
|
|
152
|
+
* file's canonical file lock so an ordinary override edit and this doctor
|
|
153
|
+
* toggle cannot race and clobber each other's sibling entries.
|
|
154
|
+
*/
|
|
155
|
+
async #setProjectEnabled(
|
|
156
|
+
name: string,
|
|
157
|
+
enabled: boolean,
|
|
158
|
+
expectedBaseline: string,
|
|
159
|
+
): Promise<{ status: "updated" | "not_needed"; enabled: boolean }> {
|
|
160
|
+
const overridesPath = getProjectPluginOverridesPath(this.#cwd);
|
|
161
|
+
return await withFileLock(overridesPath, async () => {
|
|
162
|
+
let overrides: ProjectPluginOverrides;
|
|
163
|
+
try {
|
|
164
|
+
overrides = await this.#loadProjectOverridesStrict();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw Object.assign(new Error(`Project plugin overrides are malformed at ${overridesPath}`), {
|
|
167
|
+
code: "malformed_registry",
|
|
168
|
+
cause: error,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const disabled = [...(overrides.disabled ?? [])].sort();
|
|
172
|
+
const baseline = JSON.stringify({ scope: "project" as const, disabled });
|
|
173
|
+
if (baseline !== expectedBaseline)
|
|
174
|
+
throw Object.assign(new Error("The project plugin overrides changed since they were reviewed"), {
|
|
175
|
+
code: "stale_baseline",
|
|
176
|
+
});
|
|
177
|
+
const wasDisabled = disabled.includes(name);
|
|
178
|
+
if (wasDisabled === !enabled) return { status: "not_needed" as const, enabled };
|
|
179
|
+
const nextDisabled = enabled ? disabled.filter(n => n !== name) : [...new Set([...disabled, name])].sort();
|
|
180
|
+
const next: ProjectPluginOverrides = { ...overrides, disabled: nextDisabled };
|
|
181
|
+
await fs.promises.mkdir(path.dirname(overridesPath), { recursive: true });
|
|
182
|
+
const tmpPath = `${overridesPath}.tmp-${process.pid}-${Date.now()}`;
|
|
183
|
+
await fs.promises.writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
184
|
+
await fs.promises.rename(tmpPath, overridesPath);
|
|
185
|
+
return { status: "updated" as const, enabled };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
59
189
|
// ==========================================================================
|
|
60
190
|
// Runtime Config Management
|
|
61
191
|
// ==========================================================================
|
|
62
192
|
|
|
63
193
|
async #loadRuntimeConfig(): Promise<PluginRuntimeConfig> {
|
|
194
|
+
try {
|
|
195
|
+
return await this.#loadRuntimeConfigStrict();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.warn("Failed to load plugin runtime config", { path: getPluginsLockfile(), error: String(err) });
|
|
198
|
+
return { plugins: {}, settings: {} };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Strict variant: a missing lock file is legitimately empty (no plugins
|
|
204
|
+
* installed yet), but malformed JSON must never be silently coerced into
|
|
205
|
+
* an empty config — a mutating writer built on that would overwrite (and
|
|
206
|
+
* lose) an unreadable but non-empty runtime config.
|
|
207
|
+
*/
|
|
208
|
+
async #loadRuntimeConfigStrict(): Promise<PluginRuntimeConfig> {
|
|
64
209
|
const lockPath = getPluginsLockfile();
|
|
210
|
+
let text: string;
|
|
65
211
|
try {
|
|
66
|
-
|
|
212
|
+
text = await Bun.file(lockPath).text();
|
|
67
213
|
} catch (err) {
|
|
68
214
|
if (isEnoent(err)) return { plugins: {}, settings: {} };
|
|
69
|
-
|
|
70
|
-
return { plugins: {}, settings: {} };
|
|
215
|
+
throw err;
|
|
71
216
|
}
|
|
217
|
+
let parsed: unknown;
|
|
218
|
+
try {
|
|
219
|
+
parsed = JSON.parse(text);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw Object.assign(new Error(`Plugin runtime config is malformed at ${lockPath}`), {
|
|
222
|
+
code: "malformed_registry",
|
|
223
|
+
cause: error,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
typeof parsed !== "object" ||
|
|
228
|
+
parsed === null ||
|
|
229
|
+
typeof (parsed as PluginRuntimeConfig).plugins !== "object" ||
|
|
230
|
+
(parsed as PluginRuntimeConfig).plugins === null
|
|
231
|
+
) {
|
|
232
|
+
throw Object.assign(new Error(`Plugin runtime config has an unsupported shape at ${lockPath}`), {
|
|
233
|
+
code: "malformed_registry",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const config = parsed as PluginRuntimeConfig;
|
|
237
|
+
return { plugins: config.plugins ?? {}, settings: config.settings ?? {} };
|
|
72
238
|
}
|
|
73
239
|
|
|
74
240
|
async #ensureConfigLoaded(): Promise<PluginRuntimeConfig> {
|
|
@@ -84,14 +250,42 @@ export class PluginManager {
|
|
|
84
250
|
}
|
|
85
251
|
|
|
86
252
|
async #loadProjectOverrides(): Promise<ProjectPluginOverrides> {
|
|
253
|
+
try {
|
|
254
|
+
return await this.#loadProjectOverridesStrict();
|
|
255
|
+
} catch (err) {
|
|
256
|
+
logger.warn("Failed to load project plugin overrides", {
|
|
257
|
+
path: getProjectPluginOverridesPath(this.#cwd),
|
|
258
|
+
error: String(err),
|
|
259
|
+
});
|
|
260
|
+
return {};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Strict variant: missing is empty; malformed JSON/shape throws. */
|
|
265
|
+
async #loadProjectOverridesStrict(): Promise<ProjectPluginOverrides> {
|
|
87
266
|
const overridesPath = getProjectPluginOverridesPath(this.#cwd);
|
|
267
|
+
let text: string;
|
|
88
268
|
try {
|
|
89
|
-
|
|
269
|
+
text = await Bun.file(overridesPath).text();
|
|
90
270
|
} catch (err) {
|
|
91
271
|
if (isEnoent(err)) return {};
|
|
92
|
-
|
|
93
|
-
return {};
|
|
272
|
+
throw err;
|
|
94
273
|
}
|
|
274
|
+
let parsed: unknown;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(text);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw Object.assign(new Error(`Project plugin overrides are malformed at ${overridesPath}`), {
|
|
279
|
+
code: "malformed_registry",
|
|
280
|
+
cause: error,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
284
|
+
throw Object.assign(new Error(`Project plugin overrides have an unsupported shape at ${overridesPath}`), {
|
|
285
|
+
code: "malformed_registry",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return parsed as ProjectPluginOverrides;
|
|
95
289
|
}
|
|
96
290
|
|
|
97
291
|
// ==========================================================================
|
|
@@ -383,22 +577,6 @@ export class PluginManager {
|
|
|
383
577
|
};
|
|
384
578
|
}
|
|
385
579
|
|
|
386
|
-
// ==========================================================================
|
|
387
|
-
// Enable / Disable
|
|
388
|
-
// ==========================================================================
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Enable or disable a plugin globally.
|
|
392
|
-
*/
|
|
393
|
-
async setEnabled(name: string, enabled: boolean): Promise<void> {
|
|
394
|
-
const config = await this.#ensureConfigLoaded();
|
|
395
|
-
if (!config.plugins[name]) {
|
|
396
|
-
throw new Error(`Plugin ${name} not found in runtime config`);
|
|
397
|
-
}
|
|
398
|
-
config.plugins[name].enabled = enabled;
|
|
399
|
-
await this.#saveRuntimeConfig();
|
|
400
|
-
}
|
|
401
|
-
|
|
402
580
|
// ==========================================================================
|
|
403
581
|
// Features
|
|
404
582
|
// ==========================================================================
|
|
@@ -10,12 +10,11 @@
|
|
|
10
10
|
* This ensures cache paths cannot be crafted to escape the cache directory.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
13
14
|
import * as nodeFs from "node:fs";
|
|
14
15
|
import * as fs from "node:fs/promises";
|
|
15
16
|
import * as path from "node:path";
|
|
16
|
-
|
|
17
17
|
import { isEnoent } from "@gajae-code/utils";
|
|
18
|
-
|
|
19
18
|
import { isValidNameSegment } from "./types";
|
|
20
19
|
|
|
21
20
|
// Reject anything that could be used for path traversal or shell injection in
|
|
@@ -88,6 +87,60 @@ export async function cachePlugin(
|
|
|
88
87
|
return targetPath;
|
|
89
88
|
}
|
|
90
89
|
|
|
90
|
+
export async function stageCachedPlugin(sourcePath: string, stagingPath: string): Promise<string> {
|
|
91
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
92
|
+
await fs.cp(sourcePath, stagingPath, { recursive: true, errorOnExist: true, verbatimSymlinks: true });
|
|
93
|
+
return stagingPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function digestCachedPlugin(targetPath: string): Promise<string | undefined> {
|
|
97
|
+
const hash = createHash("sha256");
|
|
98
|
+
const walk = async (root: string, rel = ""): Promise<void> => {
|
|
99
|
+
const entries = (await fs.readdir(root, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const childRel = rel ? path.join(rel, entry.name) : entry.name;
|
|
102
|
+
const child = path.join(root, entry.name);
|
|
103
|
+
const stat = await fs.lstat(child);
|
|
104
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error("unsafe cache artifact");
|
|
105
|
+
// Reject a regular file with more than one hard link: its bytes could be
|
|
106
|
+
// mutated through a second pathname we never inspected, so a digest taken
|
|
107
|
+
// here would not be provably bound to the artifact this walk actually saw.
|
|
108
|
+
// Directory entries never carry meaningful hard-link counts on POSIX
|
|
109
|
+
// (nlink for a directory reflects '.'/subdir back-references), so this
|
|
110
|
+
// check applies only to regular files.
|
|
111
|
+
if (stat.isFile() && stat.nlink > 1) throw new Error("unsafe cache artifact: multiply-linked file");
|
|
112
|
+
if (stat.isDirectory()) await walk(child, childRel);
|
|
113
|
+
else {
|
|
114
|
+
hash.update(childRel);
|
|
115
|
+
hash.update("\0");
|
|
116
|
+
hash.update(await fs.readFile(child));
|
|
117
|
+
hash.update("\0");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
await walk(targetPath);
|
|
123
|
+
return hash.digest("hex");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
hash.digest();
|
|
126
|
+
if (isEnoent(error)) return undefined;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function inspectCachedPlugin(
|
|
132
|
+
targetPath: string,
|
|
133
|
+
): Promise<{ status: "present" | "absent" | "unreadable"; digest?: string }> {
|
|
134
|
+
try {
|
|
135
|
+
const stat = await fs.lstat(targetPath);
|
|
136
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return { status: "unreadable" };
|
|
137
|
+
const digest = await digestCachedPlugin(targetPath);
|
|
138
|
+
return digest ? { status: "present", digest } : { status: "unreadable" };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return isEnoent(error) ? { status: "absent" } : { status: "unreadable" };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
91
144
|
/**
|
|
92
145
|
* Synchronous check — true when the cache directory exists on disk.
|
|
93
146
|
* Uses `existsSync` because callers may need to run this check inline without async.
|