@zq-silk/yui 0.15.7 → 0.15.9
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/ARCHITECTURE.md +194 -399
- package/ARCHITECTURE.zh-CN.md +151 -0
- package/README.md +306 -1131
- package/dist/agent/adapterCatalog.js +15 -2
- package/dist/agent/agent.js +23 -3
- package/dist/agent/argumentPolicy.js +7 -1
- package/dist/agent/connectionPlan.js +62 -0
- package/dist/agent/executionComponents.js +158 -0
- package/dist/agent/launchEnvironment.js +31 -3
- package/dist/agent/managedRuntimeEnvironment.js +3 -5
- package/dist/{turn/turn.js → agentRun/agentRun.js} +166 -109
- package/dist/{turn/turnIdentity.js → agentRun/runIdentity.js} +4 -4
- package/dist/artifacts/artifactCapability.js +74 -0
- package/dist/artifacts/artifactCommitLock.js +249 -0
- package/dist/artifacts/artifactPaths.js +151 -0
- package/dist/artifacts/gitArtifactRef.js +146 -0
- package/dist/artifacts/managedGit.js +332 -0
- package/dist/artifacts/taskArtifactRepository.js +277 -0
- package/dist/brief/taskBrief.js +12 -0
- package/dist/cli/agentConfigurationPicker.js +13 -0
- package/dist/cli/commandCatalog.js +165 -74
- package/dist/cli/interactionCandidates.js +5 -5
- package/dist/cli/interactionPolicy.js +38 -8
- package/dist/cli/invocationRouter.js +1 -1
- package/dist/cli/managedDiagnostics.js +28 -0
- package/dist/cli/operatorWizard.js +1 -7
- package/dist/cli/roleOptionOrder.js +27 -0
- package/dist/cli/roleWizard.js +50 -14
- package/dist/cli/updateOrchestrator.js +1 -1
- package/dist/cli/updatePorts.js +3 -4
- package/dist/cli.js +245 -95
- package/dist/commands/agentCommands.js +72 -14
- package/dist/commands/capabilityCommands.js +9 -6
- package/dist/commands/configCommands.js +20 -20
- package/dist/commands/deliveryGuardPreflight.js +2 -2
- package/dist/commands/executionAuditCommands.js +24 -24
- package/dist/commands/globalRoleCommands.js +1 -1
- package/dist/commands/grantCommands.js +4 -4
- package/dist/commands/operatorCommands.js +34 -9
- package/dist/commands/projectCommands.js +4 -4
- package/dist/commands/resourcesCommands.js +2 -2
- package/dist/commands/roleConfiguration.js +25 -5
- package/dist/commands/roleRuntimeGuard.js +4 -5
- package/dist/commands/sessionCommands.js +3 -7
- package/dist/commands/taskActivationCommands.js +281 -0
- package/dist/commands/taskActor.js +28 -49
- package/dist/commands/taskCommands.js +1461 -720
- package/dist/commands/taskContextCommand.js +39 -583
- package/dist/commands/taskExecutionCommands.js +32 -32
- package/dist/commands/taskInputCommands.js +40 -104
- package/dist/commands/taskIntegrationCommands.js +3 -2
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskNextActionCommand.js +8 -8
- package/dist/commands/taskOverviewCommand.js +33 -45
- package/dist/commands/taskRemoteDeliveryCommand.js +2 -2
- package/dist/commands/taskRoleRuntimeStatus.js +133 -102
- package/dist/commands/telemetryCommands.js +36 -38
- package/dist/config/configCatalog.js +4 -4
- package/dist/config/yuiConfig.js +8 -8
- package/dist/context/contextSnapshot.js +10 -10
- package/dist/context/dispatchContext.js +11 -11
- package/dist/context/roleSessionContext.js +6 -3
- package/dist/context/{turnContextPack.js → runContextPack.js} +158 -81
- package/dist/context/{turnInputContract.js → runInputContract.js} +73 -60
- package/dist/context/sessionBootstrapManifest.js +21 -2
- package/dist/context/sourceRunContext.js +30 -0
- package/dist/context/taskContext.js +481 -0
- package/dist/context/wakeNotification.js +27 -27
- package/dist/controller/agentRuntimeObserver.js +21 -24
- package/dist/controller/capabilityBridge.js +17 -6
- package/dist/controller/clientRuntime.js +65 -92
- package/dist/controller/controller.js +120 -188
- package/dist/controller/fileSchedulerStoreAdapter.js +787 -887
- package/dist/controller/jobControl.js +54 -85
- package/dist/controller/resourceInventory.js +8 -27
- package/dist/controller/resourceInventoryLinux.js +12 -13
- package/dist/controller/runtime.js +529 -479
- package/dist/controller/runtimeEventInbox.js +55 -25
- package/dist/controller/runtimeEventProcessor.js +22 -31
- package/dist/controller/{runtimeHookTurnFence.js → runtimeHookRunFence.js} +91 -115
- package/dist/controller/runtimeLaunchCoordinator.js +80 -426
- package/dist/controller/runtimeObservationHook.js +14 -18
- package/dist/controller/sessionNotify.js +16 -24
- package/dist/controller/sessionOwnerReconciliation.js +168 -50
- package/dist/controller/structuredProviderObservation.js +138 -99
- package/dist/coordination/workMailbox.js +3 -3
- package/dist/coordination/workMailboxQueue.js +36 -33
- package/dist/core/boundedRpc.js +8 -1
- package/dist/core/controllerClient.js +20 -1
- package/dist/core/controllerServer.js +4 -4
- package/dist/doctor/doctor.js +13 -2
- package/dist/domain/agentResultTransport.js +9 -9
- package/dist/execution/codexThreadNaming.js +2 -8
- package/dist/execution/executionHealth.js +51 -63
- package/dist/execution/reviewMainRun.js +137 -0
- package/dist/execution/workItemExecution.js +28 -29
- package/dist/execution/workItemExecutionProjection.js +99 -107
- package/dist/execution/workItemMainRun.js +141 -0
- package/dist/executor/agentAdapter.js +227 -20
- package/dist/executor/agentConfigurationCatalog.js +126 -4
- package/dist/executor/agentConfigurationProbe.js +162 -4
- package/dist/executor/agentExecutor.js +79 -78
- package/dist/executor/effectiveLaunch.js +105 -18
- package/dist/executor/executorRegistry.js +29 -44
- package/dist/executor/fileRoleLaunchPlanner.js +229 -154
- package/dist/executor/workspacePreflightClassification.js +16 -16
- package/dist/grant/capabilityGrant.js +6 -3
- package/dist/input/inputRequest.js +12 -10
- package/dist/integration/gitIntegrationService.js +4 -11
- package/dist/integration/integrationQueueService.js +4 -4
- package/dist/interaction/operatorPresentation.js +1 -1
- package/dist/kernel/builtinCapabilities.js +255 -12
- package/dist/kernel/capabilityRegistry.js +64 -18
- package/dist/kernel/instanceHost.js +12 -1
- package/dist/kernel/kernelPorts.js +2 -2
- package/dist/lifecycle/canonicalLifecycleEvent.js +44 -49
- package/dist/lifecycle/exactRunTerminalization.js +449 -0
- package/dist/message/message.js +118 -6
- package/dist/message/messageContinuation.js +204 -0
- package/dist/observability/executionAudit.js +70 -72
- package/dist/observability/faultClassification.js +2 -2
- package/dist/observability/orchestrationMetrics.js +8 -8
- package/dist/operator/operatorSessionHistory.js +1 -7
- package/dist/output/agentConfigurationPresentation.js +8 -3
- package/dist/output/agentRunConfigurationPresentation.js +128 -0
- package/dist/output/rolePresentation.js +54 -3
- package/dist/plugins/pluginChild.js +104 -0
- package/dist/plugins/pluginIntent.js +26 -0
- package/dist/plugins/pluginInterpreter.js +43 -0
- package/dist/plugins/pluginPackage.js +101 -0
- package/dist/plugins/pluginProcess.js +112 -0
- package/dist/plugins/pluginService.js +388 -0
- package/dist/profile/agentProfile.js +1 -1
- package/dist/repository/gitWorkspace.js +26 -4
- package/dist/repository/project.js +19 -4
- package/dist/repository/taskBaseFreshness.js +13 -13
- package/dist/repository/taskWorkspaceCoordinator.js +20 -27
- package/dist/repository/taskWorkspacePreparer.js +344 -83
- package/dist/resources/autoResourceGc.js +3 -3
- package/dist/resources/liveReferences.js +3 -3
- package/dist/resources/projectResource.js +75 -0
- package/dist/resources/projectResourceService.js +343 -0
- package/dist/resources/resourceDiscovery.js +6 -6
- package/dist/resources/resourceGc.js +1 -1
- package/dist/resources/resourceRegistrar.js +1 -1
- package/dist/resources/resourceTypes.js +1 -1
- package/dist/review/deltaRecheck.js +3 -3
- package/dist/review/reviewAcceptance.js +16 -16
- package/dist/review/reviewDecision.js +7 -7
- package/dist/review/reviewRound.js +21 -20
- package/dist/review/reviewerAvailability.js +2 -2
- package/dist/role/role.js +51 -7
- package/dist/role/taskRoleUpdate.js +30 -0
- package/dist/runtime/acpProtocol.js +425 -0
- package/dist/runtime/acpSession.js +731 -0
- package/dist/runtime/acpSessionConfiguration.js +260 -0
- package/dist/runtime/agentDriver.js +30 -11
- package/dist/runtime/agentEndpoint.js +278 -0
- package/dist/runtime/agentEndpointIdentity.js +86 -0
- package/dist/runtime/agentEndpointOwnership.js +239 -0
- package/dist/runtime/agentError.js +2 -10
- package/dist/runtime/agentHost.js +565 -314
- package/dist/runtime/agentRunConfiguration.js +258 -0
- package/dist/runtime/builtinAgentDrivers.js +134 -18
- package/dist/runtime/builtinAgentErrorMappers.js +55 -3
- package/dist/runtime/builtinTranscriptUsage.js +1 -1
- package/dist/runtime/claude-process-owner +0 -0
- package/dist/runtime/codexAppServerRuntime.js +38 -30
- package/dist/runtime/codexInteractiveHost.js +41 -6
- package/dist/runtime/continuationManager.js +2 -6
- package/dist/runtime/executionEnvironment.js +30 -0
- package/dist/runtime/firstProgressAdvisory.js +11 -11
- package/dist/runtime/index.js +4 -3
- package/dist/runtime/jsonLineChannel.js +109 -0
- package/dist/runtime/launchBroker.js +91 -16
- package/dist/runtime/launchDiagnostics.js +2 -2
- package/dist/runtime/lifecycleReservation.js +10 -18
- package/dist/runtime/managedCaller.js +61 -17
- package/dist/runtime/nativeSessionControl.js +102 -0
- package/dist/runtime/ports.js +6 -21
- package/dist/runtime/processExitObservation.js +8 -7
- package/dist/runtime/promptEnvelope.js +17 -6
- package/dist/runtime/providerContinuation.js +3 -9
- package/dist/runtime/providerContinuationReconciliationService.js +4 -13
- package/dist/runtime/providerControl.js +2 -7
- package/dist/runtime/providerRuntimeIdentity.js +110 -222
- package/dist/runtime/providerRuntimeReconciler.js +5 -9
- package/dist/runtime/runtimeBinding.js +0 -1
- package/dist/runtime/runtimeContinuationProjection.js +4 -7
- package/dist/runtime/runtimeDeadlines.js +9 -0
- package/dist/runtime/runtimeHealthPolicy.js +1 -1
- package/dist/runtime/runtimeObservation.js +29 -65
- package/dist/runtime/runtimeProjection.js +43 -51
- package/dist/runtime/runtimeSessionCandidate.js +1 -3
- package/dist/runtime/sessionLaunchRequest.js +3 -7
- package/dist/runtime/sessionOwnerIdentity.js +7 -54
- package/dist/runtime/sessionOwnerRegistry.js +22 -17
- package/dist/runtime/sessionReconciliation.js +4 -8
- package/dist/runtime/sessionTerminationGuard.js +70 -259
- package/dist/runtime/sessionTokenMetrics.js +5 -16
- package/dist/runtime/structuredProviderHost.js +237 -117
- package/dist/runtime/taskRuntimeIsolation.js +39 -122
- package/dist/runtime/tmuxAdapters.js +39 -86
- package/dist/scheduler/activeRoleRunDelivery.js +354 -0
- package/dist/scheduler/leaderWakeupProcessor.js +75 -266
- package/dist/scheduler/operatorInputNotificationProcessor.js +1 -1
- package/dist/scheduler/ports.js +80 -9
- package/dist/scheduler/{roleTurnLiveness.js → roleRunLiveness.js} +26 -30
- package/dist/scheduler/{roleTurnStall.js → roleRunStall.js} +128 -139
- package/dist/scheduler/taskExecutionProjection.js +120 -124
- package/dist/scheduler/taskObservabilityProjection.js +29 -29
- package/dist/scheduler/taskWake.js +11 -4
- package/dist/scheduler/wakeReason.js +9 -1
- package/dist/setup/setupCommand.js +3 -7
- package/dist/storage/migrations/agentRunContract.js +159 -0
- package/dist/storage/migrations/artifactsToGit.js +338 -0
- package/dist/storage/migrations/removeRuntimeGeneration.js +207 -0
- package/dist/storage/migrations/submitIntent.js +126 -0
- package/dist/storage/sqliteSchema.js +467 -7
- package/dist/storage/sqliteStore.js +355 -220
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +10 -5
- package/dist/storage/taskStore.js +13 -11
- package/dist/storage/upgrade/upgradeOrchestrator.js +5 -7
- package/dist/surface/surfaceContributions.js +102 -0
- package/dist/task/completionReadiness.js +32 -6
- package/dist/task/deliveryGuard.js +16 -16
- package/dist/task/draftPlan.js +72 -12
- package/dist/task/nextAction.js +144 -128
- package/dist/task/remoteDelivery.js +6 -6
- package/dist/task/task.js +184 -18
- package/dist/task/taskActivation.js +327 -0
- package/dist/task/taskActivationService.js +408 -0
- package/dist/task/taskRecordReference.js +5 -4
- package/dist/task/taskRecordRetirement.js +1 -1
- package/dist/task/taskSubmission.js +236 -0
- package/dist/telemetry/sqliteTelemetryStore.js +55 -68
- package/dist/telemetry/telemetryConfig.js +14 -14
- package/dist/telemetry/telemetryWiring.js +2 -2
- package/dist/web/assets/assetManifest.js +2 -0
- package/dist/web/assets/client/app.js +121 -20
- package/dist/web/assets/client/components.js +87 -54
- package/dist/web/assets/client/i18n.js +83 -41
- package/dist/web/assets/client/markdown.js +1 -1
- package/dist/web/assets/client/taskSurface.js +442 -0
- package/dist/web/assets/client/view.js +49 -44
- package/dist/web/assets/shell.js +1 -1
- package/dist/web/assets/styles/cards.js +22 -4
- package/dist/web/controllerWeb.js +60 -0
- package/dist/web/webMutation.js +28 -0
- package/dist/web/webServer.js +133 -8
- package/dist/web/webSnapshot.js +81 -74
- package/dist/web/webTaskSurface.js +64 -0
- package/dist/workItem/dependencyGate.js +1 -1
- package/dist/workItem/workItem.js +84 -48
- package/dist/workspace/workItemChangeSetManager.js +16 -9
- package/docs/agent-result-consumption.md +96 -0
- package/docs/agent-result-consumption.zh-CN.md +81 -0
- package/docs/agent-runtime-drivers.md +93 -0
- package/docs/agent-runtime-drivers.zh-CN.md +77 -0
- package/docs/architecture/README.md +50 -0
- package/docs/architecture/README.zh-CN.md +43 -0
- package/docs/architecture/capabilities-and-resources.md +118 -0
- package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
- package/docs/managed-turn-and-session-runtime.md +224 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
- package/docs/observability/README.md +83 -0
- package/docs/observability/README.zh-CN.md +71 -0
- package/docs/plugin-sdk.md +393 -0
- package/docs/plugin-sdk.zh-CN.md +293 -0
- package/docs/provider-runtime.md +165 -0
- package/docs/provider-runtime.zh-CN.md +132 -0
- package/docs/release-workflow.md +305 -0
- package/docs/release-workflow.zh-CN.md +237 -0
- package/docs/roles-and-configuration.md +115 -0
- package/docs/roles-and-configuration.zh-CN.md +96 -0
- package/docs/sqlite-control-plane-design.md +78 -0
- package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
- package/docs/task-dag-semantics.md +80 -0
- package/docs/task-dag-semantics.zh-CN.md +59 -0
- package/docs/task-delivery.md +105 -0
- package/docs/task-delivery.zh-CN.md +82 -0
- package/docs/task-local-identity.md +8 -6
- package/docs/task-local-identity.zh-CN.md +58 -0
- package/docs/testing/verification-levels.md +88 -0
- package/docs/testing/verification-levels.zh-CN.md +69 -0
- package/i18n/README.zh-CN.md +270 -722
- package/package.json +3 -2
- package/skills/yui-leader/SKILL.md +88 -304
- package/skills/yui-leader/references/execution.md +303 -0
- package/skills/yui-leader/references/integration.md +39 -0
- package/skills/yui-leader/references/planning.md +109 -0
- package/skills/yui-leader/references/replicated-execution.md +42 -0
- package/skills/yui-leader/references/task-plugins.md +37 -0
- package/skills/yui-operator/SKILL.md +46 -62
- package/skills/yui-reviewer/SKILL.md +35 -36
- package/skills/yui-runtime/SKILL.md +88 -24
- package/skills/yui-runtime/references/publication.md +22 -0
- package/skills/yui-runtime/references/recovery.md +64 -0
- package/skills/yui-worker/SKILL.md +37 -39
- package/dist/cli/roleOptionCatalog.js +0 -68
- package/dist/context/sourceTurnContext.js +0 -30
- package/dist/execution/reviewMainTurn.js +0 -161
- package/dist/execution/workItemMainTurn.js +0 -164
- package/dist/lifecycle/exactTurnTerminalization.js +0 -407
- package/dist/runtime/preallocatedNativeSession.js +0 -13
- package/dist/runtime/runtimeStopReceipt.js +0 -42
- package/dist/scheduler/activeRoleTurnDelivery.js +0 -315
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const executeFile = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Hardened, isolated Git runner for per-Task artifact repositories.
|
|
6
|
+
*
|
|
7
|
+
* Artifact repositories are local-only working stores for product files
|
|
8
|
+
* (plans, prototypes, charts, reports). Their contents are DATA authored by
|
|
9
|
+
* agents and, indirectly, by product material — never trusted to configure or
|
|
10
|
+
* drive the Git process. Every managed invocation therefore runs with a
|
|
11
|
+
* scrubbed environment and a fixed set of hardening options that:
|
|
12
|
+
*
|
|
13
|
+
* - block ALL network/file transports (`protocol.allow=never`, and an
|
|
14
|
+
* explicit `protocol.file.allow=never`), so no `fetch`/`clone`/`push`
|
|
15
|
+
* can move bytes even if a rogue remote is configured;
|
|
16
|
+
* - disable hooks (`core.hooksPath` → null device) so a committed hook
|
|
17
|
+
* script can never execute;
|
|
18
|
+
* - refuse to read user/system config or attributes
|
|
19
|
+
* (`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` → null device,
|
|
20
|
+
* `GIT_CONFIG_NOSYSTEM`, `GIT_ATTR_NOSYSTEM`), so external filters,
|
|
21
|
+
* signing programs, pagers and editors cannot be injected;
|
|
22
|
+
* - never prompt (`GIT_TERMINAL_PROMPT=0`, askpass disabled) and fail
|
|
23
|
+
* closed on any transport helper (`GIT_SSH_COMMAND=false`);
|
|
24
|
+
* - pin a fixed committer/author identity so commits succeed without
|
|
25
|
+
* inheriting ambient user config.
|
|
26
|
+
*
|
|
27
|
+
* The runner also refuses caller-supplied `-c`/`-C`/`--exec`/`--upload-pack`/
|
|
28
|
+
* `--receive-pack` arguments: hardening flags are prepended here and callers
|
|
29
|
+
* pass only fixed subcommands with data after `--`, so there is no channel to
|
|
30
|
+
* pass through options that could re-enable transport or run a program.
|
|
31
|
+
*/
|
|
32
|
+
const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
33
|
+
/**
|
|
34
|
+
* Fixed option flags prepended to every managed invocation. These are applied
|
|
35
|
+
* before the subcommand and can never be overridden by repository-local config,
|
|
36
|
+
* because global/system config is disabled and no caller `-c` is accepted.
|
|
37
|
+
*/
|
|
38
|
+
const HARDENING_FLAGS = Object.freeze([
|
|
39
|
+
// Identity — commits succeed without ambient user config.
|
|
40
|
+
"-c", "user.name=Yui",
|
|
41
|
+
"-c", "user.email=yui@local",
|
|
42
|
+
// Transport — nothing may move over the network or the filesystem.
|
|
43
|
+
"-c", "protocol.allow=never",
|
|
44
|
+
"-c", "protocol.file.allow=never",
|
|
45
|
+
// Hooks / filters / external programs — no committed script may execute.
|
|
46
|
+
"-c", `core.hooksPath=${NULL_DEVICE}`,
|
|
47
|
+
"-c", "core.fsmonitor=false",
|
|
48
|
+
"-c", "core.pager=cat",
|
|
49
|
+
"-c", "core.editor=false",
|
|
50
|
+
// Signing — never shell out to a signing program.
|
|
51
|
+
"-c", "commit.gpgsign=false",
|
|
52
|
+
"-c", "tag.gpgsign=false",
|
|
53
|
+
// Determinism / quiet.
|
|
54
|
+
"-c", "core.autocrlf=false",
|
|
55
|
+
"-c", "gc.auto=0",
|
|
56
|
+
"-c", "advice.detachedHead=false"
|
|
57
|
+
]);
|
|
58
|
+
/** Arguments a caller may never pass: they could re-enable transport or run a program. */
|
|
59
|
+
const FORBIDDEN_ARGUMENTS = Object.freeze([
|
|
60
|
+
"-c", "-C", "--exec-path", "--upload-pack", "--receive-pack", "--exec"
|
|
61
|
+
]);
|
|
62
|
+
/** Raised when a managed Git command exits non-zero. Carries the exact stderr. */
|
|
63
|
+
export class ManagedGitError extends Error {
|
|
64
|
+
stderr;
|
|
65
|
+
args;
|
|
66
|
+
constructor(args, stderr, cause) {
|
|
67
|
+
super(stderr.length === 0 ? "Managed git command failed." : `Managed git command failed: ${stderr}`, { cause });
|
|
68
|
+
this.name = "ManagedGitError";
|
|
69
|
+
this.stderr = stderr;
|
|
70
|
+
this.args = args;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Build the scrubbed environment for a managed Git process. Never inherits ambient Git config. */
|
|
74
|
+
function managedGitEnvironment() {
|
|
75
|
+
return {
|
|
76
|
+
// A minimal, deterministic PATH so `git` (and only trusted helpers) resolve.
|
|
77
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
78
|
+
LANG: "C.UTF-8",
|
|
79
|
+
LC_ALL: "C.UTF-8",
|
|
80
|
+
// A neutral HOME keeps any accidental lookup inside a controlled area; config
|
|
81
|
+
// lookups are already redirected to the null device below.
|
|
82
|
+
HOME: process.platform === "win32" ? (process.env.USERPROFILE ?? "") : "/nonexistent",
|
|
83
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
84
|
+
GIT_CONFIG_GLOBAL: NULL_DEVICE,
|
|
85
|
+
GIT_CONFIG_SYSTEM: NULL_DEVICE,
|
|
86
|
+
GIT_ATTR_NOSYSTEM: "1",
|
|
87
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
88
|
+
GIT_ASKPASS: "",
|
|
89
|
+
SSH_ASKPASS: "",
|
|
90
|
+
GIT_SSH_COMMAND: "false",
|
|
91
|
+
GIT_PROTOCOL_FROM_USER: "0",
|
|
92
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
93
|
+
// Every pathspec is a literal path, never a glob. A file legitimately named
|
|
94
|
+
// `*.md` or `a?.md` must commit exactly itself and never sweep in undeclared
|
|
95
|
+
// sibling changes; this makes `add`/`diff`/`commit --only` scope uniformly
|
|
96
|
+
// and can never re-enable transport or run a program.
|
|
97
|
+
GIT_LITERAL_PATHSPECS: "1",
|
|
98
|
+
GIT_PAGER: "cat",
|
|
99
|
+
GIT_EDITOR: "false",
|
|
100
|
+
// Fixed identity as a second guarantee alongside the -c flags.
|
|
101
|
+
GIT_AUTHOR_NAME: "Yui",
|
|
102
|
+
GIT_AUTHOR_EMAIL: "yui@local",
|
|
103
|
+
GIT_COMMITTER_NAME: "Yui",
|
|
104
|
+
GIT_COMMITTER_EMAIL: "yui@local"
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function assertSafeArguments(args) {
|
|
108
|
+
for (const arg of args) {
|
|
109
|
+
if (typeof arg !== "string")
|
|
110
|
+
throw new Error("Managed git argument must be a string.");
|
|
111
|
+
if (arg.includes("\0"))
|
|
112
|
+
throw new Error("Managed git argument contains a NUL byte.");
|
|
113
|
+
if (FORBIDDEN_ARGUMENTS.includes(arg)) {
|
|
114
|
+
throw new Error(`Managed git argument is not permitted: ${arg}.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Core managed invocation. `-C repoPath` and the hardening flags are supplied
|
|
120
|
+
* here; `args` carries only the subcommand and its data. Returns raw stdout and
|
|
121
|
+
* stderr buffers; throws {@link ManagedGitError} on non-zero exit.
|
|
122
|
+
*/
|
|
123
|
+
async function spawnManagedGit(repoPath, args, options) {
|
|
124
|
+
assertSafeArguments(args);
|
|
125
|
+
try {
|
|
126
|
+
const result = await executeFile("git", [...HARDENING_FLAGS, "-C", repoPath, ...args], {
|
|
127
|
+
encoding: "buffer",
|
|
128
|
+
env: managedGitEnvironment(),
|
|
129
|
+
maxBuffer: options?.maxBuffer ?? 16 * 1024 * 1024,
|
|
130
|
+
timeout: options?.timeoutMs ?? 30_000,
|
|
131
|
+
windowsHide: true
|
|
132
|
+
});
|
|
133
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const stderr = readErrorStream(error);
|
|
137
|
+
throw new ManagedGitError([...args], stderr, error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function readErrorStream(error) {
|
|
141
|
+
if (typeof error === "object" && error !== null && "stderr" in error) {
|
|
142
|
+
const stderr = error.stderr;
|
|
143
|
+
if (Buffer.isBuffer(stderr))
|
|
144
|
+
return stderr.toString("utf8").trim();
|
|
145
|
+
if (typeof stderr === "string")
|
|
146
|
+
return stderr.trim();
|
|
147
|
+
}
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
/** Run a managed Git command and return trimmed UTF-8 stdout. Throws on failure. */
|
|
151
|
+
export async function managedGit(repoPath, args, options) {
|
|
152
|
+
const { stdout } = await spawnManagedGit(repoPath, args, options);
|
|
153
|
+
return stdout.toString("utf8");
|
|
154
|
+
}
|
|
155
|
+
/** Run a managed Git command and return raw stdout bytes (for reading blob content). Throws on failure. */
|
|
156
|
+
export async function managedGitBuffer(repoPath, args, options) {
|
|
157
|
+
const { stdout } = await spawnManagedGit(repoPath, args, options);
|
|
158
|
+
return stdout;
|
|
159
|
+
}
|
|
160
|
+
/** Run a managed Git command purely for its exit status; never throws on non-zero. */
|
|
161
|
+
export async function managedGitSucceeds(repoPath, args, options) {
|
|
162
|
+
try {
|
|
163
|
+
await spawnManagedGit(repoPath, args, options);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (error instanceof ManagedGitError)
|
|
168
|
+
return false;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Validate and normalize a Git object id from managed output (40- or 64-hex). */
|
|
173
|
+
export function requireCommitId(value) {
|
|
174
|
+
const commit = value.trim();
|
|
175
|
+
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(commit)) {
|
|
176
|
+
throw new Error("Managed git returned an invalid commit id.");
|
|
177
|
+
}
|
|
178
|
+
return commit;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Repo-local config keys that can make Git execute an EXTERNAL PROGRAM during an
|
|
182
|
+
* ordinary `add`/`diff`/`commit`/`show` — the vector env-scrubbing does not close.
|
|
183
|
+
*
|
|
184
|
+
* Disabling global/system config (see {@link managedGitEnvironment}) stops
|
|
185
|
+
* ambient filters/hooks, but a repository's OWN `.git/config` is always read, and
|
|
186
|
+
* a `[filter "x"] clean = <cmd>` there (paired with a worktree `.gitattributes`
|
|
187
|
+
* `* filter=x`) runs `<cmd>` on `git add`. Some keys (`core.pager`, hooks, signing)
|
|
188
|
+
* are already forced to safe values by the `-c` {@link HARDENING_FLAGS}, but an
|
|
189
|
+
* arbitrarily NAMED filter/alias/tool driver cannot be overridden by a fixed `-c`.
|
|
190
|
+
*
|
|
191
|
+
* Yui configures NONE of these on a managed artifact repository, so any that
|
|
192
|
+
* appear in repo-local config are external tampering. We DETECT and STOP (like an
|
|
193
|
+
* unexpected remote); we never silently delete the config or try to out-configure
|
|
194
|
+
* an arbitrary command. Keys are matched case-insensitively (Git config is).
|
|
195
|
+
*
|
|
196
|
+
* The direct scan sees only repo-local keys, so it also rejects the config
|
|
197
|
+
* INCLUSION entry points (`include.*`, `includeIf.*`, `extensions.worktreeConfig`)
|
|
198
|
+
* that could otherwise hide any of the below in a file this scan never opens —
|
|
199
|
+
* see {@link isExternalProgramConfigKey}.
|
|
200
|
+
*/
|
|
201
|
+
const EXTERNAL_PROGRAM_CONFIG_SUFFIXES = Object.freeze([
|
|
202
|
+
".clean", ".smudge", ".process", // filter.<name>.*
|
|
203
|
+
".command", ".textconv", ".cmd", // diff/difftool/mergetool.<name>.*
|
|
204
|
+
".driver", // merge.<name>.driver
|
|
205
|
+
".helper" // credential[.<url>].helper
|
|
206
|
+
]);
|
|
207
|
+
const EXTERNAL_PROGRAM_CONFIG_PREFIXES = Object.freeze([
|
|
208
|
+
"filter.", "alias.", "difftool.", "mergetool.", "pager.",
|
|
209
|
+
"sendemail.", "instaweb.", "guitool.", "credential.", "url.",
|
|
210
|
+
"browser.", "man.", "hooks."
|
|
211
|
+
]);
|
|
212
|
+
const EXTERNAL_PROGRAM_CONFIG_KEYS = new Set([
|
|
213
|
+
"diff.external",
|
|
214
|
+
"core.pager", "core.editor", "core.sshcommand", "core.askpass",
|
|
215
|
+
"core.gitproxy", "core.fsmonitor", "core.hookspath", "core.alternaterefscommand",
|
|
216
|
+
"gpg.program", "gpg.openpgp.program", "gpg.x509.program", "gpg.ssh.program",
|
|
217
|
+
"sequence.editor", "uploadpack.packobjectshook",
|
|
218
|
+
"web.browser", "help.browser"
|
|
219
|
+
]);
|
|
220
|
+
/**
|
|
221
|
+
* Config keys that IMPORT or ENABLE another config scope. `git config --local
|
|
222
|
+
* --list -z` lists these keys but does NOT expand them, so a `filter.*.clean`
|
|
223
|
+
* hidden inside an included file (or the per-worktree config) is invisible to
|
|
224
|
+
* the direct external-program scan above — yet an ordinary `git add`/`status`
|
|
225
|
+
* DOES follow includes and would run that hidden filter. A managed artifact
|
|
226
|
+
* repository never needs an include or a per-worktree config, so we reject the
|
|
227
|
+
* ENTRY POINT itself rather than parsing the (arbitrary, possibly nested)
|
|
228
|
+
* included files: `include.path`, any `includeIf.<condition>.path`, and the
|
|
229
|
+
* `extensions.worktreeConfig` switch that activates `.git/config.worktree`.
|
|
230
|
+
* Keys are already lower-cased by Git config.
|
|
231
|
+
*/
|
|
232
|
+
const CONFIG_INCLUSION_CONFIG_PREFIXES = Object.freeze([
|
|
233
|
+
"include.", "includeif."
|
|
234
|
+
]);
|
|
235
|
+
const CONFIG_INCLUSION_CONFIG_KEYS = new Set([
|
|
236
|
+
"extensions.worktreeconfig"
|
|
237
|
+
]);
|
|
238
|
+
function isExternalProgramConfigKey(key) {
|
|
239
|
+
if (EXTERNAL_PROGRAM_CONFIG_KEYS.has(key))
|
|
240
|
+
return true;
|
|
241
|
+
for (const prefix of EXTERNAL_PROGRAM_CONFIG_PREFIXES)
|
|
242
|
+
if (key.startsWith(prefix))
|
|
243
|
+
return true;
|
|
244
|
+
for (const suffix of EXTERNAL_PROGRAM_CONFIG_SUFFIXES)
|
|
245
|
+
if (key.endsWith(suffix))
|
|
246
|
+
return true;
|
|
247
|
+
// A config-inclusion / alternate-scope entry point can smuggle any of the
|
|
248
|
+
// above in a file this direct scan never opens; reject the entry point itself.
|
|
249
|
+
if (CONFIG_INCLUSION_CONFIG_KEYS.has(key))
|
|
250
|
+
return true;
|
|
251
|
+
for (const prefix of CONFIG_INCLUSION_CONFIG_PREFIXES)
|
|
252
|
+
if (key.startsWith(prefix))
|
|
253
|
+
return true;
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Given the raw stdout of `git config --local --list -z` (records of the form
|
|
258
|
+
* `key\nvalue\0`, keys already lower-cased by Git), return the sorted, de-duped
|
|
259
|
+
* list of repo-local keys that can execute an external program. Empty means the
|
|
260
|
+
* repository's own config is within the managed boundary.
|
|
261
|
+
*
|
|
262
|
+
* PURE: no I/O. Callers (sync migration and async runtime) read the config with
|
|
263
|
+
* their own managed runner and pass the bytes here, so the security policy lives
|
|
264
|
+
* in exactly one testable place. Reading `--local` deliberately excludes the
|
|
265
|
+
* command-line `-c` hardening flags (which are safe and not persisted) and the
|
|
266
|
+
* null-device'd global/system config; it sees only what is written in the repo.
|
|
267
|
+
*/
|
|
268
|
+
export function externalProgramConfigViolations(localConfigListZ) {
|
|
269
|
+
const offending = new Set();
|
|
270
|
+
for (const record of localConfigListZ.split("\0")) {
|
|
271
|
+
if (record.length === 0)
|
|
272
|
+
continue;
|
|
273
|
+
const newline = record.indexOf("\n");
|
|
274
|
+
const key = (newline < 0 ? record : record.slice(0, newline)).toLowerCase();
|
|
275
|
+
if (isExternalProgramConfigKey(key))
|
|
276
|
+
offending.add(key);
|
|
277
|
+
}
|
|
278
|
+
return [...offending].sort();
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* SYNCHRONOUS managed invocation, used ONLY where an async runner is
|
|
282
|
+
* structurally impossible: a storage `migrateData(db)` step runs inside
|
|
283
|
+
* `db.transaction(...)`, which better-sqlite3 requires to be synchronous, yet
|
|
284
|
+
* the 18->19 migration must build per-Task artifact repositories on disk. This
|
|
285
|
+
* shares the SAME hardening as {@link spawnManagedGit} — identical argument
|
|
286
|
+
* refusal, identical scrubbed environment, identical prepended flags — so the
|
|
287
|
+
* synchronous path never weakens the trust boundary. It is not exported for
|
|
288
|
+
* ordinary runtime use; the async runner remains the only production write path.
|
|
289
|
+
*/
|
|
290
|
+
function spawnManagedGitSync(repoPath, args, options) {
|
|
291
|
+
assertSafeArguments(args);
|
|
292
|
+
const env = managedGitEnvironment();
|
|
293
|
+
if (options?.commitDates !== undefined) {
|
|
294
|
+
// Deterministic history: fixed author/committer dates make a rebuild of the
|
|
295
|
+
// same source data reproduce the same commit ids. This only sets metadata.
|
|
296
|
+
env.GIT_AUTHOR_DATE = options.commitDates.author;
|
|
297
|
+
env.GIT_COMMITTER_DATE = options.commitDates.committer;
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const stdout = execFileSync("git", [...HARDENING_FLAGS, "-C", repoPath, ...args], {
|
|
301
|
+
encoding: "buffer",
|
|
302
|
+
env,
|
|
303
|
+
maxBuffer: options?.maxBuffer ?? 16 * 1024 * 1024,
|
|
304
|
+
timeout: options?.timeoutMs ?? 30_000,
|
|
305
|
+
windowsHide: true
|
|
306
|
+
});
|
|
307
|
+
return { stdout: stdout, stderr: Buffer.alloc(0) };
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
throw new ManagedGitError([...args], readErrorStream(error), error);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** Synchronous counterpart to {@link managedGit}; returns trimmed UTF-8 stdout. */
|
|
314
|
+
export function managedGitSync(repoPath, args, options) {
|
|
315
|
+
return spawnManagedGitSync(repoPath, args, options).stdout.toString("utf8");
|
|
316
|
+
}
|
|
317
|
+
/** Synchronous counterpart to {@link managedGitBuffer}; returns raw stdout bytes. */
|
|
318
|
+
export function managedGitSyncBuffer(repoPath, args, options) {
|
|
319
|
+
return spawnManagedGitSync(repoPath, args, options).stdout;
|
|
320
|
+
}
|
|
321
|
+
/** Synchronous counterpart to {@link managedGitSucceeds}; never throws on non-zero exit. */
|
|
322
|
+
export function managedGitSyncSucceeds(repoPath, args, options) {
|
|
323
|
+
try {
|
|
324
|
+
spawnManagedGitSync(repoPath, args, options);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (error instanceof ManagedGitError)
|
|
329
|
+
return false;
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { ManagedGitError, externalProgramConfigViolations, managedGit, managedGitBuffer, managedGitSucceeds, requireCommitId } from "./managedGit.js";
|
|
6
|
+
import { acquireArtifactCommitLock } from "./artifactCommitLock.js";
|
|
7
|
+
import { resolveContainedArtifactPath, safeRelativeArtifactPath, taskArtifactRepoPath } from "./artifactPaths.js";
|
|
8
|
+
/**
|
|
9
|
+
* Per-Task local-only Git artifact repository.
|
|
10
|
+
*
|
|
11
|
+
* This is the authority for a Task's file/directory artifacts: complete plans,
|
|
12
|
+
* prototypes, charts, reports and other multi-file material. It is a plain
|
|
13
|
+
* local Git repository under `<YUI_HOME>/task-artifacts/<task-id>/` with:
|
|
14
|
+
*
|
|
15
|
+
* - NO remote of any kind (network or file). A remote appearing in the repo
|
|
16
|
+
* is treated as an external violation: {@link assertNoRemote} reports it and
|
|
17
|
+
* stops managed writes; the configuration is never silently removed.
|
|
18
|
+
* - a hardened, isolated Git subprocess (see {@link managedGit}) that cannot
|
|
19
|
+
* reach the network, run hooks/filters, or read ambient user/system config.
|
|
20
|
+
* - fixed committer identity and an empty root commit, so HEAD always exists
|
|
21
|
+
* and per-path scoped commits work uniformly from the first real save.
|
|
22
|
+
*
|
|
23
|
+
* Save model (§3.4): write file(s) → validate scope → local commit → saved.
|
|
24
|
+
* A successful commit IS the save; there is no DB round-trip on the write path.
|
|
25
|
+
* Uncommitted changes are work-in-progress. A commit failure preserves the
|
|
26
|
+
* files exactly and surfaces the precise error — nothing is reset or cleaned.
|
|
27
|
+
* One meaningful update is one commit. A short per-Task lock plus an
|
|
28
|
+
* expected-HEAD check make concurrent saves safe; a conflict STOPS (no
|
|
29
|
+
* overwrite, no auto-merge). Branches/PRs/rebase are never exposed.
|
|
30
|
+
*
|
|
31
|
+
* Artifacts are savable from the Draft stage: this store depends only on the
|
|
32
|
+
* Home, never on an active delivery workspace or Project checkout.
|
|
33
|
+
*/
|
|
34
|
+
/** The single managed branch. Never surfaced to callers as a Git concept. */
|
|
35
|
+
const ARTIFACT_BRANCH = "main";
|
|
36
|
+
/** Upper bound for a single artifact file, mirroring the immutable-artifact cap. */
|
|
37
|
+
const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
38
|
+
/** Raised when an artifact repository has a remote configured — an external violation. */
|
|
39
|
+
export class ArtifactRemoteViolationError extends Error {
|
|
40
|
+
taskId;
|
|
41
|
+
remotes;
|
|
42
|
+
constructor(taskId, remotes) {
|
|
43
|
+
super(`Artifact repository for ${taskId} has an external remote configured (${remotes.join(", ")}); ` +
|
|
44
|
+
`managed writes are stopped. Remove the remote manually to resume.`);
|
|
45
|
+
this.name = "ArtifactRemoteViolationError";
|
|
46
|
+
this.taskId = taskId;
|
|
47
|
+
this.remotes = [...remotes];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Raised when an artifact repository's OWN config declares a filter/alias/tool
|
|
52
|
+
* that would run an external program during add/diff/commit. Env-scrubbing hides
|
|
53
|
+
* ambient config, but a repo's `.git/config` is always read; Yui configures none
|
|
54
|
+
* of these, so any that appear are external tampering. Like a remote: report and
|
|
55
|
+
* STOP managed writes, never silently delete the config.
|
|
56
|
+
*/
|
|
57
|
+
export class ArtifactManagedConfigViolationError extends Error {
|
|
58
|
+
taskId;
|
|
59
|
+
keys;
|
|
60
|
+
constructor(taskId, keys) {
|
|
61
|
+
super(`Artifact repository for ${taskId} has repo-local Git config that can run external ` +
|
|
62
|
+
`programs (${keys.join(", ")}); managed writes are stopped. Remove it manually to resume.`);
|
|
63
|
+
this.name = "ArtifactManagedConfigViolationError";
|
|
64
|
+
this.taskId = taskId;
|
|
65
|
+
this.keys = [...keys];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Raised when a save's expected HEAD no longer matches — a concurrent update won. */
|
|
69
|
+
export class ArtifactHeadConflictError extends Error {
|
|
70
|
+
taskId;
|
|
71
|
+
expectedHead;
|
|
72
|
+
actualHead;
|
|
73
|
+
retryable = true;
|
|
74
|
+
constructor(taskId, expectedHead, actualHead) {
|
|
75
|
+
super(`Artifact repository for ${taskId} advanced since it was read ` +
|
|
76
|
+
`(expected ${expectedHead}, found ${actualHead}); no changes were made. Re-read and retry.`);
|
|
77
|
+
this.name = "ArtifactHeadConflictError";
|
|
78
|
+
this.taskId = taskId;
|
|
79
|
+
this.expectedHead = expectedHead;
|
|
80
|
+
this.actualHead = actualHead;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Open (not necessarily create) the artifact repository for a Task. The repo
|
|
85
|
+
* path is derived directly from the Home; call {@link TaskArtifactRepository.ensure}
|
|
86
|
+
* before the first save. `home` MUST be the resolved YUI_HOME
|
|
87
|
+
* (e.g. `store.rootDirectory()`), so ownership is derived from the Task, never
|
|
88
|
+
* from ambient state.
|
|
89
|
+
*/
|
|
90
|
+
export function openTaskArtifactRepository(home, taskId) {
|
|
91
|
+
const repoPath = taskArtifactRepoPath(home, taskId);
|
|
92
|
+
const exists = () => existsSync(join(repoPath, ".git"));
|
|
93
|
+
const head = async () => {
|
|
94
|
+
if (!exists())
|
|
95
|
+
return null;
|
|
96
|
+
return currentHead(repoPath);
|
|
97
|
+
};
|
|
98
|
+
const ensure = async () => {
|
|
99
|
+
if (exists()) {
|
|
100
|
+
await assertNoRemote(taskId, repoPath);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await mkdir(repoPath, { recursive: true, mode: 0o700 });
|
|
104
|
+
await managedGit(repoPath, ["init", "-b", ARTIFACT_BRANCH]);
|
|
105
|
+
// An empty root commit guarantees HEAD exists, so scoped per-path commits
|
|
106
|
+
// and expected-HEAD checks work uniformly from the very first save.
|
|
107
|
+
await managedGit(repoPath, ["commit", "--allow-empty", "-m", "init artifact repo"]);
|
|
108
|
+
await assertNoRemote(taskId, repoPath);
|
|
109
|
+
};
|
|
110
|
+
const save = async (input) => {
|
|
111
|
+
if (input.files.length === 0) {
|
|
112
|
+
throw new Error("An artifact save must include at least one file.");
|
|
113
|
+
}
|
|
114
|
+
// Validate the whole request up front (fail-fast): a caller error must never
|
|
115
|
+
// leave half-written files behind. Only genuine Git failures after this
|
|
116
|
+
// point exercise the preserve-on-failure path.
|
|
117
|
+
const message = requireMessage(input.message);
|
|
118
|
+
const prepared = input.files.map((file) => {
|
|
119
|
+
const relativePath = safeRelativeArtifactPath(file.relativePath);
|
|
120
|
+
if (!Buffer.isBuffer(file.bytes)) {
|
|
121
|
+
throw new Error(`Artifact ${relativePath} bytes must be a Buffer.`);
|
|
122
|
+
}
|
|
123
|
+
if (file.bytes.byteLength > MAX_ARTIFACT_BYTES) {
|
|
124
|
+
throw new Error(`Artifact ${relativePath} exceeds the ${MAX_ARTIFACT_BYTES}-byte limit.`);
|
|
125
|
+
}
|
|
126
|
+
return { relativePath, bytes: file.bytes };
|
|
127
|
+
});
|
|
128
|
+
// Reject duplicate targets in one save — ambiguous intent.
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const file of prepared) {
|
|
131
|
+
if (seen.has(file.relativePath)) {
|
|
132
|
+
throw new Error(`Artifact ${file.relativePath} appears more than once in one save.`);
|
|
133
|
+
}
|
|
134
|
+
seen.add(file.relativePath);
|
|
135
|
+
}
|
|
136
|
+
await ensure();
|
|
137
|
+
const release = acquireArtifactCommitLock(home, taskId);
|
|
138
|
+
try {
|
|
139
|
+
await assertNoRemote(taskId, repoPath);
|
|
140
|
+
// Expected-HEAD check under the lock: a conflict stops without writing.
|
|
141
|
+
if (input.expectedHead !== undefined) {
|
|
142
|
+
const actual = await currentHead(repoPath);
|
|
143
|
+
if (actual !== input.expectedHead) {
|
|
144
|
+
throw new ArtifactHeadConflictError(taskId, input.expectedHead, actual);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Write files first. A commit failure below preserves exactly these bytes;
|
|
148
|
+
// nothing is reset or cleaned, so a caller can inspect and retry.
|
|
149
|
+
for (const file of prepared) {
|
|
150
|
+
const absolute = await resolveContainedArtifactPath(repoPath, file.relativePath);
|
|
151
|
+
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
152
|
+
await writeFile(absolute, file.bytes, { mode: 0o600 });
|
|
153
|
+
}
|
|
154
|
+
const pathspecs = prepared.map((file) => file.relativePath);
|
|
155
|
+
// Stage exactly the saved paths so new files become known to Git...
|
|
156
|
+
await managedGit(repoPath, ["add", "--", ...pathspecs]);
|
|
157
|
+
// ...but if nothing actually changed, do not create an empty commit.
|
|
158
|
+
const noChanges = await managedGitSucceeds(repoPath, [
|
|
159
|
+
"diff", "--cached", "--quiet", "--", ...pathspecs
|
|
160
|
+
]);
|
|
161
|
+
if (noChanges) {
|
|
162
|
+
return { taskId, commit: await currentHead(repoPath), savedPaths: pathspecs };
|
|
163
|
+
}
|
|
164
|
+
// `--only <paths>` records exactly these paths; any other WIP stays WIP.
|
|
165
|
+
await managedGit(repoPath, ["commit", "--only", "-m", message, "--", ...pathspecs]);
|
|
166
|
+
return { taskId, commit: await currentHead(repoPath), savedPaths: pathspecs };
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
release();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const read = async (relativePath, commit) => {
|
|
173
|
+
const safeRelative = safeRelativeArtifactPath(relativePath);
|
|
174
|
+
// A pinned commit reads frozen evidence; otherwise read the current HEAD.
|
|
175
|
+
// Both resolve `<commit>:<path>`, an OBJECT spec (never the working tree, so
|
|
176
|
+
// a read never follows a symlink or sees unrelated WIP).
|
|
177
|
+
const pinned = commit === undefined ? await currentHead(repoPath) : requireCommitId(commit);
|
|
178
|
+
const objectSpec = `${pinned}:${safeRelative}`;
|
|
179
|
+
// `<commit>:<path>` for a DIRECTORY resolves to a tree, and `git show` would
|
|
180
|
+
// print a tree listing rather than fail — silently returning directory
|
|
181
|
+
// metadata as if it were file bytes. Verify the object is a blob first, so a
|
|
182
|
+
// read only ever yields real file content.
|
|
183
|
+
const objectType = (await managedGit(repoPath, ["cat-file", "-t", objectSpec]).catch((error) => {
|
|
184
|
+
if (error instanceof ManagedGitError) {
|
|
185
|
+
throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
})).trim();
|
|
189
|
+
if (objectType !== "blob") {
|
|
190
|
+
throw new Error(`Artifact ${safeRelative} at ${pinned} is not a file (${objectType}).`);
|
|
191
|
+
}
|
|
192
|
+
const bytes = await managedGitBuffer(repoPath, ["cat-file", "blob", objectSpec], {
|
|
193
|
+
maxBuffer: MAX_ARTIFACT_BYTES + 4096
|
|
194
|
+
}).catch((error) => {
|
|
195
|
+
if (error instanceof ManagedGitError) {
|
|
196
|
+
throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
relativePath: safeRelative,
|
|
202
|
+
commit: pinned,
|
|
203
|
+
bytes,
|
|
204
|
+
digest: createHash("sha256").update(bytes).digest("hex")
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
const list = async (commit) => {
|
|
208
|
+
if (!exists())
|
|
209
|
+
return [];
|
|
210
|
+
const pinned = commit === undefined ? await currentHead(repoPath) : requireCommitId(commit);
|
|
211
|
+
const output = await managedGit(repoPath, [
|
|
212
|
+
"ls-tree", "-r", "-z", "--long", "--full-tree", pinned
|
|
213
|
+
]);
|
|
214
|
+
const entries = [];
|
|
215
|
+
for (const record of output.split("\0").filter(Boolean)) {
|
|
216
|
+
// Format: "<mode> <type> <object> <size>\t<path>"
|
|
217
|
+
const tab = record.indexOf("\t");
|
|
218
|
+
if (tab < 0)
|
|
219
|
+
continue;
|
|
220
|
+
const meta = record.slice(0, tab).split(/\s+/u);
|
|
221
|
+
const path = record.slice(tab + 1);
|
|
222
|
+
const size = Number.parseInt(meta[3] ?? "", 10);
|
|
223
|
+
if (path === ".gitignore")
|
|
224
|
+
continue;
|
|
225
|
+
entries.push({ relativePath: path, size: Number.isFinite(size) ? size : 0 });
|
|
226
|
+
}
|
|
227
|
+
return entries;
|
|
228
|
+
};
|
|
229
|
+
const workingChanges = async () => {
|
|
230
|
+
if (!exists())
|
|
231
|
+
return [];
|
|
232
|
+
const output = await managedGit(repoPath, [
|
|
233
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z"
|
|
234
|
+
]);
|
|
235
|
+
// Porcelain v1 -z records: "XY <path>\0" (rename adds a second \0 field).
|
|
236
|
+
const changes = [];
|
|
237
|
+
for (const record of output.split("\0").filter(Boolean)) {
|
|
238
|
+
const path = record.slice(3);
|
|
239
|
+
if (path.length > 0)
|
|
240
|
+
changes.push(path);
|
|
241
|
+
}
|
|
242
|
+
return changes;
|
|
243
|
+
};
|
|
244
|
+
return { taskId, repoPath, ensure, exists, head, save, read, list, workingChanges };
|
|
245
|
+
}
|
|
246
|
+
async function currentHead(repoPath) {
|
|
247
|
+
return requireCommitId(await managedGit(repoPath, ["rev-parse", "HEAD^{commit}"]));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Verify the repository is within the managed boundary before a write: NO remote
|
|
251
|
+
* (network or file sync) AND no repo-local config that can execute an external
|
|
252
|
+
* program on add/diff/commit. Both are external violations — we report and stop,
|
|
253
|
+
* never silently remove them.
|
|
254
|
+
*/
|
|
255
|
+
async function assertNoRemote(taskId, repoPath) {
|
|
256
|
+
const output = await managedGit(repoPath, ["remote"]);
|
|
257
|
+
const remotes = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
258
|
+
if (remotes.length > 0) {
|
|
259
|
+
throw new ArtifactRemoteViolationError(taskId, remotes);
|
|
260
|
+
}
|
|
261
|
+
// A repo's own `.git/config` is read even with global/system config disabled,
|
|
262
|
+
// so a committed `.gitattributes` filter paired with a repo-local
|
|
263
|
+
// `filter.<n>.clean = <cmd>` would run `<cmd>` on `git add`. Detect and stop.
|
|
264
|
+
const configListZ = await managedGit(repoPath, ["config", "--local", "--list", "-z"]);
|
|
265
|
+
const violations = externalProgramConfigViolations(configListZ);
|
|
266
|
+
if (violations.length > 0) {
|
|
267
|
+
throw new ArtifactManagedConfigViolationError(taskId, violations);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function requireMessage(message) {
|
|
271
|
+
const trimmed = typeof message === "string" ? message.trim() : "";
|
|
272
|
+
if (trimmed.length === 0)
|
|
273
|
+
throw new Error("An artifact save requires a commit message.");
|
|
274
|
+
if (trimmed.startsWith("-"))
|
|
275
|
+
throw new Error("An artifact commit message may not begin with a dash.");
|
|
276
|
+
return trimmed;
|
|
277
|
+
}
|
package/dist/brief/taskBrief.js
CHANGED
|
@@ -11,6 +11,7 @@ export function createTaskBrief(input, now) {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
export function updateTaskBrief(brief, patch, updatedBy, now) {
|
|
14
|
+
validateTaskBrief(brief);
|
|
14
15
|
return createTaskBrief({
|
|
15
16
|
objective: patch.objective ?? brief.objective,
|
|
16
17
|
boundaries: patch.boundaries ?? brief.boundaries,
|
|
@@ -20,6 +21,17 @@ export function updateTaskBrief(brief, patch, updatedBy, now) {
|
|
|
20
21
|
updatedBy
|
|
21
22
|
}, now);
|
|
22
23
|
}
|
|
24
|
+
export function validateTaskBrief(brief) {
|
|
25
|
+
if (brief.schemaVersion !== 2) {
|
|
26
|
+
throw new Error("Task Brief requires schemaVersion 2.");
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(brief.boundaries) || typeof brief.technicalApproach !== "string"
|
|
29
|
+
|| typeof brief.updatedAt !== "string" || !Number.isFinite(Date.parse(brief.updatedAt))) {
|
|
30
|
+
throw new Error("Task Brief content or timestamp is invalid.");
|
|
31
|
+
}
|
|
32
|
+
createTaskBrief(brief, new Date(brief.updatedAt));
|
|
33
|
+
return brief;
|
|
34
|
+
}
|
|
23
35
|
function normalizeBoundaries(values) {
|
|
24
36
|
const normalized = values.map((value) => {
|
|
25
37
|
if (typeof value !== "string" || value.includes("\0")) {
|
|
@@ -78,6 +78,19 @@ export async function selectAgentPermission(resolved, io, current) {
|
|
|
78
78
|
const mode = await selectPermissionField(resolved, io, "permission.mode", claude?.mode, claude === undefined ? undefined : OMIT);
|
|
79
79
|
if (mode.kind === "cancelled")
|
|
80
80
|
return mode;
|
|
81
|
+
// ACP's configured permission is exactly the mode, and its adapter requires
|
|
82
|
+
// one: the value is matched against what the Agent enumerates at launch, so
|
|
83
|
+
// there is no "configured with nothing set" to fall back to. Claude's remains
|
|
84
|
+
// optional because its other native options can carry the configuration
|
|
85
|
+
// instead.
|
|
86
|
+
if (resolved.catalog.adapterId === "acp") {
|
|
87
|
+
return mode.value === undefined
|
|
88
|
+
? { kind: "cancelled" }
|
|
89
|
+
: {
|
|
90
|
+
kind: "selected",
|
|
91
|
+
permission: { strategy: "configured", mode: mode.value }
|
|
92
|
+
};
|
|
93
|
+
}
|
|
81
94
|
const permission = {
|
|
82
95
|
...(claude ?? { strategy: "configured" })
|
|
83
96
|
};
|