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,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orizu-owned sandbox provider seam (ALI-973 / WS-F, per ADR-003).
|
|
3
|
+
*
|
|
4
|
+
* ADR-003 requires every provider call (GitHub, Daytona) to sit behind an
|
|
5
|
+
* Orizu-owned interface so the durable contracts never couple to whichever SDK
|
|
6
|
+
* wins the quarter. This module defines `SandboxProvider` / `SandboxSession` and
|
|
7
|
+
* ships two implementations:
|
|
8
|
+
*
|
|
9
|
+
* - `createDaytonaProvider(config)` — a THIN adapter over the Daytona
|
|
10
|
+
* TypeScript SDK (`@daytonaio/sdk`). The SDK is imported LAZILY (dynamic
|
|
11
|
+
* import through a non-literal specifier) so the CLI works without it
|
|
12
|
+
* installed, the app build never resolves it, and tests never load it.
|
|
13
|
+
* - `createLocalSimProvider(config)` — a temp-dir + real-`git`-subprocess
|
|
14
|
+
* simulation used by the unit tests AND by the no-credentials local-sim
|
|
15
|
+
* rehearsal. It is a genuine rehearsal (real clone/commit/push against a
|
|
16
|
+
* local bare repo), not a mock.
|
|
17
|
+
*
|
|
18
|
+
* SDK-vs-docs note (verified against daytona.io/docs, 2026-07-02): the real
|
|
19
|
+
* `git.clone` signature is
|
|
20
|
+
* clone(url, path, branch?, commitId?, username?, password?, insecureSkipTls?)
|
|
21
|
+
* i.e. a `commitId` argument sits BETWEEN `branch` and `username` — the ALI-973
|
|
22
|
+
* brief documented `clone(url, path, branch, username, password)`. The adapter
|
|
23
|
+
* passes `undefined` for `commitId` so the Orizu seam keeps its clean per-call
|
|
24
|
+
* credential shape. `git.push(path, username?, password?)` matches the brief.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from 'child_process'
|
|
28
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
29
|
+
import { tmpdir } from 'os'
|
|
30
|
+
import { basename, dirname, join } from 'path'
|
|
31
|
+
|
|
32
|
+
export interface ExecResult {
|
|
33
|
+
stdout: string
|
|
34
|
+
exitCode: number
|
|
35
|
+
/** Captured standard error, when the provider exposes it — surfaced so a
|
|
36
|
+
* failed git clone/push does not vanish behind a masked exit code. */
|
|
37
|
+
stderr?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GitCloneParams {
|
|
41
|
+
url: string
|
|
42
|
+
path: string
|
|
43
|
+
branch?: string
|
|
44
|
+
username?: string
|
|
45
|
+
password?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GitPushParams {
|
|
49
|
+
path: string
|
|
50
|
+
username?: string
|
|
51
|
+
password?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A single request transform a provider firewall applies to matching egress
|
|
56
|
+
* (the credential-brokering seam: inject a header at the proxy so the raw
|
|
57
|
+
* secret never enters the sandbox). Structurally identical to @vercel/sandbox's
|
|
58
|
+
* `NetworkPolicyRule` so a Vercel adapter can pass it straight through, but
|
|
59
|
+
* declared here so the Orizu seam owns the shape (ADR-003).
|
|
60
|
+
*/
|
|
61
|
+
export interface SandboxEgressRule {
|
|
62
|
+
transform?: { headers?: Record<string, string> }[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Provider-neutral egress/network policy, wired to the sandbox firewall AT
|
|
67
|
+
* CREATE. G5 (ALI-1006) owns the allowlist CONTENT; this slice only plumbs the
|
|
68
|
+
* option so the orchestrator can pass a policy (incl. a credential-injection
|
|
69
|
+
* transform for a model endpoint). `'allow-all'` is the provider default when
|
|
70
|
+
* omitted. Structurally compatible with @vercel/sandbox's `NetworkPolicy`.
|
|
71
|
+
*/
|
|
72
|
+
export type SandboxEgressPolicy =
|
|
73
|
+
| 'allow-all'
|
|
74
|
+
| 'deny-all'
|
|
75
|
+
| {
|
|
76
|
+
allow?: string[] | Record<string, SandboxEgressRule[]>
|
|
77
|
+
subnets?: { allow?: string[]; deny?: string[] }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SandboxCreateOpts {
|
|
81
|
+
language?: string
|
|
82
|
+
snapshot?: string
|
|
83
|
+
envVars?: Record<string, string>
|
|
84
|
+
labels?: Record<string, string>
|
|
85
|
+
/** Sandbox auto-terminate timeout (ms). Honored where the provider supports a
|
|
86
|
+
* configurable session length (Vercel); ignored by providers that do not. */
|
|
87
|
+
timeoutMs?: number
|
|
88
|
+
/** Provider runtime image hint (e.g. Vercel 'node24'); provider-specific. */
|
|
89
|
+
runtime?: string
|
|
90
|
+
/**
|
|
91
|
+
* Provider custom-image ref (e.g. Vercel VCR 'orizu-hosted-runtime:<tag>').
|
|
92
|
+
* When set, the provider creates the sandbox from this pre-baked image instead
|
|
93
|
+
* of the base runtime (ALI-1017). Provider-specific — Vercel honors it; other
|
|
94
|
+
* providers ignore it. `runtime` is still the base-runtime hint when no image
|
|
95
|
+
* is given.
|
|
96
|
+
*/
|
|
97
|
+
image?: string
|
|
98
|
+
/** vCPUs to allocate (memory scales with vCPUs on Vercel: 2048 MiB each). */
|
|
99
|
+
vcpus?: number
|
|
100
|
+
/** Ports to expose from the sandbox (provider-specific; up to 4 on Vercel). */
|
|
101
|
+
ports?: number[]
|
|
102
|
+
/** Egress/firewall policy applied at create (G5 supplies content). */
|
|
103
|
+
egressPolicy?: SandboxEgressPolicy
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A live sandbox. Per-call git credentials mirror Daytona's contract (and
|
|
108
|
+
* OpenInspect's credential-helper shape): the token is handed to each operation,
|
|
109
|
+
* never written once per session.
|
|
110
|
+
*/
|
|
111
|
+
export interface SandboxSession {
|
|
112
|
+
readonly id: string
|
|
113
|
+
gitClone(params: GitCloneParams): Promise<void>
|
|
114
|
+
gitPush(params: GitPushParams): Promise<void>
|
|
115
|
+
exec(command: string, opts?: { cwd?: string }): Promise<ExecResult>
|
|
116
|
+
readFile(path: string): Promise<string>
|
|
117
|
+
writeFile(path: string, content: string): Promise<void>
|
|
118
|
+
fileExists(path: string): Promise<boolean>
|
|
119
|
+
destroy(): Promise<void>
|
|
120
|
+
/**
|
|
121
|
+
* Extend the sandbox's auto-terminate timeout by `durationMs` (keepalive).
|
|
122
|
+
* OPTIONAL: only providers with a configurable session length implement it
|
|
123
|
+
* (Vercel). Host-side token rotation uses it to keep a long session alive
|
|
124
|
+
* beyond the provider's default. Absent on local-sim / Daytona.
|
|
125
|
+
*/
|
|
126
|
+
extendTimeout?(durationMs: number): Promise<void>
|
|
127
|
+
/**
|
|
128
|
+
* Snapshot the running sandbox's filesystem state and RETURN the snapshot id
|
|
129
|
+
* (ALI-1017). A new sandbox can then boot from it via `createSandbox({ snapshot })`
|
|
130
|
+
* — the zero-Docker "prebaked runtime" path (parallel to a VCR `image`).
|
|
131
|
+
* OPTIONAL: only providers with a snapshot API implement it (Vercel); absent on
|
|
132
|
+
* local-sim / Daytona. NOTE: the provider may STOP this sandbox as part of the
|
|
133
|
+
* snapshot (Vercel does), so treat the session as spent afterwards.
|
|
134
|
+
*
|
|
135
|
+
* `expiration` is an optional TTL (ms) for the snapshot; 0 = never expire.
|
|
136
|
+
*/
|
|
137
|
+
snapshot?(opts?: { expiration?: number }): Promise<string>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type SandboxProviderKind = 'daytona' | 'local-sim' | 'vercel'
|
|
141
|
+
|
|
142
|
+
export interface SandboxProvider {
|
|
143
|
+
readonly kind: SandboxProviderKind
|
|
144
|
+
createSandbox(opts?: SandboxCreateOpts): Promise<SandboxSession>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -- Daytona adapter (lazy SDK) ---------------------------------------------
|
|
148
|
+
|
|
149
|
+
export interface DaytonaProviderConfig {
|
|
150
|
+
apiKey: string
|
|
151
|
+
apiUrl?: string
|
|
152
|
+
target?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Minimal structural view of the SDK surface the adapter touches. Kept local so
|
|
156
|
+
// the seam type-checks and the app build resolves without the package present.
|
|
157
|
+
interface DaytonaSdkSandbox {
|
|
158
|
+
id: string
|
|
159
|
+
git: {
|
|
160
|
+
clone(
|
|
161
|
+
url: string,
|
|
162
|
+
path: string,
|
|
163
|
+
branch?: string,
|
|
164
|
+
commitId?: string,
|
|
165
|
+
username?: string,
|
|
166
|
+
password?: string,
|
|
167
|
+
insecureSkipTls?: boolean
|
|
168
|
+
): Promise<void>
|
|
169
|
+
push(path: string, username?: string, password?: string): Promise<void>
|
|
170
|
+
}
|
|
171
|
+
process: {
|
|
172
|
+
executeCommand(
|
|
173
|
+
command: string,
|
|
174
|
+
cwd?: string,
|
|
175
|
+
env?: Record<string, string>,
|
|
176
|
+
timeout?: number
|
|
177
|
+
): Promise<{ exitCode?: number; result?: string; stderr?: string; artifacts?: { stdout?: string; stderr?: string } }>
|
|
178
|
+
}
|
|
179
|
+
fs: {
|
|
180
|
+
downloadFile(remotePath: string): Promise<Uint8Array | Buffer | string>
|
|
181
|
+
uploadFile(file: Uint8Array, remotePath: string): Promise<void>
|
|
182
|
+
getFileDetails(path: string): Promise<unknown>
|
|
183
|
+
}
|
|
184
|
+
delete(timeout?: number): Promise<void>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface DaytonaSdkClient {
|
|
188
|
+
create(params?: Record<string, unknown>, options?: { timeout: number }): Promise<DaytonaSdkSandbox>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface DaytonaSdkModule {
|
|
192
|
+
Daytona: new (config: { apiKey: string; apiUrl?: string; target?: string }) => DaytonaSdkClient
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Test seam: inject a fake SDK module so the adapter's request mapping can be
|
|
196
|
+
* exercised without the real package installed or a network. */
|
|
197
|
+
export interface DaytonaProviderDeps {
|
|
198
|
+
loadModule?: () => Promise<DaytonaSdkModule>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The package name lives in a variable so TypeScript treats the dynamic import
|
|
202
|
+
// as `any` (no static module resolution / no ts2307 at app build time) and so
|
|
203
|
+
// no bundler traces it. This is what keeps the SDK genuinely optional.
|
|
204
|
+
//
|
|
205
|
+
// PIN CONTRACT: `wrapDaytonaSandbox` hard-codes the POSITIONAL git.clone
|
|
206
|
+
// signature (url, path, branch, commitId, username, password) verified against
|
|
207
|
+
// @daytonaio/sdk@0.193.0. package.json pins that version EXACTLY (no caret) and
|
|
208
|
+
// packages/cli/bun.lock records it, so a minor bump cannot silently shift the
|
|
209
|
+
// argument order out from under this adapter. Re-verify the signature before
|
|
210
|
+
// changing the pin.
|
|
211
|
+
//
|
|
212
|
+
// TRANSITIVE-DEPENDENCY NOTE: @daytonaio/sdk itself drags in a substantial
|
|
213
|
+
// tree — @aws-sdk/client-s3, the OpenTelemetry SDK/exporters, and friends —
|
|
214
|
+
// into packages/cli's dependency graph. `bun install --cwd packages/cli`
|
|
215
|
+
// resolves and downloads all of it even though this file only ever reaches
|
|
216
|
+
// the package via the lazy `import(specifier)` above. That's judged
|
|
217
|
+
// acceptable because: (1) this is a founder-only, not end-user-shipped,
|
|
218
|
+
// script — packages/cli's app build and published `dist/` never bundle or
|
|
219
|
+
// execute this path; (2) the lazy import means the SDK (and its transitive
|
|
220
|
+
// tree) is never loaded into the running process unless `runDaytonaSlice`
|
|
221
|
+
// actually selects the `daytona` provider; (3) `bun install`'s resolve-time
|
|
222
|
+
// cost is a one-time developer/CI tax on this package, not a runtime or
|
|
223
|
+
// shipped-artifact cost. Re-evaluate if @daytonaio/sdk ever becomes a
|
|
224
|
+
// dependency of a package that IS shipped to end users.
|
|
225
|
+
const DAYTONA_SDK_SPECIFIER = '@daytonaio/sdk'
|
|
226
|
+
|
|
227
|
+
async function loadDaytonaModule(): Promise<DaytonaSdkModule> {
|
|
228
|
+
try {
|
|
229
|
+
const specifier: string = DAYTONA_SDK_SPECIFIER
|
|
230
|
+
const mod = (await import(specifier)) as unknown as DaytonaSdkModule
|
|
231
|
+
if (!mod || typeof mod.Daytona !== 'function') {
|
|
232
|
+
throw new Error('module did not export a Daytona class')
|
|
233
|
+
}
|
|
234
|
+
return mod
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Daytona SDK unavailable (${detail}). Install it before the live run: \`bun install --cwd packages/cli\` (declares @daytonaio/sdk).`
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function wrapDaytonaSandbox(sandbox: DaytonaSdkSandbox): SandboxSession {
|
|
244
|
+
return {
|
|
245
|
+
id: sandbox.id,
|
|
246
|
+
async gitClone(params) {
|
|
247
|
+
// commitId (4th arg) is undefined — clean per-call credential shape.
|
|
248
|
+
await sandbox.git.clone(
|
|
249
|
+
params.url,
|
|
250
|
+
params.path,
|
|
251
|
+
params.branch,
|
|
252
|
+
undefined,
|
|
253
|
+
params.username,
|
|
254
|
+
params.password
|
|
255
|
+
)
|
|
256
|
+
},
|
|
257
|
+
async gitPush(params) {
|
|
258
|
+
await sandbox.git.push(params.path, params.username, params.password)
|
|
259
|
+
},
|
|
260
|
+
async exec(command, opts) {
|
|
261
|
+
const res = await sandbox.process.executeCommand(command, opts?.cwd)
|
|
262
|
+
const stdout =
|
|
263
|
+
typeof res.result === 'string' ? res.result : res.artifacts?.stdout ?? ''
|
|
264
|
+
const stderr = res.stderr ?? res.artifacts?.stderr
|
|
265
|
+
// A missing exitCode is treated as a FAILURE (1), never masked to success:
|
|
266
|
+
// a live clone/push that returns no code must not read as green.
|
|
267
|
+
const exitCode = typeof res.exitCode === 'number' ? res.exitCode : 1
|
|
268
|
+
return { stdout, exitCode, stderr }
|
|
269
|
+
},
|
|
270
|
+
async readFile(path) {
|
|
271
|
+
const data = await sandbox.fs.downloadFile(path)
|
|
272
|
+
if (typeof data === 'string') return data
|
|
273
|
+
return Buffer.from(data).toString('utf8')
|
|
274
|
+
},
|
|
275
|
+
async writeFile(path, content) {
|
|
276
|
+
await sandbox.fs.uploadFile(Buffer.from(content, 'utf8'), path)
|
|
277
|
+
},
|
|
278
|
+
async fileExists(path) {
|
|
279
|
+
try {
|
|
280
|
+
await sandbox.fs.getFileDetails(path)
|
|
281
|
+
return true
|
|
282
|
+
} catch {
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
async destroy() {
|
|
287
|
+
await sandbox.delete()
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function createDaytonaProvider(
|
|
293
|
+
config: DaytonaProviderConfig,
|
|
294
|
+
deps: DaytonaProviderDeps = {}
|
|
295
|
+
): SandboxProvider {
|
|
296
|
+
const load = deps.loadModule ?? loadDaytonaModule
|
|
297
|
+
let clientPromise: Promise<DaytonaSdkClient> | null = null
|
|
298
|
+
const client = (): Promise<DaytonaSdkClient> => {
|
|
299
|
+
if (!clientPromise) {
|
|
300
|
+
clientPromise = (async () => {
|
|
301
|
+
const mod = await load()
|
|
302
|
+
return new mod.Daytona({
|
|
303
|
+
apiKey: config.apiKey,
|
|
304
|
+
apiUrl: config.apiUrl,
|
|
305
|
+
target: config.target,
|
|
306
|
+
})
|
|
307
|
+
})()
|
|
308
|
+
}
|
|
309
|
+
return clientPromise
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
kind: 'daytona',
|
|
314
|
+
async createSandbox(opts) {
|
|
315
|
+
const daytona = await client()
|
|
316
|
+
const params: Record<string, unknown> = {}
|
|
317
|
+
if (opts?.language) params.language = opts.language
|
|
318
|
+
if (opts?.snapshot) params.snapshot = opts.snapshot
|
|
319
|
+
if (opts?.envVars) params.envVars = opts.envVars
|
|
320
|
+
if (opts?.labels) params.labels = opts.labels
|
|
321
|
+
// ORPHAN-SANDBOX WINDOW: `daytona.create(...)` is a single SDK call that
|
|
322
|
+
// provisions the remote sandbox AND resolves with its handle as one unit.
|
|
323
|
+
// If it throws AFTER the remote sandbox exists but BEFORE this awaits
|
|
324
|
+
// returns (e.g. the create succeeded server-side but the response never
|
|
325
|
+
// reached us), the caller (runDaytonaSlice) never receives a
|
|
326
|
+
// SandboxSession and has no id to destroy — the sandbox is orphaned on
|
|
327
|
+
// Daytona's side with nothing in our process able to name it. This is
|
|
328
|
+
// inherent to the SDK's create-returns-handle shape, not fixable by
|
|
329
|
+
// catching here (there is no id to catch). `--keep`/manual cleanup in
|
|
330
|
+
// the Daytona dashboard is the backstop. A create-then-fetch-id two-phase
|
|
331
|
+
// API (create returns an id immediately, a separate call waits for
|
|
332
|
+
// readiness) would close this window if Daytona ever ships one.
|
|
333
|
+
const sandbox = await daytona.create(Object.keys(params).length ? params : undefined)
|
|
334
|
+
return wrapDaytonaSandbox(sandbox)
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// -- Local-sim provider (real git subprocess) --------------------------------
|
|
340
|
+
|
|
341
|
+
export interface LocalSimProviderConfig {
|
|
342
|
+
/** Parent directory for per-sandbox temp dirs (defaults to the OS tmpdir). */
|
|
343
|
+
rootDir?: string
|
|
344
|
+
/** Skip filesystem teardown on destroy() (used by --keep for debugging). */
|
|
345
|
+
keepOnDestroy?: boolean
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
interface ProcResult {
|
|
349
|
+
stdout: string
|
|
350
|
+
stderr: string
|
|
351
|
+
exitCode: number
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function runIn(bin: string, args: string[], cwd: string, home: string): ProcResult {
|
|
355
|
+
// Strip git's repo-context env (set by git when this code runs inside a
|
|
356
|
+
// hook, e.g. pre-push running `bun test`) so sim git commands operate on
|
|
357
|
+
// the sandbox cwd — not whatever repo GIT_DIR points at. See
|
|
358
|
+
// daytona-slice-rehearsal.ts for the core.bare=true corruption this caused.
|
|
359
|
+
const env: NodeJS.ProcessEnv = {
|
|
360
|
+
...process.env,
|
|
361
|
+
HOME: home,
|
|
362
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
363
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
364
|
+
}
|
|
365
|
+
delete env.GIT_DIR
|
|
366
|
+
delete env.GIT_WORK_TREE
|
|
367
|
+
delete env.GIT_INDEX_FILE
|
|
368
|
+
delete env.GIT_OBJECT_DIRECTORY
|
|
369
|
+
delete env.GIT_COMMON_DIR
|
|
370
|
+
const res = spawnSync(bin, args, { cwd, encoding: 'utf8', env })
|
|
371
|
+
return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', exitCode: res.status ?? 1 }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function makeLocalSimSession(id: string, root: string, keepOnDestroy: boolean): SandboxSession {
|
|
375
|
+
// A self-contained git identity so commits work regardless of host config.
|
|
376
|
+
writeFileSync(
|
|
377
|
+
join(root, '.gitconfig'),
|
|
378
|
+
'[user]\n\temail = orizu-slice@local.sim\n\tname = Orizu Slice\n' +
|
|
379
|
+
'[init]\n\tdefaultBranch = main\n[safe]\n\tdirectory = *\n'
|
|
380
|
+
)
|
|
381
|
+
const inRoot = (p: string): string => join(root, p)
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
id,
|
|
385
|
+
async gitClone(params) {
|
|
386
|
+
const args = ['clone']
|
|
387
|
+
if (params.branch) args.push('--branch', params.branch)
|
|
388
|
+
// Clean per-call shape: the clone URL is the bare repo path; the
|
|
389
|
+
// per-call username/password are honored by the transport but never
|
|
390
|
+
// persisted into the cloned repo's config (mirrors the contract we
|
|
391
|
+
// require of Daytona). Nothing to sanitize because nothing is embedded.
|
|
392
|
+
args.push(params.url, params.path)
|
|
393
|
+
const r = runIn('git', args, root, root)
|
|
394
|
+
if (r.exitCode !== 0) {
|
|
395
|
+
throw new Error(`git clone failed: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
async gitPush(params) {
|
|
399
|
+
const r = runIn('git', ['-C', params.path, 'push'], root, root)
|
|
400
|
+
if (r.exitCode !== 0) {
|
|
401
|
+
throw new Error(`git push failed: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
async exec(command, opts) {
|
|
405
|
+
const cwd = opts?.cwd ? inRoot(opts.cwd) : root
|
|
406
|
+
const r = runIn('bash', ['-c', command], cwd, root)
|
|
407
|
+
return { stdout: r.stdout, exitCode: r.exitCode, stderr: r.stderr }
|
|
408
|
+
},
|
|
409
|
+
async readFile(path) {
|
|
410
|
+
return readFileSync(inRoot(path), 'utf8')
|
|
411
|
+
},
|
|
412
|
+
async writeFile(path, content) {
|
|
413
|
+
const abs = inRoot(path)
|
|
414
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
415
|
+
writeFileSync(abs, content)
|
|
416
|
+
},
|
|
417
|
+
async fileExists(path) {
|
|
418
|
+
return existsSync(inRoot(path))
|
|
419
|
+
},
|
|
420
|
+
async destroy() {
|
|
421
|
+
if (!keepOnDestroy) {
|
|
422
|
+
rmSync(root, { recursive: true, force: true })
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function createLocalSimProvider(config: LocalSimProviderConfig = {}): SandboxProvider {
|
|
429
|
+
let counter = 0
|
|
430
|
+
return {
|
|
431
|
+
kind: 'local-sim',
|
|
432
|
+
async createSandbox() {
|
|
433
|
+
counter += 1
|
|
434
|
+
const parent = config.rootDir ?? tmpdir()
|
|
435
|
+
mkdirSync(parent, { recursive: true })
|
|
436
|
+
const root = mkdtempSync(join(parent, 'orizu-sim-'))
|
|
437
|
+
return makeLocalSimSession(`local-sim-${counter}-${basename(root)}`, root, Boolean(config.keepOnDestroy))
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for hosted-sandbox bootstrap (G3 / ALI-1004).
|
|
3
|
+
*
|
|
4
|
+
* The bootstrap orchestrator records lifecycle events to the RunAPI and may
|
|
5
|
+
* capture hook / install output. None of that may ever carry the Orizu bearer,
|
|
6
|
+
* a minted repo token, or any token-shaped value. `redactSecrets` scrubs a
|
|
7
|
+
* value two ways:
|
|
8
|
+
*
|
|
9
|
+
* 1. exact-match on caller-supplied secrets (the bearer, any token the
|
|
10
|
+
* orchestrator itself minted) — these are known verbatim, so replace them
|
|
11
|
+
* wherever they appear, even mid-string;
|
|
12
|
+
* 2. shape-match on the well-known credential prefixes GitHub, Orizu, and the
|
|
13
|
+
* model providers emit (ghs_/ghp_/gho_/ghu_/ghr_/github_pat_ installation+PAT
|
|
14
|
+
* tokens, Orizu's own `orizu_pat_`/`orizu_agent_` tokens, Anthropic
|
|
15
|
+
* `sk-ant-…` and OpenAI `sk-proj-…`/legacy `sk-…` model keys, and a generic
|
|
16
|
+
* `Bearer <blob>` header) — a defense-in-depth net for tokens the
|
|
17
|
+
* orchestrator never held a copy of (e.g. a model key echoed by a customer
|
|
18
|
+
* setup hook or an OpenCode error message).
|
|
19
|
+
*
|
|
20
|
+
* SHAPES ARE DEFENSE IN DEPTH, NOT THE GUARANTEE. The guaranteed scrub is the
|
|
21
|
+
* exact-match pass (1): any secret that will be present in the sandbox MUST be
|
|
22
|
+
* handed to the caller's secret list (the RunEventSink's `redactSecretsList`)
|
|
23
|
+
* so it is stripped verbatim wherever it appears, even mid-string and even when
|
|
24
|
+
* its shape is unknown. The shape patterns below only catch credentials no one
|
|
25
|
+
* declared; do not rely on them as the primary control.
|
|
26
|
+
*
|
|
27
|
+
* The function is pure and recursive over JSON-ish structures so an entire
|
|
28
|
+
* event payload can be passed through before it is sent to the RunAPI.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const REDACTION_PLACEHOLDER = '[redacted]'
|
|
32
|
+
|
|
33
|
+
// Token SHAPES, not values. Ordered longest-prefix-first so `github_pat_` wins
|
|
34
|
+
// over a hypothetical shorter alias. Each matches the prefix plus its base62/_-
|
|
35
|
+
// body. `Bearer <token>` is caught separately so an Authorization header value
|
|
36
|
+
// is scrubbed even when the token body is short.
|
|
37
|
+
const TOKEN_SHAPE_PATTERNS: readonly RegExp[] = [
|
|
38
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
39
|
+
/\bgh[posur]_[A-Za-z0-9_]{20,}/g,
|
|
40
|
+
// Orizu-issued credentials: `orizu_pat_` (personal access tokens, see
|
|
41
|
+
// lib/personal-access-tokens.ts — prefix + base64url body) and the future
|
|
42
|
+
// `orizu_agent_` session-token prefix. base64url alphabet is [A-Za-z0-9_-].
|
|
43
|
+
/\borizu_(agent|pat)_[A-Za-z0-9_-]{10,}/g,
|
|
44
|
+
// Model-provider API keys the agent runs with. Specific prefixes first so the
|
|
45
|
+
// bare `sk-` fallback below cannot partially shadow them:
|
|
46
|
+
// Anthropic `sk-ant-…` (real keys look like `sk-ant-api03-<blob>`);
|
|
47
|
+
// OpenAI `sk-proj-…` (project-scoped keys) and legacy bare `sk-<blob>`.
|
|
48
|
+
// The bare fallback demands 20+ base62 chars after `sk-` so it cannot fire on
|
|
49
|
+
// the hyphenated `sk-ant-`/`sk-proj-` prefixes (both break the run at the
|
|
50
|
+
// first `-`) or on short `sk-` substrings — tuned to avoid a false-positive
|
|
51
|
+
// massacre while still netting an undeclared legacy OpenAI key.
|
|
52
|
+
/\bsk-ant-[A-Za-z0-9_-]{10,}/g,
|
|
53
|
+
/\bsk-proj-[A-Za-z0-9_-]{10,}/g,
|
|
54
|
+
/\bsk-[A-Za-z0-9]{20,}\b/g,
|
|
55
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
function escapeRegExp(value: string): string {
|
|
59
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Redact a scalar string: exact secrets first, then token shapes.
|
|
64
|
+
*/
|
|
65
|
+
function redactString(input: string, secrets: readonly string[]): string {
|
|
66
|
+
let out = input
|
|
67
|
+
for (const secret of secrets) {
|
|
68
|
+
if (secret.length === 0) continue
|
|
69
|
+
out = out.replace(new RegExp(escapeRegExp(secret), 'g'), REDACTION_PLACEHOLDER)
|
|
70
|
+
}
|
|
71
|
+
for (const pattern of TOKEN_SHAPE_PATTERNS) {
|
|
72
|
+
out = out.replace(pattern, match =>
|
|
73
|
+
match.toLowerCase().startsWith('bearer') ? `Bearer ${REDACTION_PLACEHOLDER}` : REDACTION_PLACEHOLDER
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RedactOptions {
|
|
80
|
+
/** Verbatim secret values to strip wherever they appear (bearer, tokens). */
|
|
81
|
+
secrets?: readonly string[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Deep-redact a JSON-ish value. Strings are scrubbed; arrays/objects are walked;
|
|
86
|
+
* everything else is returned as-is. Object KEYS are never treated as secrets
|
|
87
|
+
* (a key literally named after a token is not the token), but their VALUES are.
|
|
88
|
+
*/
|
|
89
|
+
export function redactSecrets<T>(value: T, options: RedactOptions = {}): T {
|
|
90
|
+
const secrets = (options.secrets ?? []).filter(s => typeof s === 'string' && s.length > 0)
|
|
91
|
+
return redactValue(value, secrets) as T
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function redactValue(value: unknown, secrets: readonly string[]): unknown {
|
|
95
|
+
if (typeof value === 'string') {
|
|
96
|
+
return redactString(value, secrets)
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
return value.map(item => redactValue(item, secrets))
|
|
100
|
+
}
|
|
101
|
+
if (value && typeof value === 'object') {
|
|
102
|
+
const out: Record<string, unknown> = {}
|
|
103
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
104
|
+
out[key] = redactValue(inner, secrets)
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* True when a string still contains any of the supplied secrets or a
|
|
113
|
+
* token-shaped substring — the assertion tests use it to prove an event payload
|
|
114
|
+
* left the orchestrator clean.
|
|
115
|
+
*/
|
|
116
|
+
export function containsSecret(input: string, secrets: readonly string[]): boolean {
|
|
117
|
+
for (const secret of secrets) {
|
|
118
|
+
if (secret.length > 0 && input.includes(secret)) return true
|
|
119
|
+
}
|
|
120
|
+
return TOKEN_SHAPE_PATTERNS.some(pattern => {
|
|
121
|
+
pattern.lastIndex = 0
|
|
122
|
+
return pattern.test(input)
|
|
123
|
+
})
|
|
124
|
+
}
|