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.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +276 -21
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +945 -258
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +96 -0
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +277 -21
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +1102 -317
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +164 -0
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +8 -4
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,9 @@
20
20
  "postpack": "node scripts/vendor-gepa-python.mjs --clean && node scripts/vendor-orizu-skill.mjs --clean"
21
21
  },
22
22
  "dependencies": {
23
+ "@anthropic-ai/claude-agent-sdk": "0.3.201",
24
+ "@daytonaio/sdk": "0.193.0",
25
+ "@vercel/sandbox": "1.10.2",
23
26
  "@playwright/test": "^1.60.0",
24
27
  "@tailwindcss/postcss": "^4.1.7",
25
28
  "esbuild": "^0.25.8",
@@ -0,0 +1,182 @@
1
+ import { createHash } from 'crypto'
2
+ import { readdirSync, readFileSync, statSync } from 'fs'
3
+ import { join } from 'path'
4
+
5
+ export interface StoredZipEntry {
6
+ path: string
7
+ data: Buffer
8
+ }
9
+
10
+ export interface ArtifactArchive {
11
+ zipBase64: string
12
+ contentSha256: string
13
+ }
14
+
15
+ const ARTIFACT_EXCLUDED_PATH_NAMES = new Set([
16
+ '.git',
17
+ '.DS_Store',
18
+ '__pycache__',
19
+ '.pytest_cache',
20
+ '.orizu',
21
+ ])
22
+
23
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
24
+ return error instanceof Error
25
+ }
26
+
27
+ function expandHomePath(path: string): string {
28
+ if (path.startsWith('~/')) {
29
+ const home = process.env.HOME || ''
30
+ return `${home}/${path.slice(2)}`
31
+ }
32
+
33
+ return path
34
+ }
35
+
36
+ /**
37
+ * Archives must be byte-identical for identical logical content across machines
38
+ * because content-addressed deduplication depends on deterministic inputs.
39
+ *
40
+ * Tradeoff: runner artifacts are downloaded, unzipped, and executed directly
41
+ * (there is no install step), so vendored dependency trees such as
42
+ * node_modules, .venv/venv, and build outputs are legitimate runtime payload
43
+ * and are deliberately NOT excluded — hash reproducibility therefore depends
44
+ * on the author shipping identical vendored trees across machines. Only true
45
+ * junk (.git, .DS_Store, __pycache__, .pytest_cache, .orizu) and secrets
46
+ * (.env, .env.*) are stripped: secrets must never ship, because runners
47
+ * receive their configuration at exec time.
48
+ */
49
+ export function shouldExcludeArtifactPath(relativePath: string): boolean {
50
+ const parts = relativePath.split('/')
51
+ return parts.some(part =>
52
+ ARTIFACT_EXCLUDED_PATH_NAMES.has(part) ||
53
+ part === '.env' ||
54
+ part.startsWith('.env.')
55
+ )
56
+ }
57
+
58
+ export function collectArtifactFiles(sourceDir: string, relativeDir = ''): string[] {
59
+ const absoluteDir = relativeDir ? join(sourceDir, relativeDir) : sourceDir
60
+ return readdirSync(absoluteDir, { withFileTypes: true })
61
+ .flatMap(entry => {
62
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name
63
+ if (shouldExcludeArtifactPath(relativePath)) {
64
+ return []
65
+ }
66
+ if (entry.isDirectory()) {
67
+ return collectArtifactFiles(sourceDir, relativePath)
68
+ }
69
+ return entry.isFile() ? [relativePath] : []
70
+ })
71
+ .sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
72
+ }
73
+
74
+ export const ZIP_GENERAL_PURPOSE_UTF8 = 0x0800
75
+ export const ZIP_DOS_TIME_2000_01_01 = 0
76
+ export const ZIP_DOS_DATE_2000_01_01 = ((2000 - 1980) << 9) | (1 << 5) | 1
77
+
78
+ const CRC32_TABLE = Array.from({ length: 256 }, (_, index) => {
79
+ let value = index
80
+ for (let bit = 0; bit < 8; bit++) {
81
+ value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1)
82
+ }
83
+ return value >>> 0
84
+ })
85
+
86
+ function crc32(bytes: Buffer): number {
87
+ let crc = 0xffffffff
88
+ for (const byte of bytes) {
89
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
90
+ }
91
+ return (crc ^ 0xffffffff) >>> 0
92
+ }
93
+
94
+ export function createStoredZip(entries: StoredZipEntry[]): Buffer {
95
+ const localParts: Buffer[] = []
96
+ const centralParts: Buffer[] = []
97
+ let offset = 0
98
+
99
+ for (const entry of entries) {
100
+ const name = Buffer.from(entry.path, 'utf8')
101
+ const crc = crc32(entry.data)
102
+ const localHeader = Buffer.alloc(30)
103
+ localHeader.writeUInt32LE(0x04034b50, 0)
104
+ localHeader.writeUInt16LE(10, 4)
105
+ localHeader.writeUInt16LE(ZIP_GENERAL_PURPOSE_UTF8, 6)
106
+ localHeader.writeUInt16LE(0, 8)
107
+ localHeader.writeUInt16LE(ZIP_DOS_TIME_2000_01_01, 10)
108
+ localHeader.writeUInt16LE(ZIP_DOS_DATE_2000_01_01, 12)
109
+ localHeader.writeUInt32LE(crc, 14)
110
+ localHeader.writeUInt32LE(entry.data.length, 18)
111
+ localHeader.writeUInt32LE(entry.data.length, 22)
112
+ localHeader.writeUInt16LE(name.length, 26)
113
+ localHeader.writeUInt16LE(0, 28)
114
+
115
+ localParts.push(localHeader, name, entry.data)
116
+
117
+ const centralHeader = Buffer.alloc(46)
118
+ centralHeader.writeUInt32LE(0x02014b50, 0)
119
+ centralHeader.writeUInt16LE(20, 4)
120
+ centralHeader.writeUInt16LE(10, 6)
121
+ centralHeader.writeUInt16LE(ZIP_GENERAL_PURPOSE_UTF8, 8)
122
+ centralHeader.writeUInt16LE(0, 10)
123
+ centralHeader.writeUInt16LE(ZIP_DOS_TIME_2000_01_01, 12)
124
+ centralHeader.writeUInt16LE(ZIP_DOS_DATE_2000_01_01, 14)
125
+ centralHeader.writeUInt32LE(crc, 16)
126
+ centralHeader.writeUInt32LE(entry.data.length, 20)
127
+ centralHeader.writeUInt32LE(entry.data.length, 24)
128
+ centralHeader.writeUInt16LE(name.length, 28)
129
+ centralHeader.writeUInt16LE(0, 30)
130
+ centralHeader.writeUInt16LE(0, 32)
131
+ centralHeader.writeUInt16LE(0, 34)
132
+ centralHeader.writeUInt16LE(0, 36)
133
+ centralHeader.writeUInt32LE(0, 38)
134
+ centralHeader.writeUInt32LE(offset, 42)
135
+ centralParts.push(centralHeader, name)
136
+
137
+ offset += localHeader.length + name.length + entry.data.length
138
+ }
139
+
140
+ const centralDirectoryOffset = offset
141
+ const centralDirectory = Buffer.concat(centralParts)
142
+ const endOfCentralDirectory = Buffer.alloc(22)
143
+ endOfCentralDirectory.writeUInt32LE(0x06054b50, 0)
144
+ endOfCentralDirectory.writeUInt16LE(0, 4)
145
+ endOfCentralDirectory.writeUInt16LE(0, 6)
146
+ endOfCentralDirectory.writeUInt16LE(entries.length, 8)
147
+ endOfCentralDirectory.writeUInt16LE(entries.length, 10)
148
+ endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12)
149
+ endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16)
150
+ endOfCentralDirectory.writeUInt16LE(0, 20)
151
+
152
+ return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory])
153
+ }
154
+
155
+ export function zipDirectoryToBase64(dirArg: string): ArtifactArchive {
156
+ const sourceDir = expandHomePath(dirArg)
157
+ try {
158
+ const stats = statSync(sourceDir)
159
+ if (!stats.isDirectory()) {
160
+ throw new Error(`${sourceDir} is not a directory`)
161
+ }
162
+ } catch (error: unknown) {
163
+ if (isNodeError(error) && error.code === 'ENOENT') {
164
+ throw new Error(`Directory not found: ${sourceDir}`)
165
+ }
166
+ throw error
167
+ }
168
+
169
+ const files = collectArtifactFiles(sourceDir)
170
+ if (files.length === 0) {
171
+ throw new Error(`Directory contains no artifact files: ${sourceDir}`)
172
+ }
173
+
174
+ const bytes = createStoredZip(files.map(relativePath => ({
175
+ path: relativePath,
176
+ data: readFileSync(join(sourceDir, relativePath)),
177
+ })))
178
+ return {
179
+ zipBase64: bytes.toString('base64'),
180
+ contentSha256: createHash('sha256').update(bytes).digest('hex'),
181
+ }
182
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Connector readiness CLI (ALI-903). Thin, read-only consumer of the
3
+ * project-scoped `/api/cli/connectors` endpoint. Pure command logic with an
4
+ * injected fetcher and project resolver; the entry point owns argument parsing
5
+ * and human/JSON output. Secret material is never printed (the endpoint already
6
+ * redacts config and surfaces credentials only as a boolean).
7
+ */
8
+
9
+ import { authedFetch } from './http.js'
10
+
11
+ export type CliFetcher = (path: string, init?: RequestInit) => Promise<Response>
12
+
13
+ export interface ConnectorReadiness {
14
+ type: string
15
+ status: string
16
+ hasCredentials: boolean
17
+ updatedAt: string | null
18
+ config: Record<string, unknown>
19
+ }
20
+
21
+ export interface ConnectorsResult {
22
+ connectors: ConnectorReadiness[]
23
+ }
24
+
25
+ export interface FetchConnectorsOptions {
26
+ fetcher?: CliFetcher
27
+ projectSlug: string
28
+ }
29
+
30
+ export interface ConnectorsCommandIo {
31
+ json: boolean
32
+ print: (line: string) => void
33
+ fetcher?: CliFetcher
34
+ resolveProjectSlug?: (arg: string | null) => Promise<string>
35
+ }
36
+
37
+ async function responseMessage(response: Response): Promise<string> {
38
+ try {
39
+ const data = (await response.clone().json()) as Record<string, unknown>
40
+ const error = typeof data.error === 'string' ? data.error : null
41
+ const message = typeof data.message === 'string' ? data.message : null
42
+ return error || message || response.statusText || String(response.status)
43
+ } catch {
44
+ return response.statusText || String(response.status)
45
+ }
46
+ }
47
+
48
+ export async function fetchConnectors(opts: FetchConnectorsOptions): Promise<ConnectorsResult> {
49
+ const fetcher = opts.fetcher ?? authedFetch
50
+ const response = await fetcher(`/api/cli/connectors?project=${encodeURIComponent(opts.projectSlug)}`)
51
+ if (!response.ok) {
52
+ throw new Error(`Connector status failed (${response.status}): ${await responseMessage(response)}`)
53
+ }
54
+ const data = (await response.json()) as ConnectorsResult
55
+ return { connectors: Array.isArray(data.connectors) ? data.connectors : [] }
56
+ }
57
+
58
+ function argValue(args: string[], flag: string): string | null {
59
+ const index = args.indexOf(flag)
60
+ if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
61
+ return null
62
+ }
63
+ return args[index + 1]
64
+ }
65
+
66
+ // Positional tokens only: skips flags AND their values, so
67
+ // `orizu connectors --project team/slug` never misreads the value.
68
+ function positionalArgs(args: string[]): string[] {
69
+ const values: string[] = []
70
+ for (let i = 0; i < args.length; i += 1) {
71
+ const arg = args[i]
72
+ if (arg.startsWith('--')) {
73
+ if (argValue(args, arg) !== null) {
74
+ i += 1
75
+ }
76
+ continue
77
+ }
78
+ values.push(arg)
79
+ }
80
+ return values
81
+ }
82
+
83
+ function formatConnector(connector: ConnectorReadiness): string {
84
+ const creds = connector.hasCredentials ? 'credentials:yes' : 'credentials:no'
85
+ return `${connector.type} ${connector.status} ${creds}`
86
+ }
87
+
88
+ export async function connectorsCommand(args: string[], io: ConnectorsCommandIo): Promise<number> {
89
+ const subcommand = positionalArgs(args)[0]
90
+ if (subcommand && subcommand !== 'status') {
91
+ io.print('Usage: orizu connectors [status] [--project <team/project>] [--json]')
92
+ return 1
93
+ }
94
+
95
+ const resolveProjectSlug = io.resolveProjectSlug
96
+ if (!resolveProjectSlug) {
97
+ throw new Error('Project resolver unavailable')
98
+ }
99
+ const projectSlug = await resolveProjectSlug(argValue(args, '--project'))
100
+ const result = await fetchConnectors({ fetcher: io.fetcher, projectSlug })
101
+
102
+ if (io.json) {
103
+ io.print(JSON.stringify(result))
104
+ return 0
105
+ }
106
+
107
+ if (result.connectors.length === 0) {
108
+ io.print('No connectors known for this project.')
109
+ return 0
110
+ }
111
+ for (const connector of result.connectors) {
112
+ io.print(formatConnector(connector))
113
+ }
114
+ return 0
115
+ }
@@ -20,6 +20,10 @@ import {
20
20
  StoredCredentialsV3,
21
21
  } from './types.js'
22
22
 
23
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
24
+ return error instanceof Error
25
+ }
26
+
23
27
  function getConfigDir(): string {
24
28
  if (process.env.ORIZU_CONFIG_DIR) {
25
29
  return process.env.ORIZU_CONFIG_DIR
@@ -100,8 +104,8 @@ function writeCredentials(config: StoredCredentialsV2 | StoredCredentialsV3) {
100
104
  chmodSync(tempPath, 0o600)
101
105
  try {
102
106
  renameSync(tempPath, path)
103
- } catch (error: any) {
104
- if (process.platform === 'win32' && error?.code === 'EEXIST') {
107
+ } catch (error: unknown) {
108
+ if (process.platform === 'win32' && isNodeError(error) && error.code === 'EEXIST') {
105
109
  rmSync(path, { force: true })
106
110
  renameSync(tempPath, path)
107
111
  } else {
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Self-contained rehearsal harness for the Daytona slice (ALI-973 / WS-F).
3
+ *
4
+ * Extends the smoke test's in-memory fake-server technique to the WS-F broker /
5
+ * session-branch / finish-branch / apply contracts, and backs it with a REAL
6
+ * local bare git repo so the whole flow (clone → commit → push → merge) runs
7
+ * against genuine git. Consumed by BOTH the unit test and the local-sim
8
+ * rehearsal in `scripts/daytona-workbench-slice.mjs`, so the two never drift.
9
+ *
10
+ * This is the piece that makes `--provider local-sim` a rehearsal, not a mock:
11
+ * `finish-branch` and `apply` shell out to git on the bare repo (rev-list /
12
+ * update-ref), so a session branch really is merged into `main`.
13
+ */
14
+
15
+ import { spawnSync } from 'child_process'
16
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
17
+ import { tmpdir } from 'os'
18
+ import { join } from 'path'
19
+
20
+ import { WORKSPACE_SCHEMA_VERSION } from './workspace.js'
21
+ import type { SliceConfig, SliceFetcher } from './daytona-slice.js'
22
+ import { createLocalSimProvider } from './sandbox-provider.js'
23
+ import type { SandboxProvider } from './sandbox-provider.js'
24
+
25
+ function git(args: string[], cwd?: string): { stdout: string; stderr: string; status: number } {
26
+ // Strip git's repo-context env vars before spawning. When this code runs
27
+ // inside a git hook (e.g. the pre-push hook running `bun test`), git sets
28
+ // GIT_DIR in the environment — and `git init --bare <dir>` with GIT_DIR set
29
+ // REINITIALIZES the repo GIT_DIR points at as bare (core.bare=true in the
30
+ // shared config), breaking every linked worktree. Scope every child git
31
+ // strictly to its cwd/args instead.
32
+ const env: NodeJS.ProcessEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' }
33
+ delete env.GIT_DIR
34
+ delete env.GIT_WORK_TREE
35
+ delete env.GIT_INDEX_FILE
36
+ delete env.GIT_OBJECT_DIRECTORY
37
+ delete env.GIT_COMMON_DIR
38
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', env })
39
+ return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', status: res.status ?? 1 }
40
+ }
41
+
42
+ function gitOrThrow(args: string[], cwd?: string): string {
43
+ const r = git(args, cwd)
44
+ if (r.status !== 0) {
45
+ throw new Error(`git ${args.join(' ')} failed: ${r.stderr.trim() || `exit ${r.status}`}`)
46
+ }
47
+ return r.stdout
48
+ }
49
+
50
+ export interface SeededBareRepo {
51
+ gitDir: string
52
+ repoFullName: string
53
+ defaultBranch: string
54
+ }
55
+
56
+ /**
57
+ * Create a bare git repo seeded with the Phase-1 workbench contract (matching
58
+ * `lib/workbench-repo-contract.ts`) on a `main` branch: root manifests, a
59
+ * `.gitignore` that ignores `.orizu/`, and a project stub.
60
+ */
61
+ export function seedLocalBareRepo(baseDir: string, config: SliceConfig): SeededBareRepo {
62
+ const gitDir = join(baseDir, 'workbench.git')
63
+ gitOrThrow(['init', '--bare', '--initial-branch=main', gitDir])
64
+
65
+ const work = mkdtempSync(join(baseDir, 'seed-'))
66
+ gitOrThrow(['init', '--initial-branch=main', work])
67
+ const ident = ['-c', 'user.email=seed@orizu.local', '-c', 'user.name=Seed']
68
+
69
+ const teamManifest = {
70
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
71
+ kind: 'team',
72
+ slug: config.teamSlug,
73
+ canonical: { owner: 'repo', repoState: 'source', objectRef: null },
74
+ }
75
+ const projectManifest = {
76
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
77
+ kind: 'project',
78
+ slug: config.projectSlug,
79
+ canonical: { owner: 'repo', repoState: 'draft', objectRef: null },
80
+ }
81
+ writeFileSync(join(work, 'orizu.team.json'), `${JSON.stringify(teamManifest, null, 2)}\n`)
82
+ writeFileSync(join(work, '.gitignore'), '# Orizu workspace policy\n.orizu/\n')
83
+ writeFileSync(join(work, 'AGENTS.md'), '# Workbench\n')
84
+ const projectDir = join(work, 'projects', config.projectSlug)
85
+ mkdirSync(projectDir, { recursive: true })
86
+ writeFileSync(join(projectDir, 'orizu.project.json'), `${JSON.stringify(projectManifest, null, 2)}\n`)
87
+ writeFileSync(join(projectDir, '.gitkeep'), '')
88
+
89
+ gitOrThrow([...ident, 'add', '-A'], work)
90
+ gitOrThrow([...ident, 'commit', '-m', 'seed workbench contract'], work)
91
+ gitOrThrow(['remote', 'add', 'origin', gitDir], work)
92
+ gitOrThrow(['push', 'origin', 'main'], work)
93
+ rmSync(work, { recursive: true, force: true })
94
+
95
+ return { gitDir, repoFullName: `orizu-sim/orizu-workbench-${config.teamSlug}`, defaultBranch: 'main' }
96
+ }
97
+
98
+ interface StoredSession {
99
+ id: string
100
+ workspaceId: string
101
+ branch: string | null
102
+ headSha: string | null
103
+ projectSlug: string | null
104
+ }
105
+
106
+ interface StoredMint {
107
+ id: string
108
+ workspaceId: string
109
+ purpose: string
110
+ revoked: boolean
111
+ }
112
+
113
+ interface StoredManifest {
114
+ id: string
115
+ status: string
116
+ branch: string
117
+ headSha: string
118
+ outcome: Record<string, unknown>
119
+ }
120
+
121
+ function jsonResponse(body: unknown, status = 200): Response {
122
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
123
+ }
124
+
125
+ /**
126
+ * In-memory WS-F control plane over a real bare repo. Session-start cuts a real
127
+ * branch; finish-branch diffs it; apply fast-forwards `main`. Tracks token mints
128
+ * so revocation is observable and asserts tokens never leak into stored state.
129
+ */
130
+ export class FakeWorkbenchApi {
131
+ workspaces = new Map<string, { id: string; teamSlug: string; slug: string }>()
132
+ sessions = new Map<string, StoredSession>()
133
+ mints = new Map<string, StoredMint>()
134
+ manifests = new Map<string, StoredManifest>()
135
+ tokensIssued: string[] = []
136
+ private counter = 0
137
+
138
+ constructor(private readonly repo: SeededBareRepo) {}
139
+
140
+ private id(prefix: string): string {
141
+ this.counter += 1
142
+ return `${prefix}-${this.counter}`
143
+ }
144
+
145
+ private branchHead(branch: string): string {
146
+ return gitOrThrow(['--git-dir', this.repo.gitDir, 'rev-parse', branch]).trim()
147
+ }
148
+
149
+ newFetcher(): SliceFetcher {
150
+ return async (path: string, init: RequestInit = {}) => {
151
+ const method = (init.method || 'GET').toUpperCase()
152
+ const [rawPath] = path.split('?')
153
+ const body = typeof init.body === 'string' ? (JSON.parse(init.body) as Record<string, unknown>) : {}
154
+
155
+ // Workspace create-or-attach (idempotent on teamSlug + slug).
156
+ if (method === 'POST' && rawPath === '/api/cli/workspaces') {
157
+ const key = `${body.teamSlug}/${body.slug}`
158
+ const existing = this.workspaces.get(key)
159
+ if (existing) return jsonResponse({ workspace: { ...existing } }, 200)
160
+ const workspace = { id: this.id('ws'), teamSlug: String(body.teamSlug), slug: String(body.slug) }
161
+ this.workspaces.set(key, workspace)
162
+ return jsonResponse({ workspace }, 201)
163
+ }
164
+
165
+ // Session start — cut a real `orizu/session-<id>` branch off main.
166
+ const sessMatch = rawPath.match(/^\/api\/cli\/workspaces\/([^/]+)\/sessions$/)
167
+ if (method === 'POST' && sessMatch) {
168
+ const sessionId = this.id('sess')
169
+ const stored: StoredSession = {
170
+ id: sessionId,
171
+ workspaceId: sessMatch[1],
172
+ branch: null,
173
+ headSha: null,
174
+ projectSlug: typeof body.projectSlug === 'string' ? body.projectSlug : null,
175
+ }
176
+ if (body.repoBranch !== false) {
177
+ const branch = `orizu/session-${sessionId}`
178
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', branch, this.repo.defaultBranch])
179
+ stored.branch = branch
180
+ stored.headSha = this.branchHead(branch)
181
+ }
182
+ this.sessions.set(sessionId, stored)
183
+ return jsonResponse(
184
+ { session: { id: sessionId, repoBranch: stored.branch, repoHeadSha: stored.headSha, status: 'active' } },
185
+ 201
186
+ )
187
+ }
188
+
189
+ // Repo-token mint.
190
+ const tokenMatch = rawPath.match(/^\/api\/cli\/workspaces\/([^/]+)\/repo-token$/)
191
+ if (method === 'POST' && tokenMatch) {
192
+ const purpose = String(body.purpose)
193
+ const mintId = this.id('mint')
194
+ const token = `ghs_sim_${purpose}_${mintId}_${Math.random().toString(36).slice(2)}`
195
+ this.mints.set(mintId, { id: mintId, workspaceId: tokenMatch[1], purpose, revoked: false })
196
+ this.tokensIssued.push(token)
197
+ return jsonResponse({
198
+ token,
199
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
200
+ repo: this.repo.repoFullName,
201
+ mintId,
202
+ })
203
+ }
204
+
205
+ // Repo-token revoke.
206
+ if (method === 'DELETE' && tokenMatch) {
207
+ const mintId = String(body.mintId)
208
+ const mint = this.mints.get(mintId)
209
+ if (!mint) return jsonResponse({ error: 'Token mint not found' }, 404)
210
+ mint.revoked = true
211
+ return jsonResponse({ revoked: true, mintId })
212
+ }
213
+
214
+ // Finish-branch — compare the session branch against main.
215
+ const finishMatch = rawPath.match(/^\/api\/cli\/sessions\/([^/]+)\/finish-branch$/)
216
+ if (method === 'POST' && finishMatch) {
217
+ const session = this.sessions.get(finishMatch[1])
218
+ if (!session || !session.branch) return jsonResponse({ error: 'Session has no repo branch' }, 400)
219
+ const ahead = Number.parseInt(
220
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'rev-list', '--count', `${this.repo.defaultBranch}..${session.branch}`]).trim(),
221
+ 10
222
+ )
223
+ if (ahead === 0) {
224
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', session.branch])
225
+ return jsonResponse({ outcome: 'no-changes', branch: session.branch })
226
+ }
227
+ const headSha = this.branchHead(session.branch)
228
+ const manifestId = this.id('man')
229
+ this.manifests.set(manifestId, {
230
+ id: manifestId,
231
+ status: 'pending_approval',
232
+ branch: session.branch,
233
+ headSha,
234
+ outcome: {},
235
+ })
236
+ return jsonResponse(
237
+ { outcome: 'manifest', manifest: { id: manifestId, status: 'pending_approval', actionType: 'repo_merge' } },
238
+ 201
239
+ )
240
+ }
241
+
242
+ // Manifest approve / apply / reject.
243
+ const manifestMatch = rawPath.match(/^\/api\/cli\/promotion-manifests\/([^/]+)$/)
244
+ if (method === 'POST' && manifestMatch) {
245
+ const manifest = this.manifests.get(manifestMatch[1])
246
+ if (!manifest) return jsonResponse({ error: 'Promotion manifest not found' }, 404)
247
+ const action = String(body.action)
248
+ if (action === 'approve') {
249
+ if (manifest.status === 'pending_approval' || manifest.status === 'draft') manifest.status = 'approved'
250
+ return jsonResponse({ manifest: { id: manifest.id, status: manifest.status } })
251
+ }
252
+ if (action === 'apply') {
253
+ if (manifest.status === 'applied') {
254
+ return jsonResponse({ manifest: { id: manifest.id, status: 'applied', outcome: manifest.outcome } })
255
+ }
256
+ if (manifest.status !== 'approved') {
257
+ return jsonResponse({ error: 'Manifest must be approved before it can be applied' }, 400)
258
+ }
259
+ // Re-verify the branch head, then fast-forward main to it (real merge).
260
+ const head = this.branchHead(manifest.branch)
261
+ if (head !== manifest.headSha) return jsonResponse({ error: 'branch moved' }, 409)
262
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'update-ref', `refs/heads/${this.repo.defaultBranch}`, head])
263
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', manifest.branch])
264
+ manifest.status = 'applied'
265
+ manifest.outcome = { merged: true, mergeSha: head }
266
+ return jsonResponse({ manifest: { id: manifest.id, status: 'applied', outcome: manifest.outcome } })
267
+ }
268
+ if (action === 'reject') {
269
+ manifest.status = 'rejected'
270
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', manifest.branch])
271
+ return jsonResponse({ manifest: { id: manifest.id, status: 'rejected' } })
272
+ }
273
+ return jsonResponse({ error: "action must be one of 'approve', 'reject', 'apply'" }, 400)
274
+ }
275
+
276
+ throw new Error(`FakeWorkbenchApi: unexpected fetch ${method} ${path}`)
277
+ }
278
+ }
279
+ }
280
+
281
+ export interface SliceRehearsal {
282
+ config: SliceConfig
283
+ api: FakeWorkbenchApi
284
+ provider: SandboxProvider
285
+ makeFetcher: () => SliceFetcher
286
+ resolveCloneUrl: (repo: string) => string
287
+ bareRepoDir: string
288
+ defaultBranch: () => string
289
+ cleanup: () => void
290
+ }
291
+
292
+ /**
293
+ * Wire a full self-contained rehearsal: seed a bare repo, stand up the fake
294
+ * control plane, and hand back a local-sim provider + resolveCloneUrl that maps
295
+ * the broker's repo name to the bare repo path.
296
+ */
297
+ export function createSliceRehearsal(options: { config?: Partial<SliceConfig>; keep?: boolean } = {}): SliceRehearsal {
298
+ const config: SliceConfig = {
299
+ teamSlug: options.config?.teamSlug ?? 'acme',
300
+ projectSlug: options.config?.projectSlug ?? 'support-bot',
301
+ workspaceSlug: options.config?.workspaceSlug ?? 'support-bot-slice',
302
+ }
303
+ const base = mkdtempSync(join(tmpdir(), 'orizu-slice-rehearsal-'))
304
+ const repo = seedLocalBareRepo(base, config)
305
+ const api = new FakeWorkbenchApi(repo)
306
+ const provider = createLocalSimProvider({ rootDir: join(base, 'sandboxes'), keepOnDestroy: Boolean(options.keep) })
307
+
308
+ return {
309
+ config,
310
+ api,
311
+ provider,
312
+ makeFetcher: () => api.newFetcher(),
313
+ resolveCloneUrl: () => repo.gitDir,
314
+ bareRepoDir: repo.gitDir,
315
+ defaultBranch: () => gitOrThrow(['--git-dir', repo.gitDir, 'rev-parse', repo.defaultBranch]).trim(),
316
+ cleanup: () => {
317
+ if (!options.keep) rmSync(base, { recursive: true, force: true })
318
+ },
319
+ }
320
+ }