@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
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
import * as fsp from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { Settings } from "../../config/settings";
|
|
6
|
+
import { GJC_BUNDLE_KIND, type ReviewedRestoreTokenV1 } from "../../extensibility/gjc-plugins/types";
|
|
7
|
+
import {
|
|
8
|
+
type MarketplaceManager,
|
|
9
|
+
type MarketplaceRestoreApplyResult,
|
|
10
|
+
MarketplaceRestoreError,
|
|
11
|
+
type MarketplaceReviewedRestoreTokenV1,
|
|
12
|
+
} from "../../extensibility/plugins/marketplace/manager";
|
|
13
|
+
import { restartBrokerForDoctor } from "../../sdk/broker/doctor-restart";
|
|
14
|
+
import { restartDaemonForDoctor } from "../../sdk/bus/doctor-daemon-restart-client";
|
|
15
|
+
import { type DoctorAction, type DoctorConfirmationResult, isDoctorAction } from "./args";
|
|
16
|
+
import type { DoctorConfigRepairRequest, DoctorConfigRepairResult } from "./config-repairs";
|
|
17
|
+
import type { DoctorContext, DoctorPluginTarget } from "./context";
|
|
18
|
+
import { repairStandaloneBinary } from "./install-repairs";
|
|
19
|
+
import { repairManagedLink } from "./managed-link";
|
|
20
|
+
import { applyPluginQuarantine } from "./plugin-quarantine";
|
|
21
|
+
import { runPluginRestoreAction } from "./plugin-restore";
|
|
22
|
+
import { createDoctorMarketplaceManager } from "./plugin-targets";
|
|
23
|
+
import { parseServiceRecord, readDoctorService, serviceArtifactObservation } from "./service-targets";
|
|
24
|
+
import type { DoctorCheck, DoctorRepair, DoctorRiskClass, ReadinessFactCode, RepairCandidate } from "./types";
|
|
25
|
+
|
|
26
|
+
const loadConfigRepair = () => import("./config-repairs");
|
|
27
|
+
const loadPermissionRepair = () => import("./permission-repairs");
|
|
28
|
+
const loadServiceRepair = () => import("./service-repairs");
|
|
29
|
+
|
|
30
|
+
export interface DoctorSelectedRepairResult {
|
|
31
|
+
readonly repair: DoctorRepair;
|
|
32
|
+
readonly invocationError?: string;
|
|
33
|
+
readonly afterChecks?: readonly DoctorCheck[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function risksForAction(action: DoctorAction): DoctorRiskClass[] {
|
|
37
|
+
switch (action) {
|
|
38
|
+
case "config.set-validated":
|
|
39
|
+
case "mcp.set-startup-policy":
|
|
40
|
+
return ["config-change"];
|
|
41
|
+
case "permissions.restrict-owned-config":
|
|
42
|
+
return ["permission-change"];
|
|
43
|
+
case "install.restore-binary":
|
|
44
|
+
return ["install-replace", "network", "external-execution"];
|
|
45
|
+
case "install.repair-managed-link":
|
|
46
|
+
return ["install-replace"];
|
|
47
|
+
case "plugin.restore-known-artifact":
|
|
48
|
+
// The lifecycle authorizer itself requires both; the plan must demand what it
|
|
49
|
+
// enforces. The marketplace family resolves a git source and therefore needs
|
|
50
|
+
// `network` too, which planSelectedDoctorRepair appends once the target is
|
|
51
|
+
// known — this base set alone is not the full enforced set for that family.
|
|
52
|
+
return ["plugin-change", "install-replace"];
|
|
53
|
+
case "plugin.quarantine-selected":
|
|
54
|
+
return ["plugin-change"];
|
|
55
|
+
case "service.restart-owned":
|
|
56
|
+
return ["service-interruption"];
|
|
57
|
+
case "service.detach-owned-stale-artifact":
|
|
58
|
+
return ["artifact-detach"];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Actions whose apply path is fenced by the crash-recovery journal.
|
|
64
|
+
*
|
|
65
|
+
* The journal's durability primitives (`O_NOFOLLOW`/`flock`-class locking plus
|
|
66
|
+
* fsync of the file and its directory) are only implemented and exercised for
|
|
67
|
+
* Linux and macOS; `DoctorJournalAuthority::createExact` answers
|
|
68
|
+
* `unsupported_platform` on every other host — Windows and the other Unix
|
|
69
|
+
* platforms (FreeBSD, OpenBSD, …) alike. Without the journal there is no record
|
|
70
|
+
* that survives a crash mid-apply, so these repairs are refused up front on
|
|
71
|
+
* unsupported hosts rather than failing after the operator has already
|
|
72
|
+
* authorized a risk class. Diagnosis and `--dry-run` are unaffected — they never
|
|
73
|
+
* open a journal.
|
|
74
|
+
*
|
|
75
|
+
* `install.restore-binary`, `plugin.restore-known-artifact`, and
|
|
76
|
+
* `service.restart-owned` are deliberately absent: they are fenced by their own
|
|
77
|
+
* activation/ownership protocols, not by the journal.
|
|
78
|
+
*/
|
|
79
|
+
const JOURNAL_BACKED_ACTIONS = new Set<DoctorAction>([
|
|
80
|
+
"config.set-validated",
|
|
81
|
+
"mcp.set-startup-policy",
|
|
82
|
+
"permissions.restrict-owned-config",
|
|
83
|
+
"install.repair-managed-link",
|
|
84
|
+
"plugin.quarantine-selected",
|
|
85
|
+
"service.detach-owned-stale-artifact",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether this host can run the journal-fenced repair lanes at all. Only Linux
|
|
90
|
+
* and macOS carry the native journal implementation, so a journal-backed action
|
|
91
|
+
* is refused before planning/effect on every other platform.
|
|
92
|
+
*/
|
|
93
|
+
export function journalRepairSupported(action: DoctorAction, platform: NodeJS.Platform = process.platform): boolean {
|
|
94
|
+
if (!JOURNAL_BACKED_ACTIONS.has(action)) return true;
|
|
95
|
+
return platform === "linux" || platform === "darwin";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A refusal describing only what was requested; no live target, lock, journal, or candidate is read. */
|
|
99
|
+
function refusedRepair(
|
|
100
|
+
context: DoctorContext,
|
|
101
|
+
before: readonly DoctorCheck[],
|
|
102
|
+
action: DoctorAction,
|
|
103
|
+
targetId: string,
|
|
104
|
+
reasonCode: string,
|
|
105
|
+
readiness: readonly ReadinessFactCode[],
|
|
106
|
+
preconditions: readonly string[],
|
|
107
|
+
): DoctorRepair {
|
|
108
|
+
return {
|
|
109
|
+
id: action,
|
|
110
|
+
targetId,
|
|
111
|
+
riskClasses: risksForAction(action),
|
|
112
|
+
authorization: [...context.options.allowRisks],
|
|
113
|
+
readiness: [...readiness],
|
|
114
|
+
candidates: [],
|
|
115
|
+
preconditions: [...preconditions],
|
|
116
|
+
state: "blocked",
|
|
117
|
+
reasonCode,
|
|
118
|
+
sideEffectStarted: false,
|
|
119
|
+
beforeCheckIds: before.filter(check => check.targetId === targetId).map(check => check.id),
|
|
120
|
+
afterCheckIds: [],
|
|
121
|
+
restartRequired: false,
|
|
122
|
+
restartScope: "none",
|
|
123
|
+
nonrollbackableEffects: [],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Refuse the selected action because the diagnosis it would be planned against
|
|
129
|
+
* never settled.
|
|
130
|
+
*
|
|
131
|
+
* A collector that lost its timeout race is not stopped — it keeps running and
|
|
132
|
+
* keeps writing into the shared {@link DoctorContext}. Planning there would read
|
|
133
|
+
* a half-populated target map, so the action is refused before
|
|
134
|
+
* {@link planSelectedDoctorRepair} ever touches that state. Diagnosis output is
|
|
135
|
+
* unaffected: it still reports everything that was collected.
|
|
136
|
+
*/
|
|
137
|
+
export function refuseUnsettledDoctorRepair(
|
|
138
|
+
context: DoctorContext,
|
|
139
|
+
before: readonly DoctorCheck[],
|
|
140
|
+
): DoctorRepair | undefined {
|
|
141
|
+
const { repair: action, targetId } = context.options;
|
|
142
|
+
if (!action || !targetId) return undefined;
|
|
143
|
+
return refusedRepair(
|
|
144
|
+
context,
|
|
145
|
+
before,
|
|
146
|
+
action,
|
|
147
|
+
targetId,
|
|
148
|
+
"incomplete_diagnostics",
|
|
149
|
+
["target_resolution_incomplete"],
|
|
150
|
+
["settled diagnosis for every selected collector"],
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Plan data is descriptive only; no domain module, lock, journal, or candidate is materialized here. */
|
|
155
|
+
export function planSelectedDoctorRepair(
|
|
156
|
+
context: DoctorContext,
|
|
157
|
+
before: readonly DoctorCheck[],
|
|
158
|
+
): DoctorRepair | undefined {
|
|
159
|
+
const { repair: action, targetId } = context.options;
|
|
160
|
+
if (!action || !targetId) return undefined;
|
|
161
|
+
const riskClasses = [...risksForAction(action)];
|
|
162
|
+
const readiness: ReadinessFactCode[] = ["identity_recheck_required"];
|
|
163
|
+
let reasonCode: string | undefined;
|
|
164
|
+
if (!journalRepairSupported(action))
|
|
165
|
+
return refusedRepair(
|
|
166
|
+
context,
|
|
167
|
+
before,
|
|
168
|
+
action,
|
|
169
|
+
targetId,
|
|
170
|
+
"unsupported_platform",
|
|
171
|
+
["unsupported"],
|
|
172
|
+
["journal-backed crash recovery"],
|
|
173
|
+
);
|
|
174
|
+
if (
|
|
175
|
+
action === "config.set-validated" ||
|
|
176
|
+
action === "mcp.set-startup-policy" ||
|
|
177
|
+
action === "permissions.restrict-owned-config"
|
|
178
|
+
) {
|
|
179
|
+
const expectedKind =
|
|
180
|
+
action === "config.set-validated" ? "config" : action === "mcp.set-startup-policy" ? "mcp" : "permission";
|
|
181
|
+
const target = context.targets.get(targetId);
|
|
182
|
+
if (target && target.kind !== expectedKind) reasonCode = "action_target_mismatch";
|
|
183
|
+
if (
|
|
184
|
+
(target?.kind === "mcp" && !target.definitionIsMapping) ||
|
|
185
|
+
before.some(
|
|
186
|
+
check =>
|
|
187
|
+
check.targetId === targetId &&
|
|
188
|
+
["config_parse_error", "config_root_not_mapping", "config_skills_not_mapping"].includes(
|
|
189
|
+
check.reasonCode ?? "",
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
) {
|
|
193
|
+
reasonCode = "unsupported_target_shape";
|
|
194
|
+
readiness.push("unsupported");
|
|
195
|
+
}
|
|
196
|
+
if (target && !before.some(check => check.targetId === targetId && check.execution === "completed")) {
|
|
197
|
+
reasonCode = "target_resolution_incomplete";
|
|
198
|
+
readiness.push("target_resolution_incomplete");
|
|
199
|
+
}
|
|
200
|
+
if (!target) {
|
|
201
|
+
const source = context.sources.find(item => item.root.rootId === targetId.split(":")[2]);
|
|
202
|
+
const domainChecks = before.filter(
|
|
203
|
+
check => check.id.startsWith(`${action.split(".")[0]}.`) && (!source || check.scope === source.scope),
|
|
204
|
+
);
|
|
205
|
+
const incomplete =
|
|
206
|
+
source?.root.resolution !== "resolved" ||
|
|
207
|
+
domainChecks.some(
|
|
208
|
+
check =>
|
|
209
|
+
check.execution !== "completed" ||
|
|
210
|
+
check.reasonCode === "config_parse_error" ||
|
|
211
|
+
check.reasonCode === "config_root_not_mapping" ||
|
|
212
|
+
check.reasonCode === "mcp_servers_not_mapping",
|
|
213
|
+
);
|
|
214
|
+
reasonCode = incomplete ? "target_resolution_incomplete" : "unknown_target";
|
|
215
|
+
if (incomplete) readiness.push("target_resolution_incomplete");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (action === "service.restart-owned" || action === "service.detach-owned-stale-artifact") {
|
|
219
|
+
const target = context.targets.get(targetId);
|
|
220
|
+
const kind = action === "service.restart-owned" ? "service" : "artifact";
|
|
221
|
+
if (!target) {
|
|
222
|
+
const serviceChecks = before.filter(check => check.id.startsWith("service."));
|
|
223
|
+
const complete =
|
|
224
|
+
targetId.split(":")[2] === context.agentRoot.rootId &&
|
|
225
|
+
context.agentRoot.resolution === "resolved" &&
|
|
226
|
+
serviceChecks.length > 0 &&
|
|
227
|
+
serviceChecks.every(check => check.execution === "completed");
|
|
228
|
+
reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
|
|
229
|
+
if (!complete) readiness.push("target_resolution_incomplete");
|
|
230
|
+
} else if (target.kind !== kind) reasonCode = "action_target_mismatch";
|
|
231
|
+
else if (
|
|
232
|
+
target.kind === "artifact" &&
|
|
233
|
+
target.slot &&
|
|
234
|
+
!serviceArtifactObservation(target.observation, target.slot)
|
|
235
|
+
) {
|
|
236
|
+
reasonCode = "unsupported_slot";
|
|
237
|
+
readiness.push("unsupported");
|
|
238
|
+
} else if (!before.some(check => check.targetId === targetId && check.execution === "completed")) {
|
|
239
|
+
reasonCode = "target_resolution_incomplete";
|
|
240
|
+
readiness.push("target_resolution_incomplete");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (action === "plugin.quarantine-selected" || action === "plugin.restore-known-artifact") {
|
|
244
|
+
const target = context.targets.get(targetId);
|
|
245
|
+
if (!target) {
|
|
246
|
+
const domain = before.filter(check => check.id.startsWith("plugin."));
|
|
247
|
+
const complete =
|
|
248
|
+
context.sources.some(
|
|
249
|
+
source => source.root.rootId === targetId.split(":")[2] && source.root.resolution === "resolved",
|
|
250
|
+
) &&
|
|
251
|
+
domain.length > 0 &&
|
|
252
|
+
domain.every(check => check.execution === "completed" && check.reasonCode !== "plugin_registry_invalid");
|
|
253
|
+
reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
|
|
254
|
+
if (!complete) readiness.push("target_resolution_incomplete");
|
|
255
|
+
} else if (target.kind !== "plugin") reasonCode = "action_target_mismatch";
|
|
256
|
+
else if (
|
|
257
|
+
action === "plugin.quarantine-selected" &&
|
|
258
|
+
(!target.quarantinePlan || target.quarantinePlan.status === "blocked")
|
|
259
|
+
) {
|
|
260
|
+
reasonCode = target.quarantinePlan?.reason ?? "target_resolution_incomplete";
|
|
261
|
+
readiness.push("target_resolution_incomplete");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (action === "install.repair-managed-link") {
|
|
265
|
+
const target = context.targets.get(targetId);
|
|
266
|
+
if (!target) {
|
|
267
|
+
const complete = before.some(
|
|
268
|
+
check => check.id.startsWith("installation.link.") && check.execution === "completed",
|
|
269
|
+
);
|
|
270
|
+
reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
|
|
271
|
+
if (!complete) readiness.push("target_resolution_incomplete");
|
|
272
|
+
} else if (target.kind !== "link") reasonCode = "action_target_mismatch";
|
|
273
|
+
else if (!target.linkDescriptor?.receiptTrusted) {
|
|
274
|
+
reasonCode = target.linkDescriptor?.reasonCode ?? "ownership_receipt_untrusted";
|
|
275
|
+
readiness.push("unsupported");
|
|
276
|
+
} else if (
|
|
277
|
+
context.options.ref &&
|
|
278
|
+
!target.linkDescriptor.candidates.some(candidate => candidate.id === context.options.ref)
|
|
279
|
+
)
|
|
280
|
+
readiness.push("candidate_unresolved");
|
|
281
|
+
}
|
|
282
|
+
const restoreCandidates: RepairCandidate[] = [];
|
|
283
|
+
if (action === "plugin.restore-known-artifact") {
|
|
284
|
+
const target = context.targets.get(targetId);
|
|
285
|
+
// The marketplace lane resolves a git source, so its authorizer requires
|
|
286
|
+
// `network` on top of the shared classes. Declaring it here surfaces
|
|
287
|
+
// `authorization_missing` pre-effect instead of a mid-action refusal.
|
|
288
|
+
if (target?.kind === "plugin" && target.family === "marketplace") riskClasses.push("network");
|
|
289
|
+
if (!target) {
|
|
290
|
+
const complete = before.some(check => check.id.startsWith("plugin.") && check.execution === "completed");
|
|
291
|
+
reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
|
|
292
|
+
if (!complete) readiness.push("target_resolution_incomplete");
|
|
293
|
+
} else if (target.kind !== "plugin") reasonCode = "action_target_mismatch";
|
|
294
|
+
else if (target.family === "npm") {
|
|
295
|
+
// One flat shared node_modules layout cannot bound a per-plugin artifact restore.
|
|
296
|
+
reasonCode = "unsupported_shared_layout";
|
|
297
|
+
readiness.push("unsupported");
|
|
298
|
+
} else if (!target.restoreRef) {
|
|
299
|
+
reasonCode = "source_unpinnable";
|
|
300
|
+
readiness.push("unsupported");
|
|
301
|
+
} else if (target.restoreArtifactDigest)
|
|
302
|
+
// What `sha256` names depends on the channel, and an operator pastes it
|
|
303
|
+
// verbatim into `--sha256`. For "stored-source" it is the artifact-tree digest
|
|
304
|
+
// of the stored source; for "catalog" it is the marketplace catalog
|
|
305
|
+
// fingerprint, because the artifact being repaired cannot pin itself — there
|
|
306
|
+
// the installed BYTES are bound instead by the apply path recomputing the
|
|
307
|
+
// staged digest. Either way the registry's baseline fingerprint is deliberately
|
|
308
|
+
// NOT used: it mixes in `source.resolvedAt`, so it changes per install and can
|
|
309
|
+
// never be authorized ahead of time.
|
|
310
|
+
restoreCandidates.push({
|
|
311
|
+
id: target.restoreRef,
|
|
312
|
+
sourceChannel: target.family === "marketplace" ? "catalog" : "stored-source",
|
|
313
|
+
ref: target.restoreRef,
|
|
314
|
+
sha256: target.restoreArtifactDigest,
|
|
315
|
+
evidence: {
|
|
316
|
+
artifactStatus:
|
|
317
|
+
target.restorePlan?.artifact.status ?? target.marketplaceRestorePlan?.artifact.status ?? "absent",
|
|
318
|
+
sourceClass: target.family,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (action === "install.restore-binary") {
|
|
323
|
+
const target = context.targets.get(targetId);
|
|
324
|
+
if (!target) {
|
|
325
|
+
reasonCode =
|
|
326
|
+
context.installRoot.resolution === "resolved" &&
|
|
327
|
+
before.some(check => check.id === "installation.current" && check.execution === "completed")
|
|
328
|
+
? "unknown_target"
|
|
329
|
+
: "target_resolution_incomplete";
|
|
330
|
+
if (reasonCode === "target_resolution_incomplete") readiness.push("target_resolution_incomplete");
|
|
331
|
+
} else if (target.kind !== "binary") reasonCode = "action_target_mismatch";
|
|
332
|
+
else if (!target.installDescriptor?.owned) {
|
|
333
|
+
reasonCode = "source_not_owned_standalone";
|
|
334
|
+
readiness.push("unsupported");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (riskClasses.some(risk => !context.options.allowRisks.includes(risk))) readiness.push("authorization_missing");
|
|
338
|
+
if (!context.options.yes && !context.options.tty) readiness.push("confirmation_required");
|
|
339
|
+
if (
|
|
340
|
+
(action === "install.restore-binary" || action === "plugin.restore-known-artifact") &&
|
|
341
|
+
(!context.options.ref || !context.options.sha256)
|
|
342
|
+
)
|
|
343
|
+
readiness.push("pin_missing");
|
|
344
|
+
if (action === "install.repair-managed-link" && !context.options.ref) readiness.push("candidate_selection_missing");
|
|
345
|
+
return {
|
|
346
|
+
id: action,
|
|
347
|
+
targetId,
|
|
348
|
+
riskClasses,
|
|
349
|
+
authorization: [...context.options.allowRisks],
|
|
350
|
+
readiness,
|
|
351
|
+
candidates: restoreCandidates,
|
|
352
|
+
preconditions: ["exact current target", "explicit action risk authorization", "fresh independent postcheck"],
|
|
353
|
+
state: reasonCode ? "blocked" : "planned",
|
|
354
|
+
reasonCode,
|
|
355
|
+
sideEffectStarted: false,
|
|
356
|
+
beforeCheckIds: before.filter(check => check.targetId === targetId).map(check => check.id),
|
|
357
|
+
afterCheckIds: [],
|
|
358
|
+
restartRequired: action === "config.set-validated" || action === "mcp.set-startup-policy",
|
|
359
|
+
restartScope: action === "config.set-validated" || action === "mcp.set-startup-policy" ? "new-session" : "none",
|
|
360
|
+
nonrollbackableEffects: [],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function admitSelectedRepair(context: DoctorContext, plan: DoctorRepair): DoctorSelectedRepairResult | undefined {
|
|
365
|
+
if (context.options.signal?.aborted) return { repair: { ...plan, state: "blocked", reasonCode: "cancelled" } };
|
|
366
|
+
if (performance.now() >= context.deadline)
|
|
367
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "deadline_exceeded" } };
|
|
368
|
+
try {
|
|
369
|
+
context.options.onRepairAdmitted?.();
|
|
370
|
+
} catch {
|
|
371
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "repair_admission_unavailable" } };
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Private-marketplace D6 lane. Same gate as every other action: explicit pins
|
|
378
|
+
* matched against the recorded provenance, confirmation, risk authorization,
|
|
379
|
+
* admission, then an independent post-check. Authorization is the first
|
|
380
|
+
* effect-capable step because it resolves and stages the pinned source.
|
|
381
|
+
*/
|
|
382
|
+
async function executeMarketplaceRestore(
|
|
383
|
+
context: DoctorContext,
|
|
384
|
+
plan: DoctorRepair,
|
|
385
|
+
target: DoctorPluginTarget,
|
|
386
|
+
collectAfterChecks: () => Promise<readonly DoctorCheck[]>,
|
|
387
|
+
): Promise<DoctorSelectedRepairResult> {
|
|
388
|
+
if (!target.restoreRef)
|
|
389
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "source_unpinnable", readiness: ["unsupported"] } };
|
|
390
|
+
if (context.options.ref !== target.restoreRef)
|
|
391
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "candidate_provenance_mismatch" } };
|
|
392
|
+
const original = target.marketplaceRestorePlan;
|
|
393
|
+
if (!original) return { repair: { ...plan, state: "blocked", reasonCode: "original_target_missing" } };
|
|
394
|
+
// The pin binds the catalog entry the operator authorized, which is defined
|
|
395
|
+
// even when the artifact is absent. The candidate BYTES are bound separately
|
|
396
|
+
// and more strongly: the apply path recomputes the resolved source digest
|
|
397
|
+
// itself, so this gate can never be the only thing standing between an
|
|
398
|
+
// operator and unintended content.
|
|
399
|
+
if (
|
|
400
|
+
!target.restoreArtifactDigest ||
|
|
401
|
+
context.options.sha256?.toLowerCase() !== target.restoreArtifactDigest.toLowerCase()
|
|
402
|
+
)
|
|
403
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "candidate_digest_mismatch" } };
|
|
404
|
+
// `artifact.status === "present"` only means a readable tree exists; a corrupt
|
|
405
|
+
// artifact is still present. The lane's own apply performs the authoritative
|
|
406
|
+
// digest comparison and returns a true zero-effect `not_needed`, so no
|
|
407
|
+
// short-circuit is taken here.
|
|
408
|
+
if (!context.options.yes) {
|
|
409
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
410
|
+
if (confirmation !== "confirmed")
|
|
411
|
+
return {
|
|
412
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
416
|
+
if (admissionFailure) return admissionFailure;
|
|
417
|
+
// Authorization resolves the catalog and stages a candidate; `apply` is the
|
|
418
|
+
// first call that can touch installed state. Anything that throws before it —
|
|
419
|
+
// including plain errors from catalog reads or source resolution — is a
|
|
420
|
+
// pre-effect refusal and must never be reported as an uncertain mutation.
|
|
421
|
+
let manager: MarketplaceManager;
|
|
422
|
+
let token: MarketplaceReviewedRestoreTokenV1;
|
|
423
|
+
try {
|
|
424
|
+
manager = await createDoctorMarketplaceManager(context.cwd);
|
|
425
|
+
token = (await runPluginRestoreAction({
|
|
426
|
+
kind: "marketplace-authorize",
|
|
427
|
+
manager,
|
|
428
|
+
plan: original,
|
|
429
|
+
authorizations: context.options.allowRisks,
|
|
430
|
+
})) as MarketplaceReviewedRestoreTokenV1;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return {
|
|
433
|
+
repair: {
|
|
434
|
+
...plan,
|
|
435
|
+
state: "blocked",
|
|
436
|
+
sideEffectStarted: false,
|
|
437
|
+
reasonCode: error instanceof MarketplaceRestoreError ? error.reasonCode : "restore_authorization_failed",
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
// The token must still describe the exact plan the operator authorized.
|
|
443
|
+
if (
|
|
444
|
+
token.purpose !== "restore-artifact" ||
|
|
445
|
+
token.pluginId !== target.name ||
|
|
446
|
+
token.scope !== target.scope ||
|
|
447
|
+
token.baselineFingerprint !== original.baselineFingerprint ||
|
|
448
|
+
token.catalogFingerprint !== original.catalogFingerprint
|
|
449
|
+
)
|
|
450
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "stale_baseline" } };
|
|
451
|
+
const applied = (await runPluginRestoreAction({
|
|
452
|
+
kind: "marketplace-apply",
|
|
453
|
+
manager,
|
|
454
|
+
token,
|
|
455
|
+
})) as MarketplaceRestoreApplyResult;
|
|
456
|
+
const afterChecks = await collectAfterChecks();
|
|
457
|
+
const restored = afterChecks.some(
|
|
458
|
+
check =>
|
|
459
|
+
check.targetId === target.targetId &&
|
|
460
|
+
check.execution === "completed" &&
|
|
461
|
+
check.evidence.artifactStatus === "present",
|
|
462
|
+
);
|
|
463
|
+
const success = (applied.status === "verified" || applied.status === "not_needed") && restored;
|
|
464
|
+
return {
|
|
465
|
+
repair: {
|
|
466
|
+
...plan,
|
|
467
|
+
state: success ? (applied.status === "not_needed" ? "not_needed" : "verified") : "uncertain",
|
|
468
|
+
readiness: [],
|
|
469
|
+
sideEffectStarted: applied.sideEffectStarted,
|
|
470
|
+
reasonCode: success ? undefined : "restore_postcheck_unverified",
|
|
471
|
+
...(success ? { outcome: { mutationVerified: true } } : {}),
|
|
472
|
+
},
|
|
473
|
+
afterChecks,
|
|
474
|
+
};
|
|
475
|
+
} catch (error) {
|
|
476
|
+
// The lane's typed error distinguishes a pre-effect refusal from an
|
|
477
|
+
// effect-started outcome; never collapse the two.
|
|
478
|
+
if (error instanceof MarketplaceRestoreError)
|
|
479
|
+
return {
|
|
480
|
+
repair: {
|
|
481
|
+
...plan,
|
|
482
|
+
state: error.sideEffectStarted
|
|
483
|
+
? error.outcome === "conflict"
|
|
484
|
+
? "rollback_conflict"
|
|
485
|
+
: "uncertain"
|
|
486
|
+
: "blocked",
|
|
487
|
+
sideEffectStarted: error.sideEffectStarted,
|
|
488
|
+
reasonCode: error.reasonCode,
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
return {
|
|
492
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
493
|
+
};
|
|
494
|
+
} finally {
|
|
495
|
+
// Authorization already copied the whole plugin tree into a temp staging dir,
|
|
496
|
+
// and the lane deliberately never deletes it: that would be a write inside a
|
|
497
|
+
// branch which must stay zero-effect, so disposal is the caller's contract.
|
|
498
|
+
// It must happen on EVERY exit, not just the zero-effect one — a refusal
|
|
499
|
+
// leaks just as much as a no-op. On the success path the directory was
|
|
500
|
+
// renamed away, which `force` makes a no-op.
|
|
501
|
+
await fsp.rm(token.stagedArtifactPath, { recursive: true, force: true }).catch(() => {});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export async function executeSelectedDoctorRepair(
|
|
506
|
+
context: DoctorContext,
|
|
507
|
+
runId: string,
|
|
508
|
+
plan: DoctorRepair,
|
|
509
|
+
collectAfterChecks: () => Promise<readonly DoctorCheck[]>,
|
|
510
|
+
): Promise<DoctorSelectedRepairResult> {
|
|
511
|
+
if (context.options.mode !== "fix") return { repair: plan };
|
|
512
|
+
if (plan.state === "blocked")
|
|
513
|
+
return {
|
|
514
|
+
repair: plan,
|
|
515
|
+
...(plan.reasonCode === "unknown_target" || plan.reasonCode === "action_target_mismatch"
|
|
516
|
+
? { invocationError: plan.reasonCode }
|
|
517
|
+
: {}),
|
|
518
|
+
};
|
|
519
|
+
if (isDoctorAction(plan.id) && !journalRepairSupported(plan.id))
|
|
520
|
+
return {
|
|
521
|
+
repair: {
|
|
522
|
+
...plan,
|
|
523
|
+
state: "blocked",
|
|
524
|
+
reasonCode: "unsupported_platform",
|
|
525
|
+
readiness: ["unsupported"],
|
|
526
|
+
sideEffectStarted: false,
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
if (context.options.signal?.aborted) return { repair: { ...plan, state: "blocked", reasonCode: "cancelled" } };
|
|
530
|
+
if (performance.now() >= context.deadline)
|
|
531
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "deadline_exceeded" } };
|
|
532
|
+
if (
|
|
533
|
+
plan.readiness.includes("authorization_missing") ||
|
|
534
|
+
plan.readiness.includes("confirmation_required") ||
|
|
535
|
+
plan.readiness.includes("pin_missing") ||
|
|
536
|
+
plan.readiness.includes("candidate_selection_missing")
|
|
537
|
+
)
|
|
538
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "repair_preflight_incomplete" } };
|
|
539
|
+
if (
|
|
540
|
+
plan.id !== "config.set-validated" &&
|
|
541
|
+
plan.id !== "mcp.set-startup-policy" &&
|
|
542
|
+
plan.id !== "permissions.restrict-owned-config" &&
|
|
543
|
+
plan.id !== "service.detach-owned-stale-artifact" &&
|
|
544
|
+
plan.id !== "plugin.quarantine-selected" &&
|
|
545
|
+
plan.id !== "install.restore-binary" &&
|
|
546
|
+
plan.id !== "install.repair-managed-link" &&
|
|
547
|
+
plan.id !== "service.restart-owned" &&
|
|
548
|
+
plan.id !== "plugin.restore-known-artifact"
|
|
549
|
+
)
|
|
550
|
+
return {
|
|
551
|
+
repair: { ...plan, state: "blocked", reasonCode: "repair_dispatch_unavailable", readiness: ["unsupported"] },
|
|
552
|
+
};
|
|
553
|
+
const target = context.targets.get(plan.targetId);
|
|
554
|
+
if (!target) {
|
|
555
|
+
const knownRoot = context.sources.some(source => plan.targetId.split(":")[2] === source.root.rootId);
|
|
556
|
+
return {
|
|
557
|
+
repair: {
|
|
558
|
+
...plan,
|
|
559
|
+
state: "blocked",
|
|
560
|
+
reasonCode: "target_resolution_incomplete",
|
|
561
|
+
readiness: ["target_resolution_incomplete"],
|
|
562
|
+
},
|
|
563
|
+
...(knownRoot ? { invocationError: "unknown_target" } : {}),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (plan.id === "install.repair-managed-link") {
|
|
567
|
+
if (target.kind !== "link" || !target.linkDescriptor || !context.options.ref)
|
|
568
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "candidate_selection_missing" } };
|
|
569
|
+
if (!context.options.yes) {
|
|
570
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
571
|
+
if (confirmation !== "confirmed")
|
|
572
|
+
return {
|
|
573
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
577
|
+
if (admissionFailure) return admissionFailure;
|
|
578
|
+
try {
|
|
579
|
+
const descriptor = target.linkDescriptor;
|
|
580
|
+
const result = await repairManagedLink(
|
|
581
|
+
{
|
|
582
|
+
targetPath: target.filePath,
|
|
583
|
+
alias: descriptor.alias,
|
|
584
|
+
root: path.resolve(path.dirname(context.cliPath ?? ""), "..", "..", ".."),
|
|
585
|
+
ref: context.options.ref,
|
|
586
|
+
mode: "fix",
|
|
587
|
+
allowRisks: context.options.allowRisks,
|
|
588
|
+
yes: true,
|
|
589
|
+
journalRoot: context.agentRoot.locator,
|
|
590
|
+
runId,
|
|
591
|
+
},
|
|
592
|
+
descriptor,
|
|
593
|
+
);
|
|
594
|
+
const afterChecks = await collectAfterChecks();
|
|
595
|
+
const healthy = afterChecks.some(
|
|
596
|
+
check =>
|
|
597
|
+
check.targetId === target.targetId &&
|
|
598
|
+
check.execution === "completed" &&
|
|
599
|
+
check.evidence.status === "healthy",
|
|
600
|
+
);
|
|
601
|
+
const success = (result.state === "verified" || result.state === "not_needed") && healthy;
|
|
602
|
+
const state: DoctorRepair["state"] = success
|
|
603
|
+
? result.state === "not_needed"
|
|
604
|
+
? "not_needed"
|
|
605
|
+
: "verified"
|
|
606
|
+
: result.state === "rolled_back" || result.state === "rollback_conflict"
|
|
607
|
+
? result.state
|
|
608
|
+
: result.sideEffectStarted
|
|
609
|
+
? "uncertain"
|
|
610
|
+
: "blocked";
|
|
611
|
+
return {
|
|
612
|
+
repair: {
|
|
613
|
+
...plan,
|
|
614
|
+
state,
|
|
615
|
+
readiness: [],
|
|
616
|
+
reasonCode: result.reasonCode ?? (success ? undefined : "managed_link_postcheck_unverified"),
|
|
617
|
+
sideEffectStarted: result.sideEffectStarted,
|
|
618
|
+
...(success ? { outcome: { mutationVerified: true } } : {}),
|
|
619
|
+
},
|
|
620
|
+
afterChecks,
|
|
621
|
+
};
|
|
622
|
+
} catch {
|
|
623
|
+
return {
|
|
624
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (plan.id === "install.restore-binary") {
|
|
629
|
+
if (target.kind !== "binary" || !target.installDescriptor || !context.options.ref || !context.options.sha256)
|
|
630
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "original_snapshot_incomplete" } };
|
|
631
|
+
if (!context.options.yes) {
|
|
632
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
633
|
+
if (confirmation !== "confirmed")
|
|
634
|
+
return {
|
|
635
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
639
|
+
if (admissionFailure) return admissionFailure;
|
|
640
|
+
try {
|
|
641
|
+
const descriptor = target.installDescriptor;
|
|
642
|
+
const result = await repairStandaloneBinary(
|
|
643
|
+
target.filePath,
|
|
644
|
+
descriptor,
|
|
645
|
+
{
|
|
646
|
+
ref: context.options.ref,
|
|
647
|
+
sha256: context.options.sha256,
|
|
648
|
+
channel: descriptor.channel,
|
|
649
|
+
version: descriptor.version,
|
|
650
|
+
os: process.platform,
|
|
651
|
+
arch: process.arch,
|
|
652
|
+
},
|
|
653
|
+
context.options.allowRisks,
|
|
654
|
+
);
|
|
655
|
+
const afterChecks = await collectAfterChecks();
|
|
656
|
+
const checked = afterChecks.some(
|
|
657
|
+
check =>
|
|
658
|
+
check.targetId === target.targetId &&
|
|
659
|
+
check.execution === "completed" &&
|
|
660
|
+
check.evidence.integrityResult === "matches_pin",
|
|
661
|
+
);
|
|
662
|
+
const success = (result.state === "verified" || result.state === "not_needed") && checked;
|
|
663
|
+
const state: DoctorRepair["state"] =
|
|
664
|
+
result.state === "pending_activation"
|
|
665
|
+
? "pending_activation"
|
|
666
|
+
: success
|
|
667
|
+
? result.state === "not_needed"
|
|
668
|
+
? "not_needed"
|
|
669
|
+
: "verified"
|
|
670
|
+
: result.sideEffectStarted
|
|
671
|
+
? "uncertain"
|
|
672
|
+
: "blocked";
|
|
673
|
+
return {
|
|
674
|
+
repair: {
|
|
675
|
+
...plan,
|
|
676
|
+
state,
|
|
677
|
+
readiness: [],
|
|
678
|
+
reasonCode: result.reason ?? (success ? undefined : "installation_postcheck_unverified"),
|
|
679
|
+
sideEffectStarted: result.sideEffectStarted,
|
|
680
|
+
...(success ? { outcome: { mutationVerified: true } } : {}),
|
|
681
|
+
restartRequired: result.state === "pending_activation",
|
|
682
|
+
restartScope: result.state === "pending_activation" ? "new-session" : "none",
|
|
683
|
+
},
|
|
684
|
+
afterChecks,
|
|
685
|
+
};
|
|
686
|
+
} catch {
|
|
687
|
+
return {
|
|
688
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (plan.id === "plugin.quarantine-selected") {
|
|
693
|
+
if (target.kind !== "plugin" || !target.quarantinePlan)
|
|
694
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "target_resolution_incomplete" } };
|
|
695
|
+
if (!context.options.yes) {
|
|
696
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
697
|
+
if (confirmation !== "confirmed")
|
|
698
|
+
return {
|
|
699
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
703
|
+
if (admissionFailure) return admissionFailure;
|
|
704
|
+
try {
|
|
705
|
+
const result = await applyPluginQuarantine(
|
|
706
|
+
{
|
|
707
|
+
family: target.family,
|
|
708
|
+
scope: target.scope,
|
|
709
|
+
name: target.name,
|
|
710
|
+
cwd: context.cwd,
|
|
711
|
+
home: os.homedir(),
|
|
712
|
+
rootId: target.root.rootId,
|
|
713
|
+
registryPath: target.registryPath,
|
|
714
|
+
journalRoot: context.agentRoot.locator,
|
|
715
|
+
runId,
|
|
716
|
+
},
|
|
717
|
+
context.options.allowRisks,
|
|
718
|
+
target.quarantinePlan,
|
|
719
|
+
);
|
|
720
|
+
const afterChecks = await collectAfterChecks();
|
|
721
|
+
const verified =
|
|
722
|
+
result.targetExcludedFromStartup === true &&
|
|
723
|
+
afterChecks.some(
|
|
724
|
+
check =>
|
|
725
|
+
check.targetId === target.targetId &&
|
|
726
|
+
check.execution === "completed" &&
|
|
727
|
+
check.evidence.enabled === false,
|
|
728
|
+
);
|
|
729
|
+
const success = (result.status === "verified" || result.status === "not_needed") && verified;
|
|
730
|
+
const state: DoctorRepair["state"] = success
|
|
731
|
+
? result.status === "not_needed"
|
|
732
|
+
? "not_needed"
|
|
733
|
+
: "verified"
|
|
734
|
+
: result.sideEffectStarted
|
|
735
|
+
? "uncertain"
|
|
736
|
+
: "blocked";
|
|
737
|
+
const reasonCode =
|
|
738
|
+
result.reason && /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(result.reason)
|
|
739
|
+
? result.reason
|
|
740
|
+
: success
|
|
741
|
+
? undefined
|
|
742
|
+
: "plugin_quarantine_unverified";
|
|
743
|
+
return {
|
|
744
|
+
repair: {
|
|
745
|
+
...plan,
|
|
746
|
+
state,
|
|
747
|
+
readiness: [],
|
|
748
|
+
reasonCode,
|
|
749
|
+
sideEffectStarted: result.sideEffectStarted,
|
|
750
|
+
...(success ? { outcome: { mutationVerified: true } } : {}),
|
|
751
|
+
},
|
|
752
|
+
afterChecks,
|
|
753
|
+
};
|
|
754
|
+
} catch {
|
|
755
|
+
return {
|
|
756
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (plan.id === "plugin.restore-known-artifact" && target.kind === "plugin" && target.family === "marketplace") {
|
|
761
|
+
return await executeMarketplaceRestore(context, plan, target, collectAfterChecks);
|
|
762
|
+
}
|
|
763
|
+
if (plan.id === "plugin.restore-known-artifact") {
|
|
764
|
+
if (target.kind !== "plugin")
|
|
765
|
+
return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
|
|
766
|
+
// npm plugins share one flat node_modules layout, so a per-plugin artifact
|
|
767
|
+
// restore cannot be bounded to the selected target. That is the plan's own
|
|
768
|
+
// distinct refusal, not a general family gap.
|
|
769
|
+
if (target.family === "npm")
|
|
770
|
+
return {
|
|
771
|
+
repair: { ...plan, state: "blocked", reasonCode: "unsupported_shared_layout", readiness: ["unsupported"] },
|
|
772
|
+
};
|
|
773
|
+
if (!target.restoreRef)
|
|
774
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "source_unpinnable", readiness: ["unsupported"] } };
|
|
775
|
+
if (context.options.ref !== target.restoreRef)
|
|
776
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "candidate_provenance_mismatch" } };
|
|
777
|
+
const original = target.restorePlan;
|
|
778
|
+
if (!original) return { repair: { ...plan, state: "blocked", reasonCode: "original_target_missing" } };
|
|
779
|
+
// The digest pin binds the exact artifact bytes the operator authorized.
|
|
780
|
+
if (
|
|
781
|
+
!target.restoreArtifactDigest ||
|
|
782
|
+
context.options.sha256?.toLowerCase() !== target.restoreArtifactDigest.toLowerCase()
|
|
783
|
+
)
|
|
784
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "candidate_digest_mismatch" } };
|
|
785
|
+
if (original.artifact.status === "present")
|
|
786
|
+
return { repair: { ...plan, state: "not_needed", reasonCode: "plugin_artifact_present" } };
|
|
787
|
+
if (!context.options.yes) {
|
|
788
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
789
|
+
if (confirmation !== "confirmed")
|
|
790
|
+
return {
|
|
791
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
795
|
+
if (admissionFailure) return admissionFailure;
|
|
796
|
+
const identity = { kind: GJC_BUNDLE_KIND, scope: target.scope, name: target.name } as const;
|
|
797
|
+
try {
|
|
798
|
+
// Authorization resolves the stored source; it is the first effect-capable step.
|
|
799
|
+
const authorized = await runPluginRestoreAction({
|
|
800
|
+
kind: "authorize",
|
|
801
|
+
target: { identity, cwd: context.cwd },
|
|
802
|
+
plan: original,
|
|
803
|
+
authorizations: context.options.allowRisks,
|
|
804
|
+
});
|
|
805
|
+
if (!("ok" in authorized) || !authorized.ok || !("value" in authorized))
|
|
806
|
+
return {
|
|
807
|
+
repair: {
|
|
808
|
+
...plan,
|
|
809
|
+
state: "blocked",
|
|
810
|
+
reasonCode:
|
|
811
|
+
"ok" in authorized && !authorized.ok ? authorized.error.code : "restore_authorization_refused",
|
|
812
|
+
},
|
|
813
|
+
};
|
|
814
|
+
const token = authorized.value as ReviewedRestoreTokenV1;
|
|
815
|
+
if (
|
|
816
|
+
token.purpose !== "restore-artifact" ||
|
|
817
|
+
token.identity.name !== target.name ||
|
|
818
|
+
token.identity.scope !== target.scope ||
|
|
819
|
+
token.baselineFingerprint !== original.baselineFingerprint ||
|
|
820
|
+
token.decisionContextFingerprint !== original.decisionContextFingerprint
|
|
821
|
+
)
|
|
822
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "stale_baseline" } };
|
|
823
|
+
const applied = await runPluginRestoreAction({ kind: "apply", target: { identity, cwd: context.cwd }, token });
|
|
824
|
+
if (!("ok" in applied) || !applied.ok)
|
|
825
|
+
return {
|
|
826
|
+
repair: {
|
|
827
|
+
...plan,
|
|
828
|
+
state: "uncertain",
|
|
829
|
+
sideEffectStarted: true,
|
|
830
|
+
reasonCode: "ok" in applied && !applied.ok ? applied.error.code : "restore_apply_unverified",
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
const afterChecks = await collectAfterChecks();
|
|
834
|
+
const restored = afterChecks.some(
|
|
835
|
+
check =>
|
|
836
|
+
check.targetId === target.targetId &&
|
|
837
|
+
check.execution === "completed" &&
|
|
838
|
+
check.evidence.artifactStatus === "present",
|
|
839
|
+
);
|
|
840
|
+
return {
|
|
841
|
+
repair: {
|
|
842
|
+
...plan,
|
|
843
|
+
state: restored ? "verified" : "uncertain",
|
|
844
|
+
readiness: [],
|
|
845
|
+
sideEffectStarted: true,
|
|
846
|
+
reasonCode: restored ? undefined : "restore_postcheck_unverified",
|
|
847
|
+
...(restored ? { outcome: { mutationVerified: true } } : {}),
|
|
848
|
+
},
|
|
849
|
+
afterChecks,
|
|
850
|
+
};
|
|
851
|
+
} catch {
|
|
852
|
+
return {
|
|
853
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (plan.id === "service.restart-owned") {
|
|
858
|
+
if (target.kind !== "service")
|
|
859
|
+
return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
|
|
860
|
+
const before = parseServiceRecord(target.observation.state);
|
|
861
|
+
if (!before) return { repair: { ...plan, state: "blocked", reasonCode: "service_owner_absent" } };
|
|
862
|
+
if (!context.options.yes) {
|
|
863
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
864
|
+
if (confirmation !== "confirmed")
|
|
865
|
+
return {
|
|
866
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
870
|
+
if (admissionFailure) return admissionFailure;
|
|
871
|
+
let settings: Settings | undefined;
|
|
872
|
+
try {
|
|
873
|
+
const drainSeconds = context.options.drainSeconds;
|
|
874
|
+
const deadlineMs = Math.max(
|
|
875
|
+
1_000,
|
|
876
|
+
// `context.deadline` is monotonic (performance.now); mixing it with Date.now
|
|
877
|
+
// yields a large negative remainder that silently clamps every restart to the
|
|
878
|
+
// 1s floor and defeats --drain.
|
|
879
|
+
Math.min(context.deadline - performance.now(), 15_000 + (drainSeconds ?? 0) * 1_000),
|
|
880
|
+
);
|
|
881
|
+
// The owner's own refusal reason is kept alongside the coarse stage: an
|
|
882
|
+
// operator needs to tell "nothing is running" apart from "this daemon
|
|
883
|
+
// predates the restart protocol and needs the manual transition".
|
|
884
|
+
let outcome: { readonly kind: string; readonly reason?: string; readonly successorIncarnation?: string };
|
|
885
|
+
if (target.service === "broker") {
|
|
886
|
+
const result = await restartBrokerForDoctor({
|
|
887
|
+
agentDir: context.agentRoot.locator,
|
|
888
|
+
requestId: runId,
|
|
889
|
+
deadlineMs,
|
|
890
|
+
drain: drainSeconds !== undefined,
|
|
891
|
+
});
|
|
892
|
+
outcome =
|
|
893
|
+
result.kind === "restarted"
|
|
894
|
+
? { kind: result.kind, successorIncarnation: result.successor.incarnation }
|
|
895
|
+
: { kind: result.kind, ...("reason" in result ? { reason: result.reason } : {}) };
|
|
896
|
+
} else {
|
|
897
|
+
settings = await Settings.loadForScope({ cwd: context.cwd, agentDir: context.agentRoot.locator });
|
|
898
|
+
const result = await restartDaemonForDoctor({
|
|
899
|
+
agentDir: context.agentRoot.locator,
|
|
900
|
+
owner: target.service,
|
|
901
|
+
settings,
|
|
902
|
+
requestId: runId,
|
|
903
|
+
deadlineMs,
|
|
904
|
+
...(drainSeconds === undefined ? {} : { drainSeconds }),
|
|
905
|
+
});
|
|
906
|
+
outcome =
|
|
907
|
+
result.kind === "restarted"
|
|
908
|
+
? { kind: result.kind, successorIncarnation: result.successor.incarnation }
|
|
909
|
+
: { kind: result.kind, ...("reason" in result ? { reason: result.reason } : {}) };
|
|
910
|
+
}
|
|
911
|
+
const afterChecks = await collectAfterChecks();
|
|
912
|
+
const republished = afterChecks.some(
|
|
913
|
+
check =>
|
|
914
|
+
check.targetId === target.targetId && check.execution === "completed" && check.evidence.present === true,
|
|
915
|
+
);
|
|
916
|
+
// A restart is only verified when the published owner is a DIFFERENT incarnation
|
|
917
|
+
// than the one observed before; a surviving incumbent is never success. The
|
|
918
|
+
// successor identity is re-read from the refreshed target rather than taken
|
|
919
|
+
// from the mutation's own return value, so the claim rests on this pass's
|
|
920
|
+
// independent observation.
|
|
921
|
+
// `collectAfterChecks` runs in a fresh context, so re-observe the published
|
|
922
|
+
// record here rather than reading the pre-restart snapshot still held in
|
|
923
|
+
// `context.targets`.
|
|
924
|
+
// A daemon rewrites its heartbeat by rename, so a read can legitimately land
|
|
925
|
+
// mid-swap and report `changed`. That is a torn read, not evidence, and it
|
|
926
|
+
// would downgrade a real restart to uncertain; retry it exactly once.
|
|
927
|
+
let observed = (await readDoctorService(context.agentRoot.locator, target.service)).state;
|
|
928
|
+
if (observed.status === "changed")
|
|
929
|
+
observed = (await readDoctorService(context.agentRoot.locator, target.service)).state;
|
|
930
|
+
const published = parseServiceRecord(observed)?.incarnation;
|
|
931
|
+
const replaced =
|
|
932
|
+
typeof before.incarnation === "string" &&
|
|
933
|
+
typeof published === "string" &&
|
|
934
|
+
published !== before.incarnation &&
|
|
935
|
+
outcome.successorIncarnation === published;
|
|
936
|
+
if (outcome.kind === "restarted" && republished && replaced)
|
|
937
|
+
return {
|
|
938
|
+
repair: {
|
|
939
|
+
...plan,
|
|
940
|
+
state: "verified",
|
|
941
|
+
readiness: [],
|
|
942
|
+
sideEffectStarted: true,
|
|
943
|
+
outcome: { mutationVerified: true },
|
|
944
|
+
restartRequired: false,
|
|
945
|
+
restartScope: "none",
|
|
946
|
+
},
|
|
947
|
+
afterChecks,
|
|
948
|
+
};
|
|
949
|
+
const preEffect = outcome.kind === "owner_unavailable" || outcome.kind === "prepare_refused";
|
|
950
|
+
return {
|
|
951
|
+
repair: {
|
|
952
|
+
...plan,
|
|
953
|
+
state: preEffect ? "blocked" : "uncertain",
|
|
954
|
+
readiness: [],
|
|
955
|
+
sideEffectStarted: !preEffect,
|
|
956
|
+
reasonCode:
|
|
957
|
+
outcome.kind === "restarted"
|
|
958
|
+
? "service_restart_postcheck_unverified"
|
|
959
|
+
: outcome.reason === undefined
|
|
960
|
+
? outcome.kind
|
|
961
|
+
: `${outcome.kind}:${outcome.reason}`,
|
|
962
|
+
},
|
|
963
|
+
afterChecks,
|
|
964
|
+
};
|
|
965
|
+
} catch {
|
|
966
|
+
return {
|
|
967
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
968
|
+
};
|
|
969
|
+
} finally {
|
|
970
|
+
await settings?.close();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (plan.id === "service.detach-owned-stale-artifact") {
|
|
974
|
+
if (target.kind !== "artifact" || !target.slot)
|
|
975
|
+
return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
|
|
976
|
+
if (serviceArtifactObservation(target.observation, target.slot)?.status === "missing") {
|
|
977
|
+
const afterChecks = await collectAfterChecks();
|
|
978
|
+
if (
|
|
979
|
+
!afterChecks.some(
|
|
980
|
+
check =>
|
|
981
|
+
check.targetId === target.targetId &&
|
|
982
|
+
check.execution === "completed" &&
|
|
983
|
+
check.evidenceLevel === "observed" &&
|
|
984
|
+
check.evidence.present === false,
|
|
985
|
+
)
|
|
986
|
+
)
|
|
987
|
+
return { repair: { ...plan, state: "blocked", reasonCode: "artifact_absence_unverified" }, afterChecks };
|
|
988
|
+
return {
|
|
989
|
+
repair: { ...plan, state: "not_needed", readiness: [], outcome: { mutationVerified: true } },
|
|
990
|
+
afterChecks,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
if (!context.options.yes) {
|
|
994
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
995
|
+
if (confirmation !== "confirmed")
|
|
996
|
+
return {
|
|
997
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const module = await loadServiceRepair().catch(() => undefined);
|
|
1001
|
+
if (!module)
|
|
1002
|
+
return {
|
|
1003
|
+
repair: { ...plan, state: "blocked", reasonCode: "repair_module_unavailable", readiness: ["unsupported"] },
|
|
1004
|
+
};
|
|
1005
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
1006
|
+
if (admissionFailure) return admissionFailure;
|
|
1007
|
+
try {
|
|
1008
|
+
return await module.applyDoctorStaleArtifact(context, runId, target, collectAfterChecks);
|
|
1009
|
+
} catch {
|
|
1010
|
+
return {
|
|
1011
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (plan.id === "permissions.restrict-owned-config") {
|
|
1016
|
+
if (target.kind !== "permission")
|
|
1017
|
+
return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
|
|
1018
|
+
if (target.observation.status !== "read" || target.source.root.resolution !== "resolved")
|
|
1019
|
+
return {
|
|
1020
|
+
repair: {
|
|
1021
|
+
...plan,
|
|
1022
|
+
state: "blocked",
|
|
1023
|
+
reasonCode: "target_resolution_incomplete",
|
|
1024
|
+
readiness: ["target_resolution_incomplete"],
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
if (!context.options.yes) {
|
|
1028
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
1029
|
+
if (confirmation !== "confirmed")
|
|
1030
|
+
return {
|
|
1031
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const module = await loadPermissionRepair().catch(() => undefined);
|
|
1035
|
+
if (!module)
|
|
1036
|
+
return {
|
|
1037
|
+
repair: { ...plan, state: "blocked", reasonCode: "repair_module_unavailable", readiness: ["unsupported"] },
|
|
1038
|
+
};
|
|
1039
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
1040
|
+
if (admissionFailure) return admissionFailure;
|
|
1041
|
+
try {
|
|
1042
|
+
const result = await module.applyDoctorPermissionRepair({
|
|
1043
|
+
filePath: target.source.configPath,
|
|
1044
|
+
rootPath: target.source.root.locator,
|
|
1045
|
+
scope: target.source.scope,
|
|
1046
|
+
targetId: target.targetId,
|
|
1047
|
+
repairId: plan.id,
|
|
1048
|
+
runId,
|
|
1049
|
+
mode: "fix",
|
|
1050
|
+
authorization: context.options.allowRisks,
|
|
1051
|
+
journalRoot: context.agentRoot.locator,
|
|
1052
|
+
expected: { identity: target.observation.exactIdentity, mode: target.observation.identity.mode },
|
|
1053
|
+
collectAfterChecks,
|
|
1054
|
+
});
|
|
1055
|
+
return { repair: result.repair, afterChecks: result.afterChecks };
|
|
1056
|
+
} catch {
|
|
1057
|
+
return {
|
|
1058
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (
|
|
1063
|
+
(plan.id === "config.set-validated" && target.kind !== "config") ||
|
|
1064
|
+
(plan.id === "mcp.set-startup-policy" && target.kind !== "mcp")
|
|
1065
|
+
)
|
|
1066
|
+
return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
|
|
1067
|
+
if (target.kind !== "config" && target.kind !== "mcp")
|
|
1068
|
+
return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
|
|
1069
|
+
if (target.observation.status !== "read" || target.source.root.resolution !== "resolved")
|
|
1070
|
+
return {
|
|
1071
|
+
repair: {
|
|
1072
|
+
...plan,
|
|
1073
|
+
state: "blocked",
|
|
1074
|
+
reasonCode: "target_resolution_incomplete",
|
|
1075
|
+
readiness: ["target_resolution_incomplete"],
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
if (typeof context.options.setValue !== "boolean")
|
|
1079
|
+
return { repair: { ...plan, state: "blocked" }, invocationError: "value_required" };
|
|
1080
|
+
if (!context.options.yes) {
|
|
1081
|
+
const confirmation = await confirmSelectedRepair(context, plan);
|
|
1082
|
+
if (confirmation !== "confirmed")
|
|
1083
|
+
return {
|
|
1084
|
+
repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
const request: DoctorConfigRepairRequest = {
|
|
1088
|
+
filePath: target.kind === "config" ? target.source.configPath : target.source.mcpPath,
|
|
1089
|
+
rootPath: target.source.root.locator,
|
|
1090
|
+
scope: target.source.scope,
|
|
1091
|
+
targetId: target.targetId,
|
|
1092
|
+
repairId: plan.id,
|
|
1093
|
+
runId,
|
|
1094
|
+
mode: "fix",
|
|
1095
|
+
authorization: context.options.allowRisks,
|
|
1096
|
+
journalRoot: context.agentRoot.locator,
|
|
1097
|
+
expected: {
|
|
1098
|
+
raw: target.observation.text,
|
|
1099
|
+
observedValue: target.beforeValue,
|
|
1100
|
+
identity: target.observation.exactIdentity,
|
|
1101
|
+
},
|
|
1102
|
+
kind: target.kind === "config" ? "skill" : "mcp",
|
|
1103
|
+
...(target.kind === "config"
|
|
1104
|
+
? { schemaKey: target.schemaKey }
|
|
1105
|
+
: { serverName: target.serverName, field: target.field }),
|
|
1106
|
+
value: context.options.setValue,
|
|
1107
|
+
collectAfterChecks,
|
|
1108
|
+
};
|
|
1109
|
+
const module = await loadConfigRepair().catch(() => undefined);
|
|
1110
|
+
if (!module)
|
|
1111
|
+
return {
|
|
1112
|
+
repair: { ...plan, state: "blocked", reasonCode: "repair_module_unavailable", readiness: ["unsupported"] },
|
|
1113
|
+
};
|
|
1114
|
+
const admissionFailure = admitSelectedRepair(context, plan);
|
|
1115
|
+
if (admissionFailure) return admissionFailure;
|
|
1116
|
+
let result: DoctorConfigRepairResult;
|
|
1117
|
+
try {
|
|
1118
|
+
result = await module.applyDoctorConfigRepair(request);
|
|
1119
|
+
} catch {
|
|
1120
|
+
return {
|
|
1121
|
+
repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
repair: {
|
|
1126
|
+
...result.repair,
|
|
1127
|
+
restartScope: "new-session",
|
|
1128
|
+
beforeCheckIds: plan.beforeCheckIds,
|
|
1129
|
+
},
|
|
1130
|
+
afterChecks: result.afterChecks,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function confirmSelectedRepair(context: DoctorContext, plan: DoctorRepair): Promise<DoctorConfirmationResult> {
|
|
1135
|
+
if (context.options.confirmRepair) return context.options.confirmRepair(plan);
|
|
1136
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
1137
|
+
const interrupt = new AbortController();
|
|
1138
|
+
const onInterrupt = () => interrupt.abort();
|
|
1139
|
+
terminal.once("SIGINT", onInterrupt);
|
|
1140
|
+
const remaining = Math.max(1, Math.floor(context.deadline - performance.now()));
|
|
1141
|
+
const signals = [interrupt.signal, AbortSignal.timeout(remaining)];
|
|
1142
|
+
if (context.options.signal) signals.push(context.options.signal);
|
|
1143
|
+
try {
|
|
1144
|
+
const answer = await terminal.question(
|
|
1145
|
+
`Apply ${plan.id} to ${plan.targetId}${context.options.setValue === undefined ? "" : `, value ${context.options.setValue}`}? [y/N] `,
|
|
1146
|
+
{ signal: AbortSignal.any(signals) },
|
|
1147
|
+
);
|
|
1148
|
+
return /^(y|yes)$/i.test(answer.trim()) ? "confirmed" : "confirmation_declined";
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
1151
|
+
return interrupt.signal.aborted || context.options.signal?.aborted ? "cancelled" : "confirmation_timeout";
|
|
1152
|
+
throw error;
|
|
1153
|
+
} finally {
|
|
1154
|
+
terminal.off("SIGINT", onInterrupt);
|
|
1155
|
+
terminal.close();
|
|
1156
|
+
}
|
|
1157
|
+
}
|