orizu 0.5.11 → 0.5.13

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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Transport-agnostic deploy-key merge core (ALI-1084, merge-sandbox-job
3
+ * Phase 0). Extracted VERBATIM from `lib/git-ssh-merge.ts` so both the server
4
+ * (`lib/`) and the published CLI (`orizu internal merge-job`, Phase 2) run the
5
+ * EXACT SAME merge logic — no forked implementations, one place where the
6
+ * ADR-007 §4 `--no-ff` invariant is asserted on the exact argv.
7
+ *
8
+ * DELIBERATELY NO `import 'server-only'`: this module runs inside the one-shot
9
+ * merge sandbox (a plain Node process) as well as on the server. It must not
10
+ * import anything outside the CLI package root (bun-bundled) — node builtins
11
+ * only. `lib/git-ssh-merge.ts` re-exports everything here so every existing
12
+ * server import path and test keeps working unchanged.
13
+ *
14
+ * SECURITY PROPERTIES (unchanged from the lib original):
15
+ * - throwaway temp worktree + isolated SSH identity (key file mode 0600);
16
+ * - pinned GitHub host keys (StrictHostKeyChecking=yes, own known_hosts);
17
+ * - expected-head verification: the merge targets the REVIEWED SHA, never a
18
+ * branch name;
19
+ * - `merge-base --is-ancestor` alreadyMerged idempotency check;
20
+ * - argv-asserted commit-preserving `--no-ff` merge (ADR-007 §4).
21
+ */
22
+
23
+ import { spawn } from 'node:child_process'
24
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
25
+ import { tmpdir } from 'node:os'
26
+ import { join } from 'node:path'
27
+
28
+ export interface MergeBranchWithDeployKeyInput {
29
+ base: string
30
+ sourceBranch: string
31
+ expectedHeadSha: string
32
+ commitMessage: string
33
+ privateKeyPem: string
34
+ }
35
+
36
+ export interface GitCommandResult {
37
+ exitCode: number
38
+ stdout: string
39
+ stderr: string
40
+ }
41
+
42
+ export type GitCommandRunner = (
43
+ args: string[],
44
+ options: { cwd: string; env: NodeJS.ProcessEnv }
45
+ ) => Promise<GitCommandResult>
46
+
47
+ export interface MergeBranchWithDeployKeyDeps {
48
+ runGit?: GitCommandRunner
49
+ /**
50
+ * D8 pre-push gate (merge-sandbox-job Phase 2): called IMMEDIATELY before
51
+ * `git push`, after the merge commit exists locally. Returning `false`
52
+ * aborts WITHOUT pushing (`superseded: true` in the result) — a manifest
53
+ * rejected while the job was in flight must never reach `main`. Absent
54
+ * (the local/server executor), the merge pushes unconditionally as before.
55
+ */
56
+ prePushGate?: () => Promise<boolean>
57
+ }
58
+
59
+ /**
60
+ * The merge outcome contract. Structurally identical to `MergeResult` in
61
+ * `lib/github-repo-ops.ts` (which cannot be imported from the CLI package —
62
+ * it is server-only); TypeScript's structural typing keeps the two
63
+ * assignable, and `lib/git-ssh-merge.ts` continues to expose the lib-side
64
+ * type to server callers.
65
+ */
66
+ export interface DeployKeyMergeResult {
67
+ merged: boolean
68
+ sha: string | null
69
+ /** true when the base already contained head (GitHub 204 equivalent). */
70
+ alreadyMerged: boolean
71
+ /** true when git reported a merge conflict (409 equivalent). */
72
+ conflict: boolean
73
+ /** true when the D8 pre-push gate refused the push (manifest no longer
74
+ * appliable) — the merge commit was built but NEVER pushed. */
75
+ superseded?: boolean
76
+ status: number
77
+ }
78
+
79
+ /**
80
+ * ADR-007 §4 merge-strategy invariant: the seal merge must stay a COMMIT-
81
+ * PRESERVING merge. `--no-ff` keeps the session-branch head as a parent of the
82
+ * merge commit, so every create-time `commit_sha` pinned by a draft version
83
+ * row stays reachable on the default branch forever. A squash or rebase would
84
+ * rewrite/discard those commits and orphan the pins — "what the label says is
85
+ * live" could then dangle. These flags must never appear in the seal merge.
86
+ */
87
+ const FORBIDDEN_SEAL_MERGE_FLAGS = ['--squash', '--ff-only', '--ff', 'rebase'] as const
88
+ const REQUIRED_SEAL_MERGE_FLAG = '--no-ff'
89
+
90
+ /**
91
+ * Assert a git invocation is the commit-preserving seal merge ADR-007 §4
92
+ * requires. Called on the exact argv the merge runs with, so the invariant is
93
+ * enforced where the merge happens rather than by reviewer vigilance.
94
+ */
95
+ export function assertCommitPreservingMergeArgs(args: readonly string[]): void {
96
+ if (!args.includes('merge')) {
97
+ throw new Error('seal merge invariant: expected a git merge invocation')
98
+ }
99
+ if (!args.includes(REQUIRED_SEAL_MERGE_FLAG)) {
100
+ throw new Error(
101
+ `seal merge invariant violated: ${REQUIRED_SEAL_MERGE_FLAG} is required (ADR-007 §4 — the merge must keep the session head as a parent)`
102
+ )
103
+ }
104
+ for (const flag of FORBIDDEN_SEAL_MERGE_FLAGS) {
105
+ if (args.includes(flag)) {
106
+ throw new Error(
107
+ `seal merge invariant violated: ${flag} would rewrite or drop session commits and orphan draft version pins (ADR-007 §4)`
108
+ )
109
+ }
110
+ }
111
+ }
112
+
113
+ const LOCAL_BASE_REF = 'refs/heads/__orizu_merge_base'
114
+ const LOCAL_SOURCE_REF = 'refs/remotes/origin/__orizu_merge_source'
115
+ // GitHub's published SSH host keys (PINNED — never ssh-keyscan'd at runtime):
116
+ // https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints
117
+ // (canonically republished at https://api.github.com/meta `ssh_keys`).
118
+ const GITHUB_HOST_KEYS = [
119
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl',
120
+ 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=',
121
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=',
122
+ ] as const
123
+
124
+ // Both SSH endpoints GitHub serves git over: the standard port AND the
125
+ // SSH-over-the-HTTPS-port endpoint (docs.github.com "Using SSH over the
126
+ // HTTPS port") the merge sandbox uses — its egress firewall enforces at
127
+ // SNI/CIDR level and raw SSH on :22 was reset in the first live run
128
+ // (ALI-1084 live-fire, 2026-07-15). GitHub publishes ONE key set for both
129
+ // hosts; OpenSSH matches a non-22 port with the `[host]:port` known_hosts
130
+ // form, so the 443 endpoint needs its own lines. Pinning discipline is
131
+ // unchanged: same keys, one more host alias.
132
+ const GITHUB_KNOWN_HOSTS =
133
+ GITHUB_HOST_KEYS.flatMap(key => [`github.com ${key}`, `[ssh.github.com]:443 ${key}`]).join(
134
+ '\n'
135
+ ) + '\n'
136
+
137
+ /**
138
+ * Strip git's repo-context env vars from a child git's environment. Git
139
+ * EXPORTS these into hook subprocesses (from a linked worktree, GIT_DIR is an
140
+ * ABSOLUTE path into the shared .git), so any descendant git command that
141
+ * inherits the parent env operates on THAT repo instead of its own cwd —
142
+ * `git config` writes land in the shared .git/config and `git init` flips it
143
+ * to core.bare=true (both observed when the pre-push hook ran the test suite
144
+ * from a worktree). Every real-git spawn must build its env through this.
145
+ */
146
+ export function scrubGitRepoContextEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
147
+ const scrubbed = { ...env }
148
+ delete scrubbed.GIT_DIR
149
+ delete scrubbed.GIT_WORK_TREE
150
+ delete scrubbed.GIT_INDEX_FILE
151
+ delete scrubbed.GIT_OBJECT_DIRECTORY
152
+ delete scrubbed.GIT_COMMON_DIR
153
+ delete scrubbed.GIT_NAMESPACE
154
+ return scrubbed
155
+ }
156
+
157
+ // Exported: the ADR-007 P3 read path (lib/artifact-git-read.ts) runs its
158
+ // verified reads through the same spawn.
159
+ export function defaultRunGit(
160
+ args: string[],
161
+ options: { cwd: string; env: NodeJS.ProcessEnv }
162
+ ): Promise<GitCommandResult> {
163
+ return new Promise((resolve, reject) => {
164
+ const child = spawn('git', args, {
165
+ cwd: options.cwd,
166
+ env: options.env,
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ })
169
+ let stdout = ''
170
+ let stderr = ''
171
+ child.stdout.setEncoding('utf8')
172
+ child.stderr.setEncoding('utf8')
173
+ child.stdout.on('data', chunk => { stdout += chunk })
174
+ child.stderr.on('data', chunk => { stderr += chunk })
175
+ child.on('error', reject)
176
+ child.on('close', code => resolve({ exitCode: code ?? 1, stdout, stderr }))
177
+ })
178
+ }
179
+
180
+ export function sshRemote(repoFullName: string): string {
181
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoFullName)) {
182
+ throw new Error(`Invalid repo full name: ${repoFullName}`)
183
+ }
184
+ return `git@github.com:${repoFullName}.git`
185
+ }
186
+
187
+ /**
188
+ * The SSH-over-the-HTTPS-port remote (GitHub-supported: `ssh.github.com:443`
189
+ * speaks the same SSH protocol, same auth, same host-key set). The MERGE core
190
+ * uses this endpoint because the merge sandbox's egress firewall enforces at
191
+ * the SNI/CIDR layer and reset raw SSH on :22 in the first live run
192
+ * (ALI-1084); :443 within GitHub's published git CIDRs is the compatible
193
+ * path. Equivalent from dev machines, so local/sandbox parity holds (D6).
194
+ * The push/read paths (D9/Phase 6) keep `sshRemote` untouched.
195
+ */
196
+ export function sshRemoteOverHttpsPort(repoFullName: string): string {
197
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoFullName)) {
198
+ throw new Error(`Invalid repo full name: ${repoFullName}`)
199
+ }
200
+ return `ssh://git@ssh.github.com:443/${repoFullName}.git`
201
+ }
202
+
203
+ function shellQuote(value: string): string {
204
+ return `'${value.replace(/'/g, "'\\''")}'`
205
+ }
206
+
207
+ /** Exported for `lib/git-ssh-merge.ts` (pushFilesWithDeployKey keeps living
208
+ * there — D9 scopes the push path as a follow-on, not part of the core). */
209
+ export function assertGitOk(result: GitCommandResult, operation: string): void {
210
+ if (result.exitCode === 0) return
211
+ const detail = (result.stderr || result.stdout).trim()
212
+ throw new Error(detail ? `${operation} failed: ${detail}` : `${operation} failed`)
213
+ }
214
+
215
+ function looksLikeMergeConflict(result: GitCommandResult): boolean {
216
+ const output = `${result.stdout}\n${result.stderr}`
217
+ return /CONFLICT|Automatic merge failed|Merge conflict/i.test(output)
218
+ }
219
+
220
+ export interface DeployKeyGitContext {
221
+ dir: string
222
+ git: (args: string[]) => Promise<GitCommandResult>
223
+ gitOk: (args: string[], operation: string) => Promise<GitCommandResult>
224
+ }
225
+
226
+ /**
227
+ * Run a callback inside a throwaway git worktree wired to the repo's merge
228
+ * deploy key (isolated SSH identity + pinned GitHub host keys). Exported for
229
+ * the ADR-007 read path (lib/artifact-git-read.ts), which reuses the same
230
+ * clone/pack infrastructure for bulk artifact reads.
231
+ */
232
+ export async function withDeployKeyGit<T>(
233
+ privateKeyPem: string,
234
+ runGit: GitCommandRunner,
235
+ tempPrefix: string,
236
+ callback: (context: DeployKeyGitContext) => Promise<T>
237
+ ): Promise<T> {
238
+ const tempRoot = await mkdtemp(join(tmpdir(), tempPrefix))
239
+ const dir = join(tempRoot, 'worktree')
240
+ const sshDir = join(tempRoot, 'ssh')
241
+ await mkdir(dir)
242
+ await mkdir(sshDir)
243
+ const keyPath = join(sshDir, 'merge_deploy_key')
244
+ const knownHostsPath = join(sshDir, 'known_hosts')
245
+ const env = {
246
+ // Scrubbed: a leaked GIT_DIR (git exports it into hooks) must never
247
+ // redirect this temp-worktree clone/merge onto another repo.
248
+ ...scrubGitRepoContextEnv(process.env),
249
+ GIT_TERMINAL_PROMPT: '0',
250
+ GIT_SSH_COMMAND: [
251
+ 'ssh',
252
+ '-i',
253
+ shellQuote(keyPath),
254
+ '-o',
255
+ 'IdentitiesOnly=yes',
256
+ '-o',
257
+ 'StrictHostKeyChecking=yes',
258
+ '-o',
259
+ shellQuote(`UserKnownHostsFile=${knownHostsPath}`),
260
+ ].join(' '),
261
+ }
262
+ const git = (args: string[]) => runGit(args, { cwd: dir, env })
263
+ const gitOk = async (args: string[], operation: string) => {
264
+ const result = await git(args)
265
+ assertGitOk(result, operation)
266
+ return result
267
+ }
268
+
269
+ try {
270
+ await writeFile(keyPath, privateKeyPem, { mode: 0o600 })
271
+ await chmod(keyPath, 0o600)
272
+ await writeFile(knownHostsPath, GITHUB_KNOWN_HOSTS, { mode: 0o600 })
273
+ return await callback({ dir, git, gitOk })
274
+ } finally {
275
+ await rm(tempRoot, { recursive: true, force: true })
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Merge a reviewed session/revert branch head into the default branch using the
281
+ * server-only merge deploy key. The source branch is fetched only so GitHub can
282
+ * prove the reviewed SHA is still the branch tip; the merge itself targets the
283
+ * expected SHA, preserving the manifest CAS guarantee.
284
+ */
285
+ export async function mergeBranchWithDeployKey(
286
+ repoFullName: string,
287
+ input: MergeBranchWithDeployKeyInput,
288
+ deps: MergeBranchWithDeployKeyDeps = {}
289
+ ): Promise<DeployKeyMergeResult> {
290
+ const runGit = deps.runGit ?? defaultRunGit
291
+ return withDeployKeyGit(input.privateKeyPem, runGit, 'orizu-merge-', async ({ git, gitOk }) => {
292
+ await gitOk(['init'], 'git init')
293
+ // SSH over the HTTPS port (ALI-1084 live-fire fix): the merge sandbox's
294
+ // egress firewall reset raw SSH on github.com:22; ssh.github.com:443 is
295
+ // the GitHub-supported equivalent (same protocol, same pinned host keys).
296
+ await gitOk(['remote', 'add', 'origin', sshRemoteOverHttpsPort(repoFullName)], 'git remote add')
297
+ await gitOk(
298
+ ['fetch', '--no-tags', 'origin', `refs/heads/${input.base}:${LOCAL_BASE_REF}`],
299
+ 'git fetch default branch'
300
+ )
301
+ await gitOk(['checkout', '-B', '__orizu_merge_base', LOCAL_BASE_REF], 'git checkout default branch')
302
+ await gitOk(
303
+ ['fetch', '--no-tags', 'origin', `refs/heads/${input.sourceBranch}:${LOCAL_SOURCE_REF}`],
304
+ 'git fetch reviewed source branch'
305
+ )
306
+
307
+ const sourceHead = (await gitOk(['rev-parse', LOCAL_SOURCE_REF], 'git rev-parse source')).stdout.trim()
308
+ if (sourceHead !== input.expectedHeadSha) {
309
+ throw new Error('branch moved since manifest creation; re-finish the session')
310
+ }
311
+
312
+ const ancestor = await git(['merge-base', '--is-ancestor', input.expectedHeadSha, 'HEAD'])
313
+ if (ancestor.exitCode === 0) {
314
+ const head = (await gitOk(['rev-parse', 'HEAD'], 'git rev-parse default')).stdout.trim()
315
+ return { merged: false, sha: head, alreadyMerged: true, conflict: false, status: 204 }
316
+ }
317
+ if (ancestor.exitCode !== 1) {
318
+ assertGitOk(ancestor, 'git merge-base')
319
+ }
320
+
321
+ // ADR-007 §4: the seal is a commit-preserving --no-ff merge — never squash,
322
+ // never rebase — so every draft version's create-time commit_sha stays
323
+ // reachable once the branch reaches the default branch.
324
+ const mergeArgs = [
325
+ '-c',
326
+ 'user.name=Orizu',
327
+ '-c',
328
+ 'user.email=bot@orizu.ai',
329
+ 'merge',
330
+ '--no-ff',
331
+ '-m',
332
+ input.commitMessage,
333
+ input.expectedHeadSha,
334
+ ]
335
+ assertCommitPreservingMergeArgs(mergeArgs)
336
+ const merge = await git(mergeArgs)
337
+ if (merge.exitCode !== 0) {
338
+ if (looksLikeMergeConflict(merge)) {
339
+ return { merged: false, sha: null, alreadyMerged: false, conflict: true, status: 409 }
340
+ }
341
+ assertGitOk(merge, 'git merge')
342
+ }
343
+
344
+ const mergeSha = (await gitOk(['rev-parse', 'HEAD'], 'git rev-parse merge')).stdout.trim()
345
+
346
+ // D8 pre-push gate: the LAST check before anything reaches the default
347
+ // branch. A refused gate aborts with superseded — no push, no side effect
348
+ // (the temp worktree dies with this callback).
349
+ if (deps.prePushGate) {
350
+ const proceed = await deps.prePushGate()
351
+ if (!proceed) {
352
+ return { merged: false, sha: null, alreadyMerged: false, conflict: false, superseded: true, status: 409 }
353
+ }
354
+ }
355
+
356
+ await gitOk(['push', 'origin', `HEAD:refs/heads/${input.base}`], 'git push default branch')
357
+ return { merged: true, sha: mergeSha, alreadyMerged: false, conflict: false, status: 201 }
358
+ })
359
+ }
@@ -164,7 +164,10 @@ export function redactBootReason(reason: string, bootSecret: string): string {
164
164
  export async function postBootStatus(opts: {
165
165
  bootStatusUrl: string
166
166
  bootSecret: string
167
- status: 'ready' | 'failed'
167
+ /** 'ready' | 'failed' are the ALI-1060 liveness signals; 'complete' is the
168
+ * ALI-1064 terminal signal — the loop finished (after auto-harvest), so the
169
+ * DO ends the workspace session and stops instead of extending to 24h. */
170
+ status: 'ready' | 'failed' | 'complete'
168
171
  runId: string | null
169
172
  reason?: string | null
170
173
  fetchImpl: BootFetch
@@ -748,6 +751,25 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
748
751
  bearerProvider: () => readFile(bearerFileAbs).trim(),
749
752
  })
750
753
  log(`hosted-loop finished: ${result.status}`)
754
+ // ALI-1064: the loop is TERMINAL — the auto-harvest (ALI-1036) and the
755
+ // run's terminal seal already ran INSIDE runHostedLoop, so it is safe to
756
+ // hand the session back. Report `complete` so the DO ends the workspace
757
+ // session server-side and stops/destroys the sandbox NOW instead of
758
+ // extending it until the 24h cap (live 2026-07-15: zombie boxes burned
759
+ // 2×CPU/4GiB for hours after their loops finished). This fires for a
760
+ // failed loop too — the run is terminal either way, and the loop already
761
+ // owns its own run-level terminal state (never a `failed` boot-status
762
+ // here; that would misreport a run failure as a bootstrap failure).
763
+ // Best-effort: the DO's duration/24h caps are the backstop.
764
+ await postBootStatus({
765
+ bootStatusUrl: env.bootStatusUrl,
766
+ bootSecret: env.bootSecret,
767
+ status: 'complete',
768
+ runId,
769
+ reason: result.error ? redactBootReason(result.error, env.bootSecret) : null,
770
+ fetchImpl,
771
+ log,
772
+ })
751
773
  return { ok: !result.error, runId, loopStatus: result.status, error: result.error }
752
774
  } finally {
753
775
  rotation.stop()
@@ -54,6 +54,7 @@ import { createVercelProvider } from './vercel-sandbox-provider.js'
54
54
  import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js'
55
55
  import { hostedLoopCommand, type HostedLoopContext } from './hosted-loop.js'
56
56
  import { hostedBootCommand } from './hosted-boot.js'
57
+ import { mergeJobCommand } from './merge-job.js'
57
58
  import { authedFetch } from './http.js'
58
59
  import { resolveBaseUrl } from './http.js'
59
60
  import { getWorkspaceRoot } from './workspace.js'
@@ -926,7 +927,11 @@ export async function hostedCommand(
926
927
  if (positional[1] === 'hosted-boot') {
927
928
  return hostedBootCommand(io)
928
929
  }
929
- io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot>')
930
+ // ALI-1084: the one-shot merge sandbox entrypoint (MergeJobCoordinator DO).
931
+ if (positional[1] === 'merge-job') {
932
+ return mergeJobCommand(io)
933
+ }
934
+ io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot | merge-job>')
930
935
  return 1
931
936
  }
932
937
 
package/src/index.ts CHANGED
@@ -19,7 +19,13 @@ import { homedir, tmpdir } from 'os'
19
19
  import { createInterface } from 'readline/promises'
20
20
  import { emitKeypressEvents } from 'readline'
21
21
  import { stdin as input, stdout as output } from 'process'
22
- import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js'
22
+ import {
23
+ clearServerCredentials,
24
+ getServerCredentials,
25
+ hasResolvableAuth,
26
+ resolveAuthTokenForBaseUrl,
27
+ saveServerCredentials,
28
+ } from './credentials.js'
23
29
  import { parseDatasetFile } from './file-parser.js'
24
30
  import { streamJsonlRowChunks } from './jsonl-stream.js'
25
31
  import { parseDatasetReference } from './dataset-download.js'
@@ -507,15 +513,6 @@ function shellQuote(value: string): string {
507
513
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
508
514
  }
509
515
 
510
- function getStoredAuthTokenForBaseUrl(baseUrl: string): string {
511
- const credentials = getServerCredentials(baseUrl)
512
- if (!credentials) {
513
- throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
514
- }
515
-
516
- return 'accessToken' in credentials ? credentials.accessToken : credentials.apiKey
517
- }
518
-
519
516
  function printPromptSummaries(items: PromptSummary[], emptyMessage: string) {
520
517
  if (items.length === 0) {
521
518
  printLine(emptyMessage)
@@ -1180,7 +1177,10 @@ async function login() {
1180
1177
  const baseUrl = hasArg('--no-prompt-if-logged-in') ? getBaseUrl() : resolveLoginBaseUrl()
1181
1178
  assertSecureTokenTransport(baseUrl)
1182
1179
 
1183
- if (hasArg('--no-prompt-if-logged-in') && getServerCredentials(baseUrl)) {
1180
+ // hasResolvableAuth (ALI-1090): an env bearer (ORIZU_TOKEN / ORIZU_TOKEN_FILE)
1181
+ // counts as logged in — a pre-authenticated hosted sandbox must not fall into
1182
+ // the interactive browser flow.
1183
+ if (hasArg('--no-prompt-if-logged-in') && hasResolvableAuth(baseUrl)) {
1184
1184
  if (hasJsonFlag()) {
1185
1185
  printJson({ status: 'already-logged-in', server: baseUrl })
1186
1186
  return
@@ -1351,7 +1351,10 @@ async function logout() {
1351
1351
 
1352
1352
  async function printEnv() {
1353
1353
  const baseUrl = getBaseUrl()
1354
- const token = getStoredAuthTokenForBaseUrl(baseUrl)
1354
+ // Uniform resolution (ALI-1090): env bearer (ORIZU_TOKEN / ORIZU_TOKEN_FILE)
1355
+ // wins over credentials.json, so `orizu env` works in hosted sandboxes that
1356
+ // never ran `orizu login`.
1357
+ const token = resolveAuthTokenForBaseUrl(baseUrl)
1355
1358
  const projectArg = getArg('--project')
1356
1359
  const project = projectArg ? await resolveProjectSelection(projectArg) : null
1357
1360
  const projectId = project?.id || getArg('--project-id') || process.env.ORIZU_PROJECT_ID || ''
@@ -2643,7 +2646,10 @@ function bundledOrizuGepaPythonPath(): string | null {
2643
2646
  async function runGepaOptimization() {
2644
2647
  const project = getArg('--project') || await resolveProjectSlug(null)
2645
2648
  const baseUrl = getBaseUrl()
2646
- const token = getStoredAuthTokenForBaseUrl(baseUrl)
2649
+ // Uniform resolution (ALI-1090): honors ORIZU_TOKEN / ORIZU_TOKEN_FILE before
2650
+ // credentials.json so hosted sandboxes can run optimizations. Read at spawn
2651
+ // time — the child gets the freshest bearer available at launch.
2652
+ const token = resolveAuthTokenForBaseUrl(baseUrl)
2647
2653
  const python = getArg('--python') || process.env.PYTHON || 'python3'
2648
2654
  const bundledPythonPath = bundledOrizuGepaPythonPath()
2649
2655
  let forwardedArgs = removeFlagWithValue(cliArgs.slice(2), '--python')
@@ -3552,7 +3558,8 @@ function findExecutable(name: string): string | null {
3552
3558
  function describeSetupAuthState(): { state: 'signed-in' | 'signed-out', baseUrl: string } {
3553
3559
  const baseUrl = getBaseUrl()
3554
3560
  return {
3555
- state: getServerCredentials(baseUrl) ? 'signed-in' : 'signed-out',
3561
+ // ALI-1090: an env bearer (ORIZU_TOKEN / ORIZU_TOKEN_FILE) is signed-in too.
3562
+ state: hasResolvableAuth(baseUrl) ? 'signed-in' : 'signed-out',
3556
3563
  baseUrl,
3557
3564
  }
3558
3565
  }
@@ -3,6 +3,11 @@
3
3
  * Pure command logic with an injected fetcher and project resolver; the entry
4
4
  * point owns argument parsing and human/JSON output. apply is idempotent on the
5
5
  * server — re-applying returns the stored outcome.
6
+ *
7
+ * Every id-taking subcommand accepts a short-id PREFIX (ALI-1038/ALI-1092): a
8
+ * leading hex slice of the manifest UUID is resolved CLI-side against the
9
+ * project's manifest list — unique match wins, none/ambiguous is a clear error,
10
+ * never a server 500.
6
11
  */
7
12
 
8
13
  import { authedFetch } from './http.js'
@@ -14,10 +19,28 @@ export interface ManifestsCommandIo {
14
19
  print: (line: string) => void
15
20
  fetcher?: CliFetcher
16
21
  resolveProjectSlug?: (arg: string | null) => Promise<string>
22
+ /** Injectable sleeper for the apply auto-retry loop (tests). */
23
+ sleep?: (ms: number) => Promise<void>
17
24
  }
18
25
 
19
26
  const MANIFEST_ACTIONS = new Set(['approve', 'reject', 'apply', 'revert'])
20
27
 
28
+ // -- Apply auto-retry (ALI-1084 D5) -------------------------------------------
29
+ // A repo_merge apply now runs a one-shot merge sandbox server-side; past the
30
+ // route's bounded wait it answers a RETRYABLE 409 "merge job in progress;
31
+ // re-run apply" — the re-apply ATTACHES to the running job (never restarts
32
+ // it), so retrying is free and idempotent. The CLI absorbs that contract with
33
+ // a bounded auto-retry loop (~2 min total) and clear progress output.
34
+
35
+ const APPLY_RETRY_BUDGET_MS = 120_000
36
+ const APPLY_RETRY_INTERVAL_MS = 5_000
37
+ const MERGE_IN_PROGRESS_MARKER = 'merge job in progress'
38
+
39
+ async function isMergeJobInProgress(response: Response): Promise<boolean> {
40
+ if (response.status !== 409) return false
41
+ return (await responseMessage(response)).toLowerCase().includes(MERGE_IN_PROGRESS_MARKER)
42
+ }
43
+
21
44
  async function responseMessage(response: Response): Promise<string> {
22
45
  try {
23
46
  const data = (await response.clone().json()) as Record<string, unknown>
@@ -74,6 +97,50 @@ async function requireProjectSlug(io: ManifestsCommandIo, args: string[]): Promi
74
97
  return io.resolveProjectSlug(argValue(args, '--project'))
75
98
  }
76
99
 
100
+ // -- short-id prefix resolution (ALI-1092; `show` parity with approve/reject) --
101
+
102
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
103
+ // A candidate short id: a leading hex slice of a UUID (dashes allowed), shorter
104
+ // than the full 36 chars. Anything else (full UUIDs, non-hex ids in tests/fixtures)
105
+ // is passed to the server untouched.
106
+ const UUID_PREFIX_RE = /^[0-9a-f][0-9a-f-]{0,34}$/i
107
+
108
+ /**
109
+ * Resolve a manifest id argument shared by show/approve/reject/apply/revert.
110
+ * Full UUIDs (and non-prefix-shaped ids) pass through. A short-id prefix is
111
+ * matched against the project's manifest list: exactly one hit resolves; zero
112
+ * or multiple hits throw a clean, actionable error instead of letting a
113
+ * non-UUID id reach the server and 500.
114
+ */
115
+ async function resolveManifestId(io: ManifestsCommandIo, args: string[], id: string): Promise<string> {
116
+ if (UUID_RE.test(id) || !UUID_PREFIX_RE.test(id)) {
117
+ return id
118
+ }
119
+
120
+ const projectSlug = await requireProjectSlug(io, args)
121
+ const response = await fetcherFrom(io)(
122
+ `/api/cli/promotion-manifests?project=${encodeURIComponent(projectSlug)}`
123
+ )
124
+ const data = await requireOk(response, 'Manifest lookup')
125
+ const manifests = Array.isArray(data.manifests) ? (data.manifests as Record<string, unknown>[]) : []
126
+ const needle = id.toLowerCase()
127
+ const matches = manifests
128
+ .map(manifest => (typeof manifest.id === 'string' ? manifest.id : ''))
129
+ .filter(candidate => candidate.toLowerCase().startsWith(needle))
130
+
131
+ if (matches.length === 1) {
132
+ return matches[0]
133
+ }
134
+ if (matches.length === 0) {
135
+ throw new Error(
136
+ `No manifest matching '${id}' found in ${projectSlug}. Run \`orizu manifests list\` to see ids.`
137
+ )
138
+ }
139
+ throw new Error(
140
+ `Manifest id '${id}' is ambiguous — it matches ${matches.length} manifests (${matches.join(', ')}). Use a longer prefix.`
141
+ )
142
+ }
143
+
77
144
  function emit(io: ManifestsCommandIo, payload: Record<string, unknown>, human: string) {
78
145
  io.print(io.json ? JSON.stringify(payload) : human)
79
146
  }
@@ -222,10 +289,11 @@ export async function manifestsCommand(args: string[], io: ManifestsCommandIo):
222
289
  }
223
290
 
224
291
  if (subcommand === 'show') {
225
- const id = positional[1]
226
- if (!id) {
292
+ const idArg = positional[1]
293
+ if (!idArg) {
227
294
  throw new Error('Usage: orizu manifests show <id> [--json]')
228
295
  }
296
+ const id = await resolveManifestId(io, args, idArg)
229
297
  const response = await fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`)
230
298
  const data = await requireOk(response, 'Manifest show')
231
299
  const manifest = (data.manifest ?? {}) as Record<string, unknown>
@@ -237,15 +305,40 @@ export async function manifestsCommand(args: string[], io: ManifestsCommandIo):
237
305
  }
238
306
 
239
307
  if (subcommand && MANIFEST_ACTIONS.has(subcommand)) {
240
- const id = positional[1]
241
- if (!id) {
308
+ const idArg = positional[1]
309
+ if (!idArg) {
242
310
  throw new Error(`Usage: orizu manifests ${subcommand} <id> [--json]`)
243
311
  }
244
- const response = await fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`, {
245
- method: 'POST',
246
- headers: { 'Content-Type': 'application/json' },
247
- body: JSON.stringify({ action: subcommand }),
248
- })
312
+ const id = await resolveManifestId(io, args, idArg)
313
+ const postAction = () =>
314
+ fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`, {
315
+ method: 'POST',
316
+ headers: { 'Content-Type': 'application/json' },
317
+ body: JSON.stringify({ action: subcommand }),
318
+ })
319
+ let response = await postAction()
320
+ if (subcommand === 'apply') {
321
+ // Bounded auto-retry for the in-flight merge job (ALI-1084 D5): apply
322
+ // is idempotent and a re-apply attaches to the SAME job, so retrying
323
+ // the documented "merge job in progress" 409 is always safe.
324
+ const sleep =
325
+ io.sleep ?? ((ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)))
326
+ const startedAt = Date.now()
327
+ let attempt = 1
328
+ while (
329
+ (await isMergeJobInProgress(response)) &&
330
+ Date.now() - startedAt + APPLY_RETRY_INTERVAL_MS <= APPLY_RETRY_BUDGET_MS
331
+ ) {
332
+ if (!io.json) {
333
+ io.print(
334
+ `merge job in progress (attempt ${attempt}); retrying apply in ${APPLY_RETRY_INTERVAL_MS / 1000}s…`
335
+ )
336
+ }
337
+ await sleep(APPLY_RETRY_INTERVAL_MS)
338
+ attempt += 1
339
+ response = await postAction()
340
+ }
341
+ }
249
342
  const data = await requireOk(response, `Manifest ${subcommand}`)
250
343
  const manifest = (data.manifest ?? {}) as Record<string, unknown>
251
344
  const lines = [manifestLine(manifest)]