@defrex/autobuild 0.8.0
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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-level glue the operator HTTP server applies around the shared
|
|
3
|
+
* services, re-derived for the agent tool registry (operator/registry.ts).
|
|
4
|
+
*
|
|
5
|
+
* This module is registry-owned on purpose: the operator API routes were
|
|
6
|
+
* declared out of scope for the registry ticket, so the parity between a
|
|
7
|
+
* tool and its route is *proven* by the contract suite (registry.contract
|
|
8
|
+
* .test.ts) rather than obtained by extraction. Every refusal text here is
|
|
9
|
+
* load-bearing — it must stay byte-identical to `operator/server.ts` so the
|
|
10
|
+
* suite's full-body comparisons hold.
|
|
11
|
+
*/
|
|
12
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
13
|
+
import { effectiveStatus } from '../cli/dashboard/model'
|
|
14
|
+
import { BuildControlError, type BuildControlAction } from '../cli/build-control'
|
|
15
|
+
import type { BuildStore } from '../store/types'
|
|
16
|
+
import type { OperatorAnswerRequest } from './protocol'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The failure body an operator API route emits when its pre-service glue
|
|
20
|
+
* refuses (HttpError). Domain errors carry a `code`; these pre-service
|
|
21
|
+
* refusals never do, and the registry's mapped failure must match.
|
|
22
|
+
*/
|
|
23
|
+
export interface RouteRefusal {
|
|
24
|
+
kind: 'validation' | 'auth' | 'not-found' | 'conflict'
|
|
25
|
+
error: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Thrown by the registry-owned glue with the route's exact failure body. */
|
|
29
|
+
export class RouteRefusalError extends Error {
|
|
30
|
+
override readonly name = 'RouteRefusalError'
|
|
31
|
+
|
|
32
|
+
constructor(readonly refusal: RouteRefusal) {
|
|
33
|
+
super(refusal.error)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `requireRouteBuild` (operator/server.ts): 404 unless the slug exists and
|
|
38
|
+
* belongs to the named repository. */
|
|
39
|
+
export async function requireRouteBuild(
|
|
40
|
+
store: BuildStore,
|
|
41
|
+
repo: string,
|
|
42
|
+
slug: string,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const record = await store.getBuild(slug)
|
|
45
|
+
if (record === null || record.repo !== repo) {
|
|
46
|
+
throw new RouteRefusalError({ kind: 'not-found', error: `unknown build "${slug}"` })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The artifact revision query rule the artifact route applies to `?rev`. */
|
|
51
|
+
export function parseArtifactRevision(raw: string): number {
|
|
52
|
+
if (!/^[0-9]+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
|
|
53
|
+
throw new RouteRefusalError({
|
|
54
|
+
kind: 'validation',
|
|
55
|
+
error: 'rev must be a nonnegative integer',
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
return Number(raw)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The route's control-request prechecks: build existence/repo, then the
|
|
63
|
+
* pending-state checks for pause and cancel-pause. Byte-identical to the
|
|
64
|
+
* `POST …/control` branch in operator/server.ts.
|
|
65
|
+
*/
|
|
66
|
+
export async function controlPrechecks(
|
|
67
|
+
store: BuildStore,
|
|
68
|
+
repo: string,
|
|
69
|
+
slug: string,
|
|
70
|
+
action:
|
|
71
|
+
| 'pause'
|
|
72
|
+
| 'cancel-pause'
|
|
73
|
+
| 'resume'
|
|
74
|
+
| 'auto-merge-on'
|
|
75
|
+
| 'auto-merge-off'
|
|
76
|
+
| 'abort'
|
|
77
|
+
| 'discard',
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
await requireRouteBuild(store, repo, slug)
|
|
80
|
+
if (action === 'pause' || action === 'cancel-pause') {
|
|
81
|
+
const state = reduceBuild(await store.getEvents(slug))
|
|
82
|
+
const display = effectiveStatus(state)
|
|
83
|
+
if (action === 'pause' && display === 'pausing') {
|
|
84
|
+
throw new BuildControlError(
|
|
85
|
+
'inactive',
|
|
86
|
+
`build "${slug}" cannot pause (status: pausing); pause is already pending`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (action === 'cancel-pause' && display !== 'pausing') {
|
|
90
|
+
throw new BuildControlError(
|
|
91
|
+
'inactive',
|
|
92
|
+
`build "${slug}" cannot cancel pause (status: ${state.status}); cancel pause requires a pending pause`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The route's answer request → `BuildControlAction` translation, byte-identical
|
|
100
|
+
* to the `POST …/answer` branch in operator/server.ts. The ticket-origin body
|
|
101
|
+
* reader travels separately, exactly as the route passes `readTicketBody`.
|
|
102
|
+
*/
|
|
103
|
+
export function answerAction(request: OperatorAnswerRequest): {
|
|
104
|
+
action: BuildControlAction
|
|
105
|
+
readTicketBody?: () => Promise<string>
|
|
106
|
+
} {
|
|
107
|
+
const action: BuildControlAction =
|
|
108
|
+
request.resolution === 'guidance'
|
|
109
|
+
? { kind: 'answer', text: request.text }
|
|
110
|
+
: request.resolution === 'retry'
|
|
111
|
+
? { kind: 'answer' }
|
|
112
|
+
: request.resolution === 'dismiss'
|
|
113
|
+
? { kind: 'answer', text: request.text, resolve: { kind: 'dismiss-finding' } }
|
|
114
|
+
: request.resolution === 'review-round-ceiling'
|
|
115
|
+
? { kind: 'answer', text: request.text, reviewRoundCeiling: request.ceiling }
|
|
116
|
+
: {
|
|
117
|
+
kind: 'answer',
|
|
118
|
+
text: request.text,
|
|
119
|
+
...(request.ceiling !== undefined ? { reviewRoundCeiling: request.ceiling } : {}),
|
|
120
|
+
resolve: {
|
|
121
|
+
kind: 'revise-spec',
|
|
122
|
+
body:
|
|
123
|
+
request.origin === 'body'
|
|
124
|
+
? {
|
|
125
|
+
kind: 'supplied',
|
|
126
|
+
origin: 'operator API body',
|
|
127
|
+
read: async () => request.body,
|
|
128
|
+
}
|
|
129
|
+
: { kind: 'ticket' },
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
action,
|
|
134
|
+
...(request.resolution === 'revise-spec' && request.origin === 'ticket'
|
|
135
|
+
? { readTicketBody: async () => request.body }
|
|
136
|
+
: {}),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator sandbox service (AUT-340): the single validation,
|
|
3
|
+
* serialization, journaling, and typed-error point behind the registry's
|
|
4
|
+
* `sandbox.*` tools. The registry hands it only the caller's attributed
|
|
5
|
+
* identity plus the tool input; the service owns every journal append and
|
|
6
|
+
* derives lifecycle state from the repository journal (`sandboxStates`), so
|
|
7
|
+
* a later dispatcher invocation can settle what this process left behind.
|
|
8
|
+
*
|
|
9
|
+
* Credential freeness is structural upstream (the providers build guest
|
|
10
|
+
* environments from empty records); redaction here keeps credential VALUES
|
|
11
|
+
* out of typed error messages that travel to tool callers.
|
|
12
|
+
*/
|
|
13
|
+
import { humanActor } from '../events/envelope'
|
|
14
|
+
import {
|
|
15
|
+
SANDBOX_FORBIDDEN_ENV,
|
|
16
|
+
SandboxOperationError,
|
|
17
|
+
type SandboxCommandResult,
|
|
18
|
+
type SandboxEnvironmentIdentity,
|
|
19
|
+
type SandboxWaitResult,
|
|
20
|
+
} from '../ports/workspace/operator-sandbox'
|
|
21
|
+
import type { WorkspaceProvider } from '../ports/types'
|
|
22
|
+
import { sandboxStates } from '../processes/sandbox-state'
|
|
23
|
+
import { systemClock, type BuildStore, type Clock } from '../store/types'
|
|
24
|
+
|
|
25
|
+
/** Hard bound on one sandbox command wait, in seconds — shared with the
|
|
26
|
+
* adapters (`SANDBOX_MAX_WAIT_SECONDS`). */
|
|
27
|
+
export const SANDBOX_EXEC_MAX_SECONDS = 300
|
|
28
|
+
/** Per-stream truncation bound for tool-returned output. */
|
|
29
|
+
export const SANDBOX_OUTPUT_LIMIT_BYTES = 65_536
|
|
30
|
+
export const SANDBOX_TRUNCATION_MARKER = '[truncated by autobuild: output exceeded 65536 bytes]'
|
|
31
|
+
/** Maximum `write_file` content size. */
|
|
32
|
+
export const SANDBOX_WRITE_LIMIT_BYTES = 1_048_576
|
|
33
|
+
/** Minimum journal interval between two `orchestrator.sandbox.activity`
|
|
34
|
+
* facts for one environment. */
|
|
35
|
+
export const SANDBOX_ACTIVITY_INTERVAL_MS = 60_000
|
|
36
|
+
|
|
37
|
+
/** Truncate one output stream to the byte bound, head-kept, with the
|
|
38
|
+
* documented marker appended past the bound. Exactly-at-bound output is
|
|
39
|
+
* returned unmodified. */
|
|
40
|
+
export function truncateSandboxOutput(output: string): string {
|
|
41
|
+
const bytes = Buffer.from(output, 'utf8')
|
|
42
|
+
if (bytes.length <= SANDBOX_OUTPUT_LIMIT_BYTES) return output
|
|
43
|
+
const head = bytes.subarray(0, SANDBOX_OUTPUT_LIMIT_BYTES).toString('utf8')
|
|
44
|
+
// Drop a possibly split trailing code unit so the marker rides on valid text.
|
|
45
|
+
const repaired =
|
|
46
|
+
head.length > 0 && Buffer.from(head, 'utf8').length > SANDBOX_OUTPUT_LIMIT_BYTES
|
|
47
|
+
? head.slice(0, -1)
|
|
48
|
+
: head
|
|
49
|
+
return `${repaired}\n${SANDBOX_TRUNCATION_MARKER}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pure lexical sandbox-path check: reject absolute paths, empty components,
|
|
53
|
+
* and any `..` after normalization. Not a sandbox boundary — the credential
|
|
54
|
+
* free rule bounds what a guest escape could reach — but it keeps tool file
|
|
55
|
+
* paths inside the checkout. */
|
|
56
|
+
export function resolveSandboxRelativePath(path: string): string {
|
|
57
|
+
if (path.length === 0) {
|
|
58
|
+
throw new SandboxOperationError('exec', 'sandbox path must be nonempty')
|
|
59
|
+
}
|
|
60
|
+
if (path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)) {
|
|
61
|
+
throw new SandboxOperationError(
|
|
62
|
+
'exec',
|
|
63
|
+
`sandbox path ${JSON.stringify(path)} must be relative to the checkout`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
if (path.includes('\\')) {
|
|
67
|
+
throw new SandboxOperationError(
|
|
68
|
+
'exec',
|
|
69
|
+
`sandbox path ${JSON.stringify(path)} must use "/" separators`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
const segments: string[] = []
|
|
73
|
+
for (const segment of path.split('/')) {
|
|
74
|
+
if (segment === '' || segment === '.') continue
|
|
75
|
+
if (segment === '..') {
|
|
76
|
+
if (segments.length === 0) {
|
|
77
|
+
throw new SandboxOperationError(
|
|
78
|
+
'exec',
|
|
79
|
+
`sandbox path ${JSON.stringify(path)} escapes the checkout`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
segments.pop()
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
segments.push(segment)
|
|
86
|
+
}
|
|
87
|
+
if (segments.length === 0) {
|
|
88
|
+
throw new SandboxOperationError('exec', `sandbox path ${JSON.stringify(path)} names no file`)
|
|
89
|
+
}
|
|
90
|
+
return segments.join('/')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Redact values of credential-named environment variables from a provider
|
|
94
|
+
* message (the existing credential-redaction discipline): the message may
|
|
95
|
+
* travel to a tool caller, so a value of any forbidden name never rides on
|
|
96
|
+
* it. Values are read from the host environment of the serving process. */
|
|
97
|
+
export function redactSandboxMessage(message: string): string {
|
|
98
|
+
let redacted = message
|
|
99
|
+
for (const name of SANDBOX_FORBIDDEN_ENV) {
|
|
100
|
+
const value = process.env[name]
|
|
101
|
+
if (value !== undefined && value !== '') redacted = redacted.split(value).join('[REDACTED]')
|
|
102
|
+
}
|
|
103
|
+
return redacted
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function describeError(error: unknown): string {
|
|
107
|
+
return redactSandboxMessage(error instanceof Error ? error.message : String(error))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface OperatorSandboxConfig {
|
|
111
|
+
idleMinutes: number
|
|
112
|
+
environmentVariables: readonly string[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface OperatorSandboxService {
|
|
116
|
+
exec(
|
|
117
|
+
identity: string,
|
|
118
|
+
input: { repo: string; command: string; cwd?: string; timeoutSeconds?: number },
|
|
119
|
+
): Promise<SandboxCommandResult>
|
|
120
|
+
start(
|
|
121
|
+
identity: string,
|
|
122
|
+
input: { repo: string; command: string; cwd?: string },
|
|
123
|
+
): Promise<{ commandId: string }>
|
|
124
|
+
wait(
|
|
125
|
+
identity: string,
|
|
126
|
+
input: { repo: string; commandId: string; waitSeconds: number },
|
|
127
|
+
): Promise<SandboxWaitResult>
|
|
128
|
+
readFile(identity: string, input: { repo: string; path: string }): Promise<Uint8Array>
|
|
129
|
+
writeFile(
|
|
130
|
+
identity: string,
|
|
131
|
+
input: { repo: string; path: string; content: string; encoding: 'utf8' | 'base64' },
|
|
132
|
+
): Promise<void>
|
|
133
|
+
/** Destructive: journal `reset`, full teardown + snapshot purge, then a
|
|
134
|
+
* fresh provision from the CURRENT base head. */
|
|
135
|
+
reset(identity: string, input: { repo: string }): Promise<void>
|
|
136
|
+
/** Full teardown + snapshot purge of the operator's environment, without
|
|
137
|
+
* provisioning. A no-op when the journal shows no environment. Used by
|
|
138
|
+
* `reset` and the session-archive hook. */
|
|
139
|
+
release(identity: string, input: { repo: string }): Promise<void>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface OperatorSandboxServiceOptions {
|
|
143
|
+
store: BuildStore
|
|
144
|
+
repo: string
|
|
145
|
+
provider: WorkspaceProvider
|
|
146
|
+
sandbox: OperatorSandboxConfig
|
|
147
|
+
/** The binding's parsed `[baseBranch]`: reset always provisions from this
|
|
148
|
+
* branch's current head. A hot baseBranch reload is picked up when the
|
|
149
|
+
* `ab mcp` process restarts. */
|
|
150
|
+
baseBranch: string
|
|
151
|
+
clock?: Clock
|
|
152
|
+
capability?: WorkspaceProvider['orchestratorSandbox']
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function createOperatorSandboxService(
|
|
156
|
+
options: OperatorSandboxServiceOptions,
|
|
157
|
+
): Promise<OperatorSandboxService> {
|
|
158
|
+
const store = options.store
|
|
159
|
+
const repo = options.repo
|
|
160
|
+
const provider = options.provider
|
|
161
|
+
const capability = options.capability ?? provider.orchestratorSandbox
|
|
162
|
+
if (capability === undefined) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`workspace provider "${provider.name}" cannot host operator sandboxes; sandbox tools are unavailable`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
const clock = options.clock ?? systemClock
|
|
168
|
+
await store.ensureRepo(repo)
|
|
169
|
+
|
|
170
|
+
/** Per-environment serialization: one in-process promise chain keyed by
|
|
171
|
+
* (repo, operator) — equivalent to keying by environment because the
|
|
172
|
+
* mapping is 1:1 — so calls against one environment serialize. */
|
|
173
|
+
const chains = new Map<string, Promise<unknown>>()
|
|
174
|
+
const serialize = async <T>(key: string, operation: () => Promise<T>): Promise<T> => {
|
|
175
|
+
const previous = chains.get(key) ?? Promise.resolve()
|
|
176
|
+
const next = previous.then(operation, operation)
|
|
177
|
+
chains.set(
|
|
178
|
+
key,
|
|
179
|
+
next.catch(() => {}),
|
|
180
|
+
)
|
|
181
|
+
return next
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const append = async (
|
|
185
|
+
identity: string,
|
|
186
|
+
type:
|
|
187
|
+
| 'orchestrator.sandbox.provisioned'
|
|
188
|
+
| 'orchestrator.sandbox.resumed'
|
|
189
|
+
| 'orchestrator.sandbox.activity'
|
|
190
|
+
| 'orchestrator.sandbox.released'
|
|
191
|
+
| 'orchestrator.sandbox.reset',
|
|
192
|
+
payload: Record<string, unknown>,
|
|
193
|
+
): Promise<void> => {
|
|
194
|
+
await store.appendRepo(repo, {
|
|
195
|
+
actor: humanActor(identity),
|
|
196
|
+
type,
|
|
197
|
+
payload: payload as never,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const journalEvents = async (): Promise<Awaited<ReturnType<typeof store.getRepoEvents>>> => {
|
|
202
|
+
if ((await store.getRepo(repo)) === null) return []
|
|
203
|
+
return store.getRepoEvents(repo)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The operator's journal state, or undefined when no environment exists. */
|
|
207
|
+
const operatorState = (identity: string, events: Awaited<ReturnType<typeof journalEvents>>) =>
|
|
208
|
+
sandboxStates(events)
|
|
209
|
+
.filter((state) => state.operator === identity && state.state !== 'released')
|
|
210
|
+
.at(-1)
|
|
211
|
+
|
|
212
|
+
/** Ensure (provision or resume) with the journal facts the transition
|
|
213
|
+
* produces. Returns the resolved identity. */
|
|
214
|
+
const ensureWithFacts = async (
|
|
215
|
+
identity: string,
|
|
216
|
+
state: ReturnType<typeof operatorState>,
|
|
217
|
+
): Promise<SandboxEnvironmentIdentity> => {
|
|
218
|
+
const resolved = await capability.ensure({
|
|
219
|
+
repo,
|
|
220
|
+
operator: identity,
|
|
221
|
+
baseBranch: options.baseBranch,
|
|
222
|
+
})
|
|
223
|
+
if (state === undefined) {
|
|
224
|
+
await append(identity, 'orchestrator.sandbox.provisioned', {
|
|
225
|
+
operator: identity,
|
|
226
|
+
environmentId: resolved.environmentId,
|
|
227
|
+
provider: resolved.provider,
|
|
228
|
+
...(resolved.sessionId !== undefined ? { sessionId: resolved.sessionId } : {}),
|
|
229
|
+
workspacePath: resolved.workspacePath,
|
|
230
|
+
})
|
|
231
|
+
} else if (state.state === 'stopped') {
|
|
232
|
+
await append(identity, 'orchestrator.sandbox.resumed', {
|
|
233
|
+
operator: identity,
|
|
234
|
+
environmentId: resolved.environmentId,
|
|
235
|
+
provider: resolved.provider,
|
|
236
|
+
...(resolved.sessionId !== undefined ? { sessionId: resolved.sessionId } : {}),
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
return resolved
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Rate-limited activity evidence: at most one fact per interval per
|
|
243
|
+
* environment, compared against the latest journaled evidence. */
|
|
244
|
+
const maybeAppendActivity = async (
|
|
245
|
+
identity: string,
|
|
246
|
+
state: ReturnType<typeof operatorState>,
|
|
247
|
+
): Promise<void> => {
|
|
248
|
+
if (state === undefined) return
|
|
249
|
+
const last = Date.parse(state.lastEvidenceTs)
|
|
250
|
+
if (Number.isFinite(last) && clock().getTime() - last < SANDBOX_ACTIVITY_INTERVAL_MS) return
|
|
251
|
+
const resolved = await capability.describe({ repo, operator: identity })
|
|
252
|
+
await append(identity, 'orchestrator.sandbox.activity', {
|
|
253
|
+
operator: identity,
|
|
254
|
+
environmentId: resolved.environmentId,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const requireRepo = (inputRepo: string | undefined): void => {
|
|
259
|
+
if (inputRepo !== undefined && inputRepo !== repo) {
|
|
260
|
+
throw new SandboxOperationError(
|
|
261
|
+
'exec',
|
|
262
|
+
`sandbox tools serve repository "${repo}"; refusing "${inputRepo}"`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const run = async <T>(
|
|
268
|
+
identity: string,
|
|
269
|
+
stage: 'provision' | 'resume' | 'exec',
|
|
270
|
+
operation: (
|
|
271
|
+
resolved: SandboxEnvironmentIdentity,
|
|
272
|
+
state: ReturnType<typeof operatorState>,
|
|
273
|
+
) => Promise<T>,
|
|
274
|
+
): Promise<T> => {
|
|
275
|
+
const key = `${repo}\0${identity}`
|
|
276
|
+
return serialize(key, async () => {
|
|
277
|
+
const events = await journalEvents()
|
|
278
|
+
const state = operatorState(identity, events)
|
|
279
|
+
const resolved = await ensureWithFacts(identity, state).catch((error: unknown) => {
|
|
280
|
+
if (error instanceof SandboxOperationError) throw error
|
|
281
|
+
throw new SandboxOperationError(
|
|
282
|
+
state === undefined ? 'provision' : 'resume',
|
|
283
|
+
describeError(error),
|
|
284
|
+
{ cause: error },
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
const freshEvents = await journalEvents()
|
|
288
|
+
const freshState = operatorState(identity, freshEvents)
|
|
289
|
+
try {
|
|
290
|
+
const result = await operation(resolved, freshState)
|
|
291
|
+
await maybeAppendActivity(identity, freshState).catch(() => {})
|
|
292
|
+
return result
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error instanceof SandboxOperationError) throw error
|
|
295
|
+
throw new SandboxOperationError(stage, describeError(error), { cause: error })
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
async exec(identity, input) {
|
|
302
|
+
requireRepo(input.repo)
|
|
303
|
+
const timeoutSeconds = validateBoundedInt(
|
|
304
|
+
input.timeoutSeconds ?? 120,
|
|
305
|
+
1,
|
|
306
|
+
SANDBOX_EXEC_MAX_SECONDS,
|
|
307
|
+
'timeoutSeconds',
|
|
308
|
+
)
|
|
309
|
+
const cwd = input.cwd === undefined ? undefined : resolveSandboxRelativePath(input.cwd)
|
|
310
|
+
return run(identity, 'exec', async (resolved) => {
|
|
311
|
+
const result = await capability.exec(resolved, {
|
|
312
|
+
command: input.command,
|
|
313
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
314
|
+
timeoutSeconds,
|
|
315
|
+
})
|
|
316
|
+
return {
|
|
317
|
+
exitCode: result.exitCode,
|
|
318
|
+
stdout: truncateSandboxOutput(result.stdout),
|
|
319
|
+
stderr: truncateSandboxOutput(result.stderr),
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async start(identity, input) {
|
|
325
|
+
requireRepo(input.repo)
|
|
326
|
+
const cwd = input.cwd === undefined ? undefined : resolveSandboxRelativePath(input.cwd)
|
|
327
|
+
return run(identity, 'exec', async (resolved) =>
|
|
328
|
+
capability.start(resolved, {
|
|
329
|
+
command: input.command,
|
|
330
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
331
|
+
}),
|
|
332
|
+
)
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
async wait(identity, input) {
|
|
336
|
+
requireRepo(input.repo)
|
|
337
|
+
const waitSeconds = validateBoundedInt(
|
|
338
|
+
input.waitSeconds,
|
|
339
|
+
0,
|
|
340
|
+
SANDBOX_EXEC_MAX_SECONDS,
|
|
341
|
+
'waitSeconds',
|
|
342
|
+
)
|
|
343
|
+
return run(identity, 'exec', async (resolved) => {
|
|
344
|
+
const result = await capability.wait(resolved, {
|
|
345
|
+
commandId: input.commandId,
|
|
346
|
+
waitSeconds,
|
|
347
|
+
})
|
|
348
|
+
if (result.state === 'running') {
|
|
349
|
+
// Partial output so far rides on `running` too: providers that
|
|
350
|
+
// stream (the local faces) supply their buffers here, and Vercel —
|
|
351
|
+
// which reports output only after exit — supplies none. Truncation
|
|
352
|
+
// bounds apply to both shapes.
|
|
353
|
+
return {
|
|
354
|
+
state: 'running' as const,
|
|
355
|
+
...(result.stdout !== undefined
|
|
356
|
+
? { stdout: truncateSandboxOutput(result.stdout) }
|
|
357
|
+
: {}),
|
|
358
|
+
...(result.stderr !== undefined
|
|
359
|
+
? { stderr: truncateSandboxOutput(result.stderr) }
|
|
360
|
+
: {}),
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
state: 'exited' as const,
|
|
365
|
+
exitCode: result.exitCode,
|
|
366
|
+
stdout: truncateSandboxOutput(result.stdout ?? ''),
|
|
367
|
+
stderr: truncateSandboxOutput(result.stderr ?? ''),
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
async readFile(identity, input) {
|
|
373
|
+
requireRepo(input.repo)
|
|
374
|
+
const path = resolveSandboxRelativePath(input.path)
|
|
375
|
+
return run(identity, 'exec', async (resolved) => capability.readFile(resolved, path))
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
async writeFile(identity, input) {
|
|
379
|
+
requireRepo(input.repo)
|
|
380
|
+
const path = resolveSandboxRelativePath(input.path)
|
|
381
|
+
const content =
|
|
382
|
+
input.encoding === 'base64'
|
|
383
|
+
? Buffer.from(input.content, 'base64')
|
|
384
|
+
: Buffer.from(input.content, 'utf8')
|
|
385
|
+
if (content.length > SANDBOX_WRITE_LIMIT_BYTES) {
|
|
386
|
+
throw new SandboxOperationError(
|
|
387
|
+
'exec',
|
|
388
|
+
`sandbox write exceeds the ${SANDBOX_WRITE_LIMIT_BYTES}-byte bound`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
await run(identity, 'exec', async (resolved) => {
|
|
392
|
+
await capability.writeFile(resolved, path, new Uint8Array(content))
|
|
393
|
+
})
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
async reset(identity, input) {
|
|
397
|
+
requireRepo(input.repo)
|
|
398
|
+
const key = `${repo}\0${identity}`
|
|
399
|
+
await serialize(key, async () => {
|
|
400
|
+
const events = await journalEvents()
|
|
401
|
+
const state = operatorState(identity, events)
|
|
402
|
+
const resolved = await capability.describe({ repo, operator: identity })
|
|
403
|
+
await append(identity, 'orchestrator.sandbox.reset', {
|
|
404
|
+
operator: identity,
|
|
405
|
+
environmentId: resolved.environmentId,
|
|
406
|
+
})
|
|
407
|
+
// Release first (snapshot purge), then reprovision from the CURRENT
|
|
408
|
+
// base head. A release failure still surfaces typed; the journal
|
|
409
|
+
// carries reset → released → provisioned as the evidence chain.
|
|
410
|
+
const { snapshots } = await capability
|
|
411
|
+
.release({ repo, operator: identity, environmentId: resolved.environmentId })
|
|
412
|
+
.catch((error: unknown) => {
|
|
413
|
+
throw new SandboxOperationError('reset', describeError(error), { cause: error })
|
|
414
|
+
})
|
|
415
|
+
await append(identity, 'orchestrator.sandbox.released', {
|
|
416
|
+
operator: identity,
|
|
417
|
+
environmentId: resolved.environmentId,
|
|
418
|
+
snapshots,
|
|
419
|
+
})
|
|
420
|
+
await ensureWithFacts(identity, undefined).catch((error: unknown) => {
|
|
421
|
+
if (error instanceof SandboxOperationError) throw error
|
|
422
|
+
throw new SandboxOperationError('provision', describeError(error), { cause: error })
|
|
423
|
+
})
|
|
424
|
+
void state
|
|
425
|
+
})
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
async release(identity, input) {
|
|
429
|
+
requireRepo(input.repo)
|
|
430
|
+
const key = `${repo}\0${identity}`
|
|
431
|
+
await serialize(key, async () => {
|
|
432
|
+
const events = await journalEvents()
|
|
433
|
+
const state = operatorState(identity, events)
|
|
434
|
+
// No environment ever provisioned: a no-op, with no fact and no
|
|
435
|
+
// provider traffic beyond the pure describe.
|
|
436
|
+
if (state === undefined) return
|
|
437
|
+
const resolved = await capability.describe({ repo, operator: identity })
|
|
438
|
+
const { snapshots } = await capability
|
|
439
|
+
.release({ repo, operator: identity, environmentId: resolved.environmentId })
|
|
440
|
+
.catch((error: unknown) => {
|
|
441
|
+
throw new SandboxOperationError('release', describeError(error), { cause: error })
|
|
442
|
+
})
|
|
443
|
+
await append(identity, 'orchestrator.sandbox.released', {
|
|
444
|
+
operator: identity,
|
|
445
|
+
environmentId: resolved.environmentId,
|
|
446
|
+
snapshots,
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function validateBoundedInt(value: number, min: number, max: number, field: string): number {
|
|
454
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
455
|
+
throw new SandboxOperationError('exec', `${field} must be an integer between ${min} and ${max}`)
|
|
456
|
+
}
|
|
457
|
+
return value
|
|
458
|
+
}
|