orizu 0.4.0 → 0.5.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/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +226 -0
- package/dist/help.js +276 -21
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +945 -258
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +96 -0
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +366 -0
- package/src/help.ts +277 -21
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +1102 -317
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +164 -0
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +8 -4
- package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-sandbox hosted agent loop (ALI-928 / P3.5, per ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* Runs INSIDE the sandbox (installed by the bootstrap CLI step, launched
|
|
5
|
+
* DETACHED by `orizu session start --hosted`). It composes the ALI-926 pieces:
|
|
6
|
+
*
|
|
7
|
+
* 1. resolve the loop boot context (a run-scoped JSON file written by the host
|
|
8
|
+
* orchestrator) + read the agent bearer from its 0600 file (G3);
|
|
9
|
+
* 2. install `opencode` pinned to OPENCODE_PINNED_VERSION (non-fatal-RECORDED;
|
|
10
|
+
* if it is unavailable the spawn/connect below fails and the run finishes
|
|
11
|
+
* 'failed' cleanly — the run never dangles);
|
|
12
|
+
* 3. build a RESUME-AWARE RunEventSink (continues the server's sequence after
|
|
13
|
+
* the run-start + bootstrap events — so a reconnect loses nothing);
|
|
14
|
+
* 4. provision the `AgentHarness` SELECTED by `context.harness` (default
|
|
15
|
+
* 'opencode': install+spawn `opencode serve` then connect the OpenCode
|
|
16
|
+
* driver; 'claude-agent-sdk': construct the in-process Claude-Agent-SDK
|
|
17
|
+
* driver — no server to install or spawn), then drive the SINGLE task prompt
|
|
18
|
+
* (v0: no queue) through `drainHarnessToSink`;
|
|
19
|
+
* 5. the terminal event decides the run's final status via the sink's PATCH.
|
|
20
|
+
*
|
|
21
|
+
* SWAPPABILITY (ALI-929 / P3.6): harness SELECTION is the ONLY thing that differs
|
|
22
|
+
* between the two drivers — `start()` → `runPrompt()` → `drainHarnessToSink()` →
|
|
23
|
+
* `shutdown()` is byte-identical for both, and the sink, event vocabulary, and
|
|
24
|
+
* terminal-PATCH flow are unchanged. That is the proof: a second harness drops in
|
|
25
|
+
* behind the seam with no change to any consumer beyond the selector.
|
|
26
|
+
*
|
|
27
|
+
* The redaction list carries the agent bearer (added automatically by the sink)
|
|
28
|
+
* plus any model key present in the process env (defense in depth for the G3
|
|
29
|
+
* per-exec fallback — with firewall brokering the real key never reaches here).
|
|
30
|
+
*
|
|
31
|
+
* TESTABILITY: `runHostedLoop` takes injected `fetchImpl`, `spawnOpenCode`,
|
|
32
|
+
* `installOpenCode`, and `createHarness`, so the whole loop runs in-process
|
|
33
|
+
* against a fake broker + fake OpenCode server with no real binary.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { spawnSync, spawn as nodeSpawn } from 'child_process'
|
|
37
|
+
import { existsSync, readFileSync } from 'fs'
|
|
38
|
+
import { join } from 'path'
|
|
39
|
+
|
|
40
|
+
import type { AgentHarness, HarnessEvent, HarnessPrompt } from './hosted-harness.js'
|
|
41
|
+
import {
|
|
42
|
+
PREBAKED_MARKER_PATH,
|
|
43
|
+
SETUP_HOOK_RELATIVE_PATH,
|
|
44
|
+
parsePrebakedMarker,
|
|
45
|
+
} from './hosted-runtime-assets.js'
|
|
46
|
+
import {
|
|
47
|
+
OPENCODE_PINNED_VERSION,
|
|
48
|
+
createOpenCodeHarness,
|
|
49
|
+
spawnOpenCode,
|
|
50
|
+
type SpawnOpenCodeOptions,
|
|
51
|
+
type SpawnedOpenCode,
|
|
52
|
+
} from './hosted-harness-opencode.js'
|
|
53
|
+
import { createClaudeAgentHarness } from './hosted-harness-claude.js'
|
|
54
|
+
import {
|
|
55
|
+
drainHarnessToSink,
|
|
56
|
+
resumeRunEventSink,
|
|
57
|
+
type HostedFetch,
|
|
58
|
+
type TerminalStatus,
|
|
59
|
+
} from './hosted-run-event-sink.js'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The run-scoped context the host orchestrator writes for the loop. Paths are
|
|
63
|
+
* ABSOLUTE (or sandbox-root-relative) so the loop resolves them regardless of
|
|
64
|
+
* cwd. NO secret lives here — the bearer is read from `bearerFile` (0600).
|
|
65
|
+
*/
|
|
66
|
+
export interface HostedLoopContext {
|
|
67
|
+
apiBaseUrl: string
|
|
68
|
+
runId: string
|
|
69
|
+
/** 0600 file holding the agent bearer (never inlined into this context). */
|
|
70
|
+
bearerFile: string
|
|
71
|
+
/** File holding the single task prompt (v0: one prompt, no queue). */
|
|
72
|
+
taskFile: string
|
|
73
|
+
/** Directory the agent operates in (the cloned session-branch workspace). */
|
|
74
|
+
workspaceDir: string
|
|
75
|
+
/** Provider-qualified model, e.g. "anthropic/claude-opus-4-8". */
|
|
76
|
+
model: string
|
|
77
|
+
reasoningEffort?: string
|
|
78
|
+
/** Idempotency / correlation key for the prompt. */
|
|
79
|
+
messageId: string
|
|
80
|
+
/** Git identity for commit attribution during the prompt. */
|
|
81
|
+
author: { name: string; email: string }
|
|
82
|
+
/**
|
|
83
|
+
* Which `AgentHarness` drives the loop (ALI-929 swappability seam). Default
|
|
84
|
+
* 'opencode'. 'claude-agent-sdk' selects the in-process Claude-Agent-SDK driver
|
|
85
|
+
* — no `opencode` install/spawn happens on that path.
|
|
86
|
+
*/
|
|
87
|
+
harness?: 'opencode' | 'claude-agent-sdk'
|
|
88
|
+
opencodePort?: number
|
|
89
|
+
opencodePinnedVersion?: string
|
|
90
|
+
/**
|
|
91
|
+
* Pre-baked runtime (ALI-1017): the image already ships the pinned `opencode`
|
|
92
|
+
* on PATH, so the loop SKIPS the `opencode-ai` install (npm is blocked under G5
|
|
93
|
+
* default-deny egress) and spawns `opencode` directly, recording an
|
|
94
|
+
* `opencode_prebaked` artifact instead of `opencode_install`. The host sets this
|
|
95
|
+
* together with the sandbox `image` so the two never disagree. When unset, the
|
|
96
|
+
* loop ALSO belt-checks the `/opt/orizu/prebaked.json` marker; either signal
|
|
97
|
+
* skips the install. Ignored on the claude-agent-sdk path (no server to install).
|
|
98
|
+
*/
|
|
99
|
+
prebaked?: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Non-secret placeholder key OpenCode is given so it FORMS model requests; the
|
|
102
|
+
* sandbox firewall's request transform overrides the real auth header at the
|
|
103
|
+
* proxy (model-key brokering, G3). Never a real key.
|
|
104
|
+
*/
|
|
105
|
+
anthropicDummyKey?: string
|
|
106
|
+
/** OpenCode session id to resume on reconnect. */
|
|
107
|
+
resumeAgentSessionId?: string
|
|
108
|
+
/**
|
|
109
|
+
* Startup egress-canary probe host (G5 / ALI-1006). When SET, the loop runs a
|
|
110
|
+
* POSITIVE-CONTROL canary before any agent work, probing BOTH this known
|
|
111
|
+
* NON-allowlisted (denied) host AND a known-allowed host (the Orizu API base,
|
|
112
|
+
* derived from `apiBaseUrl`). The run proceeds ONLY IF the allowed host is
|
|
113
|
+
* reachable AND the denied host is blocked (`egress_blocked` proof). Any other
|
|
114
|
+
* outcome FAILS the run closed (`egress_allowed`): the denied host reachable
|
|
115
|
+
* means the firewall did not take; the allowed host ALSO unreachable means the
|
|
116
|
+
* network is broken and we cannot distinguish policy-enforcement from a total
|
|
117
|
+
* outage — either way we must not proceed to real customer data. The host sets
|
|
118
|
+
* this only for providers that actually enforce egress (Vercel); it is UNSET
|
|
119
|
+
* for local-sim / no-policy runs, so the canary is skipped there. Default
|
|
120
|
+
* denied probe target: `example.com`.
|
|
121
|
+
*/
|
|
122
|
+
egressCanaryHost?: string
|
|
123
|
+
/**
|
|
124
|
+
* When true, run the customer `.orizu/setup.sh` hook HERE (in the loop), AFTER
|
|
125
|
+
* the egress canary has proven the firewall is live and BEFORE harness work.
|
|
126
|
+
* The host sets this for enforced-egress providers so bootstrap DEFERS the hook
|
|
127
|
+
* (which would otherwise get network access before the canary could detect a
|
|
128
|
+
* silent firewall failure). UNSET for local-sim / no-policy runs, where
|
|
129
|
+
* bootstrap runs the hook inline as before. See docs sandbox-egress-policy §4.
|
|
130
|
+
*/
|
|
131
|
+
runSetupHook?: boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface InstallResult {
|
|
135
|
+
ok: boolean
|
|
136
|
+
detail: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface RunHostedLoopOptions {
|
|
140
|
+
context: HostedLoopContext
|
|
141
|
+
/**
|
|
142
|
+
* Agent bearer, as a FIXED string (tests / in-process launcher) OR omitted in
|
|
143
|
+
* favor of `bearerProvider`. In production the loop reads the rotated 0600
|
|
144
|
+
* bearer file per request via a provider (see `bearerProvider`), so a
|
|
145
|
+
* host-side rotation is picked up without restarting the loop.
|
|
146
|
+
*/
|
|
147
|
+
bearer?: string
|
|
148
|
+
/** Per-request bearer resolver (default: read `context.bearerFile`, trimmed).
|
|
149
|
+
* Passing a provider is what lets rotation reach the event sink. */
|
|
150
|
+
bearerProvider?: () => string
|
|
151
|
+
/** The single task prompt (already read from the task file by the caller). */
|
|
152
|
+
taskPrompt: string
|
|
153
|
+
fetchImpl?: HostedFetch
|
|
154
|
+
/** Build the harness for a spawned OpenCode base URL (default: OpenCode driver). */
|
|
155
|
+
createHarness?: (baseUrl: string) => AgentHarness
|
|
156
|
+
/** Build the in-process Claude-Agent-SDK harness (default: real SDK loader).
|
|
157
|
+
* Injectable so the swap test drives a fake `query()` with no real SDK. */
|
|
158
|
+
createClaudeHarness?: () => AgentHarness
|
|
159
|
+
spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode
|
|
160
|
+
installOpenCode?: () => Promise<InstallResult>
|
|
161
|
+
now?: () => number
|
|
162
|
+
signal?: AbortSignal
|
|
163
|
+
/** Extra verbatim secrets to redact (the bearer is added by the sink itself). */
|
|
164
|
+
redactSecretsList?: readonly string[]
|
|
165
|
+
/** Egress-canary probe (default: a bounded `fetch` to https://<host>/).
|
|
166
|
+
* Injectable so tests exercise both the blocked and reachable branches with
|
|
167
|
+
* no real network. Called once per host (allowed + denied) by the canary. */
|
|
168
|
+
probeEgress?: (host: string) => Promise<EgressProbeResult>
|
|
169
|
+
/** Run the deferred customer setup hook (default: `bash .orizu/setup.sh` in the
|
|
170
|
+
* workspace, non-fatal). Injectable so tests exercise the deferred-hook path
|
|
171
|
+
* with no real filesystem or child process. */
|
|
172
|
+
runSetupHook?: (input: { workspaceDir: string }) => Promise<SetupHookOutcome> | SetupHookOutcome
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface HostedLoopResult {
|
|
176
|
+
status: TerminalStatus
|
|
177
|
+
agentSessionId: string | null
|
|
178
|
+
installOk: boolean
|
|
179
|
+
error: string | null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Default egress-canary probe host (a known non-allowlisted destination). */
|
|
183
|
+
export const DEFAULT_EGRESS_CANARY_HOST = 'example.com'
|
|
184
|
+
/** Bound (ms) on the canary probe — a live firewall resets/times out fast; a
|
|
185
|
+
* reachable host answers well within this. */
|
|
186
|
+
const EGRESS_CANARY_TIMEOUT_MS = 5000
|
|
187
|
+
|
|
188
|
+
export interface EgressProbeResult {
|
|
189
|
+
/** True when the denied host was reachable (policy did NOT take → fail closed). */
|
|
190
|
+
reachable: boolean
|
|
191
|
+
/** Short human/audit detail (HTTP status on reach, error class on block). */
|
|
192
|
+
detail: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Default probe: attempt a bounded HTTPS GET to `host`. A live default-deny
|
|
197
|
+
* firewall makes this THROW for a DENIED host (connection reset / timeout / DNS
|
|
198
|
+
* failure) → `reachable: false`. Any response at all — even an error status —
|
|
199
|
+
* proves the host was reachable → `reachable: true`. Used for BOTH the allowed
|
|
200
|
+
* (positive-control) and denied probes.
|
|
201
|
+
*/
|
|
202
|
+
async function defaultProbeEgress(host: string): Promise<EgressProbeResult> {
|
|
203
|
+
const controller = new AbortController()
|
|
204
|
+
const timer = setTimeout(() => controller.abort(), EGRESS_CANARY_TIMEOUT_MS)
|
|
205
|
+
try {
|
|
206
|
+
const response = await fetch(`https://${host}/`, {
|
|
207
|
+
method: 'GET',
|
|
208
|
+
redirect: 'manual',
|
|
209
|
+
signal: controller.signal,
|
|
210
|
+
})
|
|
211
|
+
return { reachable: true, detail: `reachable (HTTP ${response.status})` }
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return { reachable: false, detail: `blocked (${error instanceof Error ? error.name : 'error'})` }
|
|
214
|
+
} finally {
|
|
215
|
+
clearTimeout(timer)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The two hosts the positive-control canary probes. */
|
|
220
|
+
export interface EgressCanaryTargets {
|
|
221
|
+
/** A known-ALLOWED host (the Orizu API base) that MUST be reachable — the
|
|
222
|
+
* positive control that distinguishes "policy blocking" from "network dead". */
|
|
223
|
+
allowedHost: string
|
|
224
|
+
/** A known-DENIED, non-allowlisted host that MUST be blocked (`example.com`). */
|
|
225
|
+
deniedHost: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface EgressCanaryDecision {
|
|
229
|
+
proceed: boolean
|
|
230
|
+
/** Null on proceed; a human/audit reason for the fail-closed decision. */
|
|
231
|
+
reason: string | null
|
|
232
|
+
allowed: EgressProbeResult
|
|
233
|
+
denied: EgressProbeResult
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Run the startup egress canary as a POSITIVE CONTROL and emit the proof event
|
|
238
|
+
* through the (single-writer) sink. Probes BOTH a known-allowed host and the
|
|
239
|
+
* known-denied host, then decides:
|
|
240
|
+
*
|
|
241
|
+
* - allowed REACHABLE and denied BLOCKED → the firewall is live AND the network
|
|
242
|
+
* works → emit `egress_blocked` and PROCEED (the ONLY healthy outcome);
|
|
243
|
+
* - denied REACHABLE → egress is NOT enforced → emit `egress_allowed`, FAIL
|
|
244
|
+
* CLOSED;
|
|
245
|
+
* - allowed ALSO UNREACHABLE (denied blocked but the positive control failed) →
|
|
246
|
+
* we cannot tell policy-blocking from a total network outage → emit
|
|
247
|
+
* `egress_allowed`, FAIL CLOSED.
|
|
248
|
+
*
|
|
249
|
+
* The `egress_blocked` kind therefore means EXACTLY "proceeded, positive control
|
|
250
|
+
* passed"; `egress_allowed` means "failed closed" (the payload `result` field
|
|
251
|
+
* distinguishes an unexpectedly-reachable denied host from an unreachable
|
|
252
|
+
* positive control). Old behavior — treating ANY denied-host throw (incl. a
|
|
253
|
+
* timeout under a total outage) as "blocked → proceed" — failed OPEN; this now
|
|
254
|
+
* fails closed on that ambiguity.
|
|
255
|
+
*/
|
|
256
|
+
export async function runEgressCanary(
|
|
257
|
+
targets: EgressCanaryTargets,
|
|
258
|
+
sink: { append: (event: HarnessEvent) => Promise<void> },
|
|
259
|
+
probe: (host: string) => Promise<EgressProbeResult>,
|
|
260
|
+
now: () => number = () => Date.now()
|
|
261
|
+
): Promise<EgressCanaryDecision> {
|
|
262
|
+
const [allowed, denied] = await Promise.all([probe(targets.allowedHost), probe(targets.deniedHost)])
|
|
263
|
+
const checkedAt = new Date(now()).toISOString()
|
|
264
|
+
const positiveControl = {
|
|
265
|
+
host: targets.allowedHost,
|
|
266
|
+
reachable: allowed.reachable,
|
|
267
|
+
detail: allowed.detail,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// FAIL CLOSED: a denied host answered — the policy is not enforcing egress.
|
|
271
|
+
if (denied.reachable) {
|
|
272
|
+
await sink.append({
|
|
273
|
+
kind: 'egress_allowed',
|
|
274
|
+
critical: true,
|
|
275
|
+
payload: {
|
|
276
|
+
host: targets.deniedHost,
|
|
277
|
+
result: 'unexpectedly_reachable',
|
|
278
|
+
detail: denied.detail,
|
|
279
|
+
positiveControl,
|
|
280
|
+
checkedAt,
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
return {
|
|
284
|
+
proceed: false,
|
|
285
|
+
reason: `denied host ${targets.deniedHost} was reachable (${denied.detail}) — egress is not enforced`,
|
|
286
|
+
allowed,
|
|
287
|
+
denied,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// FAIL CLOSED: the denied host was blocked, but the positive control ALSO did
|
|
292
|
+
// not answer — the environment/network is broken and we cannot distinguish a
|
|
293
|
+
// policy block from a total outage. This is the case the old code got wrong.
|
|
294
|
+
if (!allowed.reachable) {
|
|
295
|
+
await sink.append({
|
|
296
|
+
kind: 'egress_allowed',
|
|
297
|
+
critical: true,
|
|
298
|
+
payload: {
|
|
299
|
+
host: targets.deniedHost,
|
|
300
|
+
result: 'positive_control_unreachable',
|
|
301
|
+
detail: denied.detail,
|
|
302
|
+
positiveControl,
|
|
303
|
+
checkedAt,
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
return {
|
|
307
|
+
proceed: false,
|
|
308
|
+
reason: `positive control ${targets.allowedHost} was unreachable (${allowed.detail}) — cannot distinguish egress enforcement from a network outage`,
|
|
309
|
+
allowed,
|
|
310
|
+
denied,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// HEALTHY: allowed reachable AND denied blocked → the firewall is live.
|
|
315
|
+
await sink.append({
|
|
316
|
+
kind: 'egress_blocked',
|
|
317
|
+
critical: true,
|
|
318
|
+
payload: {
|
|
319
|
+
host: targets.deniedHost,
|
|
320
|
+
result: 'blocked',
|
|
321
|
+
detail: denied.detail,
|
|
322
|
+
positiveControl,
|
|
323
|
+
checkedAt,
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
return { proceed: true, reason: null, allowed, denied }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Derive the known-ALLOWED positive-control host from the run's Orizu API base —
|
|
331
|
+
* always allowlisted (it is the agent's control-plane path) and ours, so it is
|
|
332
|
+
* the safest "must be reachable" signal. Falls back to a lenient host parse if
|
|
333
|
+
* the base is not a well-formed URL.
|
|
334
|
+
*/
|
|
335
|
+
export function egressCanaryAllowedHost(apiBaseUrl: string): string {
|
|
336
|
+
try {
|
|
337
|
+
return new URL(apiBaseUrl).hostname
|
|
338
|
+
} catch {
|
|
339
|
+
return apiBaseUrl.replace(/^[a-z]+:\/\//i, '').split('/')[0].split(':')[0]
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Outcome of the deferred customer setup hook run inside the loop. */
|
|
344
|
+
export interface SetupHookOutcome {
|
|
345
|
+
/** True when a `.orizu/setup.sh` existed and was executed. */
|
|
346
|
+
ran: boolean
|
|
347
|
+
/** True when the hook was absent OR exited 0 (non-fatal either way). */
|
|
348
|
+
ok: boolean
|
|
349
|
+
detail: string
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Default deferred-setup-hook runner (in-sandbox): run `bash .orizu/setup.sh` in
|
|
354
|
+
* the workspace if present. Non-fatal — a missing hook or a non-zero exit is
|
|
355
|
+
* recorded and the loop proceeds, matching the bootstrap's inline behavior.
|
|
356
|
+
*/
|
|
357
|
+
function defaultRunSetupHook({ workspaceDir }: { workspaceDir: string }): SetupHookOutcome {
|
|
358
|
+
const hookPath = join(workspaceDir, SETUP_HOOK_RELATIVE_PATH)
|
|
359
|
+
if (!existsSync(hookPath)) return { ran: false, ok: true, detail: 'no .orizu/setup.sh' }
|
|
360
|
+
const res = spawnSync('bash', [SETUP_HOOK_RELATIVE_PATH], { cwd: workspaceDir, encoding: 'utf8' })
|
|
361
|
+
const ok = (res.status ?? 1) === 0
|
|
362
|
+
return { ran: true, ok, detail: ok ? 'setup.sh ok' : `setup.sh exit ${res.status ?? 'unknown'}` }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Model-key env vars whose value must be scrubbed from every run event. */
|
|
366
|
+
const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']
|
|
367
|
+
|
|
368
|
+
function redactionListFromEnv(): string[] {
|
|
369
|
+
const secrets: string[] = []
|
|
370
|
+
for (const key of MODEL_KEY_ENV_VARS) {
|
|
371
|
+
const value = process.env[key]
|
|
372
|
+
if (value) secrets.push(value)
|
|
373
|
+
}
|
|
374
|
+
return secrets
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Decide whether the loop is running the PRE-BAKED runtime image (ALI-1017).
|
|
379
|
+
* Prefers the explicit boot-context flag (the host KNOWS the image it passed);
|
|
380
|
+
* falls back to a belt read of the `/opt/orizu/prebaked.json` marker (validated
|
|
381
|
+
* via `parsePrebakedMarker`, so a stray file cannot trigger a skip). Either → true.
|
|
382
|
+
*/
|
|
383
|
+
function detectLoopPrebaked(flag: boolean | undefined): boolean {
|
|
384
|
+
if (flag) return true
|
|
385
|
+
try {
|
|
386
|
+
return parsePrebakedMarker(readFileSync(PREBAKED_MARKER_PATH, 'utf8')) !== null
|
|
387
|
+
} catch {
|
|
388
|
+
return false
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Best-effort global install of the pinned opencode. Non-fatal: a failure is
|
|
393
|
+
* recorded and the run finishes cleanly when the subsequent connect fails. */
|
|
394
|
+
function installOpenCodePinned(version: string): InstallResult {
|
|
395
|
+
const command =
|
|
396
|
+
`bun add -g opencode-ai@${version} >/dev/null 2>&1 || ` +
|
|
397
|
+
`npm install -g opencode-ai@${version} >/dev/null 2>&1`
|
|
398
|
+
const res = spawnSync('bash', ['-c', command], { encoding: 'utf8' })
|
|
399
|
+
const ok = (res.status ?? 1) === 0
|
|
400
|
+
return { ok, detail: ok ? `installed opencode-ai@${version}` : `install exit ${res.status ?? 'unknown'}` }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Node child_process spawner for `opencode serve` (the sandbox runtime is
|
|
404
|
+
* node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
|
|
405
|
+
* runtime-agnostic spawner). */
|
|
406
|
+
function nodeChildSpawner(
|
|
407
|
+
cmd: string[],
|
|
408
|
+
opts: { cwd?: string; env: Record<string, string> }
|
|
409
|
+
): { kill: () => void } {
|
|
410
|
+
const child = nodeSpawn(cmd[0], cmd.slice(1), {
|
|
411
|
+
cwd: opts.cwd,
|
|
412
|
+
env: { ...process.env, ...opts.env },
|
|
413
|
+
stdio: 'ignore',
|
|
414
|
+
})
|
|
415
|
+
return { kill: () => child.kill() }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedLoopResult> {
|
|
419
|
+
const { context, taskPrompt } = opts
|
|
420
|
+
const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as HostedFetch)
|
|
421
|
+
const signal = opts.signal ?? new AbortController().signal
|
|
422
|
+
const pinnedVersion = context.opencodePinnedVersion ?? OPENCODE_PINNED_VERSION
|
|
423
|
+
|
|
424
|
+
// Resolve the bearer per request. Precedence: an explicit provider, then a
|
|
425
|
+
// fixed string, else read the rotated 0600 bearer file every time (production
|
|
426
|
+
// + rotation path — a host-side rotation overwrites this file and the sink's
|
|
427
|
+
// next resolve picks it up).
|
|
428
|
+
const fixedBearer = opts.bearer
|
|
429
|
+
const bearerProvider: () => string =
|
|
430
|
+
opts.bearerProvider ??
|
|
431
|
+
(fixedBearer !== undefined
|
|
432
|
+
? (): string => fixedBearer
|
|
433
|
+
: (): string => readFileSync(context.bearerFile, 'utf8').trim())
|
|
434
|
+
|
|
435
|
+
// Build the resume-aware sink FIRST: even if the opencode install/spawn fails,
|
|
436
|
+
// we can record a terminal transition so the run is never left non-terminal.
|
|
437
|
+
const sink = await resumeRunEventSink({
|
|
438
|
+
apiBaseUrl: context.apiBaseUrl,
|
|
439
|
+
runId: context.runId,
|
|
440
|
+
bearer: bearerProvider,
|
|
441
|
+
fetchImpl,
|
|
442
|
+
now: opts.now,
|
|
443
|
+
redactSecretsList: [...(opts.redactSecretsList ?? []), ...redactionListFromEnv()],
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
// G5 startup canary (fail-closed, POSITIVE CONTROL): when the host applied an
|
|
447
|
+
// enforced egress policy it sets `egressCanaryHost`; probe BOTH the denied host
|
|
448
|
+
// AND a known-allowed host (the Orizu API base) BEFORE any agent work. Proceed
|
|
449
|
+
// ONLY IF the allowed host is reachable and the denied host is blocked; any
|
|
450
|
+
// other outcome (denied reachable → firewall not enforcing; allowed ALSO
|
|
451
|
+
// unreachable → network broken / indistinguishable from a block) emits
|
|
452
|
+
// `egress_allowed` and finishes the run FAILED rather than touch real customer
|
|
453
|
+
// data. A canary whose own event append fails is likewise fail-closed.
|
|
454
|
+
if (context.egressCanaryHost) {
|
|
455
|
+
const probe = opts.probeEgress ?? defaultProbeEgress
|
|
456
|
+
const targets: EgressCanaryTargets = {
|
|
457
|
+
allowedHost: egressCanaryAllowedHost(context.apiBaseUrl),
|
|
458
|
+
deniedHost: context.egressCanaryHost,
|
|
459
|
+
}
|
|
460
|
+
let canary: EgressCanaryDecision
|
|
461
|
+
try {
|
|
462
|
+
canary = await runEgressCanary(targets, sink, probe, opts.now)
|
|
463
|
+
} catch (error) {
|
|
464
|
+
// The canary's own event append failed — treat as fail-closed: we cannot
|
|
465
|
+
// prove the firewall is live, so do not proceed.
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
467
|
+
if (!sink.sealed) {
|
|
468
|
+
try {
|
|
469
|
+
await sink.finish('failed', { summary: { error: `egress canary error: ${message}` } })
|
|
470
|
+
} catch {
|
|
471
|
+
// best-effort terminal
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { status: 'failed', agentSessionId: null, installOk: false, error: `egress canary error: ${message}` }
|
|
475
|
+
}
|
|
476
|
+
if (!canary.proceed) {
|
|
477
|
+
const error = `egress canary FAILED: ${canary.reason} — refusing to proceed`
|
|
478
|
+
if (!sink.sealed) {
|
|
479
|
+
try {
|
|
480
|
+
await sink.finish('failed', {
|
|
481
|
+
summary: {
|
|
482
|
+
error,
|
|
483
|
+
egressCanary: {
|
|
484
|
+
deniedHost: targets.deniedHost,
|
|
485
|
+
deniedReachable: canary.denied.reachable,
|
|
486
|
+
deniedDetail: canary.denied.detail,
|
|
487
|
+
allowedHost: targets.allowedHost,
|
|
488
|
+
allowedReachable: canary.allowed.reachable,
|
|
489
|
+
allowedDetail: canary.allowed.detail,
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
})
|
|
493
|
+
} catch {
|
|
494
|
+
// best-effort terminal
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return { status: 'failed', agentSessionId: null, installOk: false, error }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// P3-a: run the DEFERRED customer setup hook now — AFTER the canary proved the
|
|
502
|
+
// firewall is live, BEFORE any harness/network work. bootstrap deferred it (for
|
|
503
|
+
// enforced-egress providers) precisely so its network access could not precede
|
|
504
|
+
// the canary. Non-fatal, single-writer (this loop sink owns the run now).
|
|
505
|
+
if (context.runSetupHook) {
|
|
506
|
+
try {
|
|
507
|
+
const runHook = opts.runSetupHook ?? defaultRunSetupHook
|
|
508
|
+
const outcome = await runHook({ workspaceDir: context.workspaceDir })
|
|
509
|
+
await sink.append({
|
|
510
|
+
kind: 'artifact',
|
|
511
|
+
payload: {
|
|
512
|
+
step: outcome.ran ? 'setup_hook_completed' : 'setup_hook_skipped',
|
|
513
|
+
ok: outcome.ok,
|
|
514
|
+
detail: outcome.detail,
|
|
515
|
+
deferred: true,
|
|
516
|
+
},
|
|
517
|
+
})
|
|
518
|
+
} catch (error) {
|
|
519
|
+
// The hook run/record is best-effort — never block the run on it.
|
|
520
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
521
|
+
if (!sink.sealed) {
|
|
522
|
+
try {
|
|
523
|
+
await sink.append({
|
|
524
|
+
kind: 'artifact',
|
|
525
|
+
payload: { step: 'setup_hook_error', ok: false, detail: message, deferred: true },
|
|
526
|
+
})
|
|
527
|
+
} catch {
|
|
528
|
+
// best-effort
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let spawned: SpawnedOpenCode | null = null
|
|
535
|
+
let agentSessionId: string | null = null
|
|
536
|
+
let installOk = false
|
|
537
|
+
const harnessKind = context.harness ?? 'opencode'
|
|
538
|
+
try {
|
|
539
|
+
// --- Harness PROVISIONING: the ONLY thing that differs between the two
|
|
540
|
+
// drivers (ALI-929). Everything after `harness` is constructed is identical.
|
|
541
|
+
let harness: AgentHarness
|
|
542
|
+
if (harnessKind === 'claude-agent-sdk') {
|
|
543
|
+
// In-process agent loop: no server to install or spawn. Record an
|
|
544
|
+
// analogous setup artifact so the event stream shape is unchanged.
|
|
545
|
+
installOk = true
|
|
546
|
+
await sink.append({
|
|
547
|
+
kind: 'artifact',
|
|
548
|
+
payload: { step: 'harness_select', harness: harnessKind, inProcess: true },
|
|
549
|
+
})
|
|
550
|
+
// G3 parity with the OpenCode path: hand the in-process SDK client the SAME
|
|
551
|
+
// non-secret dummy key (firewall brokers the real key on egress).
|
|
552
|
+
harness = (opts.createClaudeHarness ??
|
|
553
|
+
((): AgentHarness =>
|
|
554
|
+
createClaudeAgentHarness({ anthropicDummyKey: context.anthropicDummyKey })))()
|
|
555
|
+
} else {
|
|
556
|
+
// PRE-BAKED (ALI-1017): the pinned `opencode` is already on PATH, so SKIP the
|
|
557
|
+
// install (npm is blocked under G5 default-deny egress) and record
|
|
558
|
+
// `opencode_prebaked`. FROM-SCRATCH (local-sim / fallback): install as before.
|
|
559
|
+
const prebaked = detectLoopPrebaked(context.prebaked)
|
|
560
|
+
if (prebaked) {
|
|
561
|
+
installOk = true
|
|
562
|
+
await sink.append({
|
|
563
|
+
kind: 'artifact',
|
|
564
|
+
payload: { step: 'opencode_prebaked', ok: true, detail: `opencode-ai@${pinnedVersion} pre-baked in the runtime image` },
|
|
565
|
+
})
|
|
566
|
+
} else {
|
|
567
|
+
const install = opts.installOpenCode
|
|
568
|
+
? await opts.installOpenCode()
|
|
569
|
+
: installOpenCodePinned(pinnedVersion)
|
|
570
|
+
installOk = install.ok
|
|
571
|
+
await sink.append({
|
|
572
|
+
kind: 'artifact',
|
|
573
|
+
payload: { step: 'opencode_install', ok: install.ok, detail: install.detail },
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode
|
|
578
|
+
spawned = spawnImpl({
|
|
579
|
+
model: context.model,
|
|
580
|
+
cwd: context.workspaceDir,
|
|
581
|
+
port: context.opencodePort,
|
|
582
|
+
env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
|
|
583
|
+
spawn: nodeChildSpawner,
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
harness = (opts.createHarness ?? ((baseUrl: string) => createOpenCodeHarness({ baseUrl })))(
|
|
587
|
+
spawned.baseUrl
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const started = await harness.start({
|
|
592
|
+
workspaceDir: context.workspaceDir,
|
|
593
|
+
model: context.model,
|
|
594
|
+
reasoningEffort: context.reasoningEffort,
|
|
595
|
+
resumeAgentSessionId: context.resumeAgentSessionId,
|
|
596
|
+
})
|
|
597
|
+
agentSessionId = started.agentSessionId
|
|
598
|
+
|
|
599
|
+
const prompt: HarnessPrompt = {
|
|
600
|
+
runId: context.runId,
|
|
601
|
+
messageId: context.messageId,
|
|
602
|
+
content: taskPrompt,
|
|
603
|
+
author: context.author,
|
|
604
|
+
}
|
|
605
|
+
const status = await drainHarnessToSink(harness.runPrompt(prompt, signal), sink, {
|
|
606
|
+
summary: { agentSessionId },
|
|
607
|
+
})
|
|
608
|
+
await harness.shutdown()
|
|
609
|
+
return { status, agentSessionId, installOk, error: null }
|
|
610
|
+
} catch (error) {
|
|
611
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
612
|
+
// Fail closed: never leave the run non-terminal. finish() is idempotent and
|
|
613
|
+
// no-ops when the drain already sealed the sink.
|
|
614
|
+
if (!sink.sealed) {
|
|
615
|
+
try {
|
|
616
|
+
await sink.finish('failed', { summary: { error: message } })
|
|
617
|
+
} catch {
|
|
618
|
+
// best-effort — the terminal write itself may be impossible (bearer gone)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { status: 'failed', agentSessionId, installOk, error: message }
|
|
622
|
+
} finally {
|
|
623
|
+
if (spawned) {
|
|
624
|
+
try {
|
|
625
|
+
spawned.stop()
|
|
626
|
+
} catch {
|
|
627
|
+
// ignore teardown failure
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// -- Thin CLI entry (`orizu internal hosted-loop --context <path>`) -----------
|
|
634
|
+
|
|
635
|
+
export interface HostedLoopCommandIo {
|
|
636
|
+
print: (line: string) => void
|
|
637
|
+
printErr?: (line: string) => void
|
|
638
|
+
json?: boolean
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function argValue(args: readonly string[], flag: string): string | null {
|
|
642
|
+
const index = args.indexOf(flag)
|
|
643
|
+
if (index === -1 || index + 1 >= args.length) return null
|
|
644
|
+
const value = args[index + 1]
|
|
645
|
+
return value.startsWith('--') ? null : value
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Read the loop context + bearer + task from disk and run the loop. Invoked
|
|
650
|
+
* in-sandbox as a hidden command; the run's terminal status is recorded
|
|
651
|
+
* server-side regardless of this process's exit code.
|
|
652
|
+
*/
|
|
653
|
+
export async function hostedLoopCommand(
|
|
654
|
+
args: readonly string[],
|
|
655
|
+
io: HostedLoopCommandIo
|
|
656
|
+
): Promise<number> {
|
|
657
|
+
const contextPath = argValue(args, '--context')
|
|
658
|
+
if (!contextPath) {
|
|
659
|
+
io.printErr?.('Usage: orizu internal hosted-loop --context <path>')
|
|
660
|
+
return 1
|
|
661
|
+
}
|
|
662
|
+
let context: HostedLoopContext
|
|
663
|
+
try {
|
|
664
|
+
context = JSON.parse(readFileSync(contextPath, 'utf8')) as HostedLoopContext
|
|
665
|
+
} catch (error) {
|
|
666
|
+
io.printErr?.(`unreadable loop context: ${error instanceof Error ? error.message : String(error)}`)
|
|
667
|
+
return 1
|
|
668
|
+
}
|
|
669
|
+
// Read the bearer per request (via the provider) so a host-side rotation that
|
|
670
|
+
// overwrites the 0600 file is picked up without restarting the loop.
|
|
671
|
+
const bearerProvider = (): string => readFileSync(context.bearerFile, 'utf8').trim()
|
|
672
|
+
const taskPrompt = readFileSync(context.taskFile, 'utf8')
|
|
673
|
+
const result = await runHostedLoop({ context, bearerProvider, taskPrompt })
|
|
674
|
+
io.print(
|
|
675
|
+
io.json
|
|
676
|
+
? JSON.stringify({ status: result.status, agentSessionId: result.agentSessionId, error: result.error })
|
|
677
|
+
: `hosted-loop finished: ${result.status}${result.error ? ` (${result.error})` : ''}`
|
|
678
|
+
)
|
|
679
|
+
return result.error ? 1 : 0
|
|
680
|
+
}
|