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.
@@ -206,6 +206,43 @@ export function resolveEnvBearerToken() {
206
206
  }
207
207
  return null;
208
208
  }
209
+ /**
210
+ * THE uniform bearer resolution for every CLI command (ALI-1090). Exactly the
211
+ * order `authedFetch` uses: `ORIZU_TOKEN` env → `ORIZU_TOKEN_FILE` (both via
212
+ * resolveEnvBearerToken, read FRESH per call — never cached, so hosted token
213
+ * rotations are picked up) → stored credentials.json. Commands that need a raw
214
+ * bearer (`orizu env`, the run-gepa wrapper) MUST use this instead of reading
215
+ * credentials.json directly, or they break in hosted sandboxes that are
216
+ * pre-authenticated via ORIZU_TOKEN_FILE and never ran `orizu login`.
217
+ */
218
+ export function resolveAuthTokenForBaseUrl(baseUrl) {
219
+ const envBearer = resolveEnvBearerToken();
220
+ if (envBearer) {
221
+ return envBearer;
222
+ }
223
+ const credentials = getServerCredentials(baseUrl);
224
+ if (!credentials) {
225
+ throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`);
226
+ }
227
+ return 'accessToken' in credentials ? credentials.accessToken : credentials.apiKey;
228
+ }
229
+ /**
230
+ * Non-throwing "is any auth available?" check (login --no-prompt-if-logged-in,
231
+ * setup status). Mirrors resolveAuthTokenForBaseUrl's order so an env bearer
232
+ * counts as signed in. A misconfigured ORIZU_TOKEN_FILE reports false here —
233
+ * actual requests still fail loudly via resolveEnvBearerToken.
234
+ */
235
+ export function hasResolvableAuth(baseUrl) {
236
+ try {
237
+ if (resolveEnvBearerToken()) {
238
+ return true;
239
+ }
240
+ }
241
+ catch {
242
+ // fall through to stored credentials
243
+ }
244
+ return getServerCredentials(baseUrl) !== null;
245
+ }
209
246
  export function setActiveBaseUrl(baseUrl) {
210
247
  const config = loadCredentialsConfigForWrite();
211
248
  config.activeBaseUrl = baseUrl;
@@ -0,0 +1,258 @@
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
+ import { spawn } from 'node:child_process';
23
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
24
+ import { tmpdir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ /**
27
+ * ADR-007 §4 merge-strategy invariant: the seal merge must stay a COMMIT-
28
+ * PRESERVING merge. `--no-ff` keeps the session-branch head as a parent of the
29
+ * merge commit, so every create-time `commit_sha` pinned by a draft version
30
+ * row stays reachable on the default branch forever. A squash or rebase would
31
+ * rewrite/discard those commits and orphan the pins — "what the label says is
32
+ * live" could then dangle. These flags must never appear in the seal merge.
33
+ */
34
+ const FORBIDDEN_SEAL_MERGE_FLAGS = ['--squash', '--ff-only', '--ff', 'rebase'];
35
+ const REQUIRED_SEAL_MERGE_FLAG = '--no-ff';
36
+ /**
37
+ * Assert a git invocation is the commit-preserving seal merge ADR-007 §4
38
+ * requires. Called on the exact argv the merge runs with, so the invariant is
39
+ * enforced where the merge happens rather than by reviewer vigilance.
40
+ */
41
+ export function assertCommitPreservingMergeArgs(args) {
42
+ if (!args.includes('merge')) {
43
+ throw new Error('seal merge invariant: expected a git merge invocation');
44
+ }
45
+ if (!args.includes(REQUIRED_SEAL_MERGE_FLAG)) {
46
+ throw new Error(`seal merge invariant violated: ${REQUIRED_SEAL_MERGE_FLAG} is required (ADR-007 §4 — the merge must keep the session head as a parent)`);
47
+ }
48
+ for (const flag of FORBIDDEN_SEAL_MERGE_FLAGS) {
49
+ if (args.includes(flag)) {
50
+ throw new Error(`seal merge invariant violated: ${flag} would rewrite or drop session commits and orphan draft version pins (ADR-007 §4)`);
51
+ }
52
+ }
53
+ }
54
+ const LOCAL_BASE_REF = 'refs/heads/__orizu_merge_base';
55
+ const LOCAL_SOURCE_REF = 'refs/remotes/origin/__orizu_merge_source';
56
+ // GitHub's published SSH host keys (PINNED — never ssh-keyscan'd at runtime):
57
+ // https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints
58
+ // (canonically republished at https://api.github.com/meta `ssh_keys`).
59
+ const GITHUB_HOST_KEYS = [
60
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl',
61
+ 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=',
62
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=',
63
+ ];
64
+ // Both SSH endpoints GitHub serves git over: the standard port AND the
65
+ // SSH-over-the-HTTPS-port endpoint (docs.github.com "Using SSH over the
66
+ // HTTPS port") the merge sandbox uses — its egress firewall enforces at
67
+ // SNI/CIDR level and raw SSH on :22 was reset in the first live run
68
+ // (ALI-1084 live-fire, 2026-07-15). GitHub publishes ONE key set for both
69
+ // hosts; OpenSSH matches a non-22 port with the `[host]:port` known_hosts
70
+ // form, so the 443 endpoint needs its own lines. Pinning discipline is
71
+ // unchanged: same keys, one more host alias.
72
+ const GITHUB_KNOWN_HOSTS = GITHUB_HOST_KEYS.flatMap(key => [`github.com ${key}`, `[ssh.github.com]:443 ${key}`]).join('\n') + '\n';
73
+ /**
74
+ * Strip git's repo-context env vars from a child git's environment. Git
75
+ * EXPORTS these into hook subprocesses (from a linked worktree, GIT_DIR is an
76
+ * ABSOLUTE path into the shared .git), so any descendant git command that
77
+ * inherits the parent env operates on THAT repo instead of its own cwd —
78
+ * `git config` writes land in the shared .git/config and `git init` flips it
79
+ * to core.bare=true (both observed when the pre-push hook ran the test suite
80
+ * from a worktree). Every real-git spawn must build its env through this.
81
+ */
82
+ export function scrubGitRepoContextEnv(env = process.env) {
83
+ const scrubbed = { ...env };
84
+ delete scrubbed.GIT_DIR;
85
+ delete scrubbed.GIT_WORK_TREE;
86
+ delete scrubbed.GIT_INDEX_FILE;
87
+ delete scrubbed.GIT_OBJECT_DIRECTORY;
88
+ delete scrubbed.GIT_COMMON_DIR;
89
+ delete scrubbed.GIT_NAMESPACE;
90
+ return scrubbed;
91
+ }
92
+ // Exported: the ADR-007 P3 read path (lib/artifact-git-read.ts) runs its
93
+ // verified reads through the same spawn.
94
+ export function defaultRunGit(args, options) {
95
+ return new Promise((resolve, reject) => {
96
+ const child = spawn('git', args, {
97
+ cwd: options.cwd,
98
+ env: options.env,
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ });
101
+ let stdout = '';
102
+ let stderr = '';
103
+ child.stdout.setEncoding('utf8');
104
+ child.stderr.setEncoding('utf8');
105
+ child.stdout.on('data', chunk => { stdout += chunk; });
106
+ child.stderr.on('data', chunk => { stderr += chunk; });
107
+ child.on('error', reject);
108
+ child.on('close', code => resolve({ exitCode: code ?? 1, stdout, stderr }));
109
+ });
110
+ }
111
+ export function sshRemote(repoFullName) {
112
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoFullName)) {
113
+ throw new Error(`Invalid repo full name: ${repoFullName}`);
114
+ }
115
+ return `git@github.com:${repoFullName}.git`;
116
+ }
117
+ /**
118
+ * The SSH-over-the-HTTPS-port remote (GitHub-supported: `ssh.github.com:443`
119
+ * speaks the same SSH protocol, same auth, same host-key set). The MERGE core
120
+ * uses this endpoint because the merge sandbox's egress firewall enforces at
121
+ * the SNI/CIDR layer and reset raw SSH on :22 in the first live run
122
+ * (ALI-1084); :443 within GitHub's published git CIDRs is the compatible
123
+ * path. Equivalent from dev machines, so local/sandbox parity holds (D6).
124
+ * The push/read paths (D9/Phase 6) keep `sshRemote` untouched.
125
+ */
126
+ export function sshRemoteOverHttpsPort(repoFullName) {
127
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoFullName)) {
128
+ throw new Error(`Invalid repo full name: ${repoFullName}`);
129
+ }
130
+ return `ssh://git@ssh.github.com:443/${repoFullName}.git`;
131
+ }
132
+ function shellQuote(value) {
133
+ return `'${value.replace(/'/g, "'\\''")}'`;
134
+ }
135
+ /** Exported for `lib/git-ssh-merge.ts` (pushFilesWithDeployKey keeps living
136
+ * there — D9 scopes the push path as a follow-on, not part of the core). */
137
+ export function assertGitOk(result, operation) {
138
+ if (result.exitCode === 0)
139
+ return;
140
+ const detail = (result.stderr || result.stdout).trim();
141
+ throw new Error(detail ? `${operation} failed: ${detail}` : `${operation} failed`);
142
+ }
143
+ function looksLikeMergeConflict(result) {
144
+ const output = `${result.stdout}\n${result.stderr}`;
145
+ return /CONFLICT|Automatic merge failed|Merge conflict/i.test(output);
146
+ }
147
+ /**
148
+ * Run a callback inside a throwaway git worktree wired to the repo's merge
149
+ * deploy key (isolated SSH identity + pinned GitHub host keys). Exported for
150
+ * the ADR-007 read path (lib/artifact-git-read.ts), which reuses the same
151
+ * clone/pack infrastructure for bulk artifact reads.
152
+ */
153
+ export async function withDeployKeyGit(privateKeyPem, runGit, tempPrefix, callback) {
154
+ const tempRoot = await mkdtemp(join(tmpdir(), tempPrefix));
155
+ const dir = join(tempRoot, 'worktree');
156
+ const sshDir = join(tempRoot, 'ssh');
157
+ await mkdir(dir);
158
+ await mkdir(sshDir);
159
+ const keyPath = join(sshDir, 'merge_deploy_key');
160
+ const knownHostsPath = join(sshDir, 'known_hosts');
161
+ const env = {
162
+ // Scrubbed: a leaked GIT_DIR (git exports it into hooks) must never
163
+ // redirect this temp-worktree clone/merge onto another repo.
164
+ ...scrubGitRepoContextEnv(process.env),
165
+ GIT_TERMINAL_PROMPT: '0',
166
+ GIT_SSH_COMMAND: [
167
+ 'ssh',
168
+ '-i',
169
+ shellQuote(keyPath),
170
+ '-o',
171
+ 'IdentitiesOnly=yes',
172
+ '-o',
173
+ 'StrictHostKeyChecking=yes',
174
+ '-o',
175
+ shellQuote(`UserKnownHostsFile=${knownHostsPath}`),
176
+ ].join(' '),
177
+ };
178
+ const git = (args) => runGit(args, { cwd: dir, env });
179
+ const gitOk = async (args, operation) => {
180
+ const result = await git(args);
181
+ assertGitOk(result, operation);
182
+ return result;
183
+ };
184
+ try {
185
+ await writeFile(keyPath, privateKeyPem, { mode: 0o600 });
186
+ await chmod(keyPath, 0o600);
187
+ await writeFile(knownHostsPath, GITHUB_KNOWN_HOSTS, { mode: 0o600 });
188
+ return await callback({ dir, git, gitOk });
189
+ }
190
+ finally {
191
+ await rm(tempRoot, { recursive: true, force: true });
192
+ }
193
+ }
194
+ /**
195
+ * Merge a reviewed session/revert branch head into the default branch using the
196
+ * server-only merge deploy key. The source branch is fetched only so GitHub can
197
+ * prove the reviewed SHA is still the branch tip; the merge itself targets the
198
+ * expected SHA, preserving the manifest CAS guarantee.
199
+ */
200
+ export async function mergeBranchWithDeployKey(repoFullName, input, deps = {}) {
201
+ const runGit = deps.runGit ?? defaultRunGit;
202
+ return withDeployKeyGit(input.privateKeyPem, runGit, 'orizu-merge-', async ({ git, gitOk }) => {
203
+ await gitOk(['init'], 'git init');
204
+ // SSH over the HTTPS port (ALI-1084 live-fire fix): the merge sandbox's
205
+ // egress firewall reset raw SSH on github.com:22; ssh.github.com:443 is
206
+ // the GitHub-supported equivalent (same protocol, same pinned host keys).
207
+ await gitOk(['remote', 'add', 'origin', sshRemoteOverHttpsPort(repoFullName)], 'git remote add');
208
+ await gitOk(['fetch', '--no-tags', 'origin', `refs/heads/${input.base}:${LOCAL_BASE_REF}`], 'git fetch default branch');
209
+ await gitOk(['checkout', '-B', '__orizu_merge_base', LOCAL_BASE_REF], 'git checkout default branch');
210
+ await gitOk(['fetch', '--no-tags', 'origin', `refs/heads/${input.sourceBranch}:${LOCAL_SOURCE_REF}`], 'git fetch reviewed source branch');
211
+ const sourceHead = (await gitOk(['rev-parse', LOCAL_SOURCE_REF], 'git rev-parse source')).stdout.trim();
212
+ if (sourceHead !== input.expectedHeadSha) {
213
+ throw new Error('branch moved since manifest creation; re-finish the session');
214
+ }
215
+ const ancestor = await git(['merge-base', '--is-ancestor', input.expectedHeadSha, 'HEAD']);
216
+ if (ancestor.exitCode === 0) {
217
+ const head = (await gitOk(['rev-parse', 'HEAD'], 'git rev-parse default')).stdout.trim();
218
+ return { merged: false, sha: head, alreadyMerged: true, conflict: false, status: 204 };
219
+ }
220
+ if (ancestor.exitCode !== 1) {
221
+ assertGitOk(ancestor, 'git merge-base');
222
+ }
223
+ // ADR-007 §4: the seal is a commit-preserving --no-ff merge — never squash,
224
+ // never rebase — so every draft version's create-time commit_sha stays
225
+ // reachable once the branch reaches the default branch.
226
+ const mergeArgs = [
227
+ '-c',
228
+ 'user.name=Orizu',
229
+ '-c',
230
+ 'user.email=bot@orizu.ai',
231
+ 'merge',
232
+ '--no-ff',
233
+ '-m',
234
+ input.commitMessage,
235
+ input.expectedHeadSha,
236
+ ];
237
+ assertCommitPreservingMergeArgs(mergeArgs);
238
+ const merge = await git(mergeArgs);
239
+ if (merge.exitCode !== 0) {
240
+ if (looksLikeMergeConflict(merge)) {
241
+ return { merged: false, sha: null, alreadyMerged: false, conflict: true, status: 409 };
242
+ }
243
+ assertGitOk(merge, 'git merge');
244
+ }
245
+ const mergeSha = (await gitOk(['rev-parse', 'HEAD'], 'git rev-parse merge')).stdout.trim();
246
+ // D8 pre-push gate: the LAST check before anything reaches the default
247
+ // branch. A refused gate aborts with superseded — no push, no side effect
248
+ // (the temp worktree dies with this callback).
249
+ if (deps.prePushGate) {
250
+ const proceed = await deps.prePushGate();
251
+ if (!proceed) {
252
+ return { merged: false, sha: null, alreadyMerged: false, conflict: false, superseded: true, status: 409 };
253
+ }
254
+ }
255
+ await gitOk(['push', 'origin', `HEAD:refs/heads/${input.base}`], 'git push default branch');
256
+ return { merged: true, sha: mergeSha, alreadyMerged: false, conflict: false, status: 201 };
257
+ });
258
+ }
@@ -546,6 +546,25 @@ export async function runHostedBoot(opts) {
546
546
  bearerProvider: () => readFile(bearerFileAbs).trim(),
547
547
  });
548
548
  log(`hosted-loop finished: ${result.status}`);
549
+ // ALI-1064: the loop is TERMINAL — the auto-harvest (ALI-1036) and the
550
+ // run's terminal seal already ran INSIDE runHostedLoop, so it is safe to
551
+ // hand the session back. Report `complete` so the DO ends the workspace
552
+ // session server-side and stops/destroys the sandbox NOW instead of
553
+ // extending it until the 24h cap (live 2026-07-15: zombie boxes burned
554
+ // 2×CPU/4GiB for hours after their loops finished). This fires for a
555
+ // failed loop too — the run is terminal either way, and the loop already
556
+ // owns its own run-level terminal state (never a `failed` boot-status
557
+ // here; that would misreport a run failure as a bootstrap failure).
558
+ // Best-effort: the DO's duration/24h caps are the backstop.
559
+ await postBootStatus({
560
+ bootStatusUrl: env.bootStatusUrl,
561
+ bootSecret: env.bootSecret,
562
+ status: 'complete',
563
+ runId,
564
+ reason: result.error ? redactBootReason(result.error, env.bootSecret) : null,
565
+ fetchImpl,
566
+ log,
567
+ });
549
568
  return { ok: !result.error, runId, loopStatus: result.status, error: result.error };
550
569
  }
551
570
  finally {
@@ -42,6 +42,7 @@ import { createVercelProvider } from './vercel-sandbox-provider.js';
42
42
  import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js';
43
43
  import { hostedLoopCommand } from './hosted-loop.js';
44
44
  import { hostedBootCommand } from './hosted-boot.js';
45
+ import { mergeJobCommand } from './merge-job.js';
45
46
  import { authedFetch } from './http.js';
46
47
  import { resolveBaseUrl } from './http.js';
47
48
  import { getWorkspaceRoot } from './workspace.js';
@@ -699,7 +700,11 @@ export async function hostedCommand(args, io, deps = {}) {
699
700
  if (positional[1] === 'hosted-boot') {
700
701
  return hostedBootCommand(io);
701
702
  }
702
- io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot>');
703
+ // ALI-1084: the one-shot merge sandbox entrypoint (MergeJobCoordinator DO).
704
+ if (positional[1] === 'merge-job') {
705
+ return mergeJobCommand(io);
706
+ }
707
+ io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot | merge-job>');
703
708
  return 1;
704
709
  }
705
710
  const task = argVal(args, '--task');
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { homedir, tmpdir } from 'os';
10
10
  import { createInterface } from 'readline/promises';
11
11
  import { emitKeypressEvents } from 'readline';
12
12
  import { stdin as input, stdout as output } from 'process';
13
- import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js';
13
+ import { clearServerCredentials, getServerCredentials, hasResolvableAuth, resolveAuthTokenForBaseUrl, saveServerCredentials, } from './credentials.js';
14
14
  import { parseDatasetFile } from './file-parser.js';
15
15
  import { streamJsonlRowChunks } from './jsonl-stream.js';
16
16
  import { parseDatasetReference } from './dataset-download.js';
@@ -244,13 +244,6 @@ export async function parseJsonResponse(response, context) {
244
244
  function shellQuote(value) {
245
245
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
246
246
  }
247
- function getStoredAuthTokenForBaseUrl(baseUrl) {
248
- const credentials = getServerCredentials(baseUrl);
249
- if (!credentials) {
250
- throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`);
251
- }
252
- return 'accessToken' in credentials ? credentials.accessToken : credentials.apiKey;
253
- }
254
247
  function printPromptSummaries(items, emptyMessage) {
255
248
  if (items.length === 0) {
256
249
  printLine(emptyMessage);
@@ -735,7 +728,10 @@ function renderCliAuthBrowserPage(status) {
735
728
  async function login() {
736
729
  const baseUrl = hasArg('--no-prompt-if-logged-in') ? getBaseUrl() : resolveLoginBaseUrl();
737
730
  assertSecureTokenTransport(baseUrl);
738
- if (hasArg('--no-prompt-if-logged-in') && getServerCredentials(baseUrl)) {
731
+ // hasResolvableAuth (ALI-1090): an env bearer (ORIZU_TOKEN / ORIZU_TOKEN_FILE)
732
+ // counts as logged in — a pre-authenticated hosted sandbox must not fall into
733
+ // the interactive browser flow.
734
+ if (hasArg('--no-prompt-if-logged-in') && hasResolvableAuth(baseUrl)) {
739
735
  if (hasJsonFlag()) {
740
736
  printJson({ status: 'already-logged-in', server: baseUrl });
741
737
  return;
@@ -884,7 +880,10 @@ async function logout() {
884
880
  }
885
881
  async function printEnv() {
886
882
  const baseUrl = getBaseUrl();
887
- const token = getStoredAuthTokenForBaseUrl(baseUrl);
883
+ // Uniform resolution (ALI-1090): env bearer (ORIZU_TOKEN / ORIZU_TOKEN_FILE)
884
+ // wins over credentials.json, so `orizu env` works in hosted sandboxes that
885
+ // never ran `orizu login`.
886
+ const token = resolveAuthTokenForBaseUrl(baseUrl);
888
887
  const projectArg = getArg('--project');
889
888
  const project = projectArg ? await resolveProjectSelection(projectArg) : null;
890
889
  const projectId = project?.id || getArg('--project-id') || process.env.ORIZU_PROJECT_ID || '';
@@ -1916,7 +1915,10 @@ function bundledOrizuGepaPythonPath() {
1916
1915
  async function runGepaOptimization() {
1917
1916
  const project = getArg('--project') || await resolveProjectSlug(null);
1918
1917
  const baseUrl = getBaseUrl();
1919
- const token = getStoredAuthTokenForBaseUrl(baseUrl);
1918
+ // Uniform resolution (ALI-1090): honors ORIZU_TOKEN / ORIZU_TOKEN_FILE before
1919
+ // credentials.json so hosted sandboxes can run optimizations. Read at spawn
1920
+ // time — the child gets the freshest bearer available at launch.
1921
+ const token = resolveAuthTokenForBaseUrl(baseUrl);
1920
1922
  const python = getArg('--python') || process.env.PYTHON || 'python3';
1921
1923
  const bundledPythonPath = bundledOrizuGepaPythonPath();
1922
1924
  let forwardedArgs = removeFlagWithValue(cliArgs.slice(2), '--python');
@@ -2682,7 +2684,8 @@ function findExecutable(name) {
2682
2684
  function describeSetupAuthState() {
2683
2685
  const baseUrl = getBaseUrl();
2684
2686
  return {
2685
- state: getServerCredentials(baseUrl) ? 'signed-in' : 'signed-out',
2687
+ // ALI-1090: an env bearer (ORIZU_TOKEN / ORIZU_TOKEN_FILE) is signed-in too.
2688
+ state: hasResolvableAuth(baseUrl) ? 'signed-in' : 'signed-out',
2686
2689
  baseUrl,
2687
2690
  };
2688
2691
  }
@@ -3,9 +3,28 @@
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
  import { authedFetch } from './http.js';
8
13
  const MANIFEST_ACTIONS = new Set(['approve', 'reject', 'apply', 'revert']);
14
+ // -- Apply auto-retry (ALI-1084 D5) -------------------------------------------
15
+ // A repo_merge apply now runs a one-shot merge sandbox server-side; past the
16
+ // route's bounded wait it answers a RETRYABLE 409 "merge job in progress;
17
+ // re-run apply" — the re-apply ATTACHES to the running job (never restarts
18
+ // it), so retrying is free and idempotent. The CLI absorbs that contract with
19
+ // a bounded auto-retry loop (~2 min total) and clear progress output.
20
+ const APPLY_RETRY_BUDGET_MS = 120_000;
21
+ const APPLY_RETRY_INTERVAL_MS = 5_000;
22
+ const MERGE_IN_PROGRESS_MARKER = 'merge job in progress';
23
+ async function isMergeJobInProgress(response) {
24
+ if (response.status !== 409)
25
+ return false;
26
+ return (await responseMessage(response)).toLowerCase().includes(MERGE_IN_PROGRESS_MARKER);
27
+ }
9
28
  async function responseMessage(response) {
10
29
  try {
11
30
  const data = (await response.clone().json());
@@ -58,6 +77,39 @@ async function requireProjectSlug(io, args) {
58
77
  }
59
78
  return io.resolveProjectSlug(argValue(args, '--project'));
60
79
  }
80
+ // -- short-id prefix resolution (ALI-1092; `show` parity with approve/reject) --
81
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
82
+ // A candidate short id: a leading hex slice of a UUID (dashes allowed), shorter
83
+ // than the full 36 chars. Anything else (full UUIDs, non-hex ids in tests/fixtures)
84
+ // is passed to the server untouched.
85
+ const UUID_PREFIX_RE = /^[0-9a-f][0-9a-f-]{0,34}$/i;
86
+ /**
87
+ * Resolve a manifest id argument shared by show/approve/reject/apply/revert.
88
+ * Full UUIDs (and non-prefix-shaped ids) pass through. A short-id prefix is
89
+ * matched against the project's manifest list: exactly one hit resolves; zero
90
+ * or multiple hits throw a clean, actionable error instead of letting a
91
+ * non-UUID id reach the server and 500.
92
+ */
93
+ async function resolveManifestId(io, args, id) {
94
+ if (UUID_RE.test(id) || !UUID_PREFIX_RE.test(id)) {
95
+ return id;
96
+ }
97
+ const projectSlug = await requireProjectSlug(io, args);
98
+ const response = await fetcherFrom(io)(`/api/cli/promotion-manifests?project=${encodeURIComponent(projectSlug)}`);
99
+ const data = await requireOk(response, 'Manifest lookup');
100
+ const manifests = Array.isArray(data.manifests) ? data.manifests : [];
101
+ const needle = id.toLowerCase();
102
+ const matches = manifests
103
+ .map(manifest => (typeof manifest.id === 'string' ? manifest.id : ''))
104
+ .filter(candidate => candidate.toLowerCase().startsWith(needle));
105
+ if (matches.length === 1) {
106
+ return matches[0];
107
+ }
108
+ if (matches.length === 0) {
109
+ throw new Error(`No manifest matching '${id}' found in ${projectSlug}. Run \`orizu manifests list\` to see ids.`);
110
+ }
111
+ throw new Error(`Manifest id '${id}' is ambiguous — it matches ${matches.length} manifests (${matches.join(', ')}). Use a longer prefix.`);
112
+ }
61
113
  function emit(io, payload, human) {
62
114
  io.print(io.json ? JSON.stringify(payload) : human);
63
115
  }
@@ -192,10 +244,11 @@ export async function manifestsCommand(args, io) {
192
244
  return 0;
193
245
  }
194
246
  if (subcommand === 'show') {
195
- const id = positional[1];
196
- if (!id) {
247
+ const idArg = positional[1];
248
+ if (!idArg) {
197
249
  throw new Error('Usage: orizu manifests show <id> [--json]');
198
250
  }
251
+ const id = await resolveManifestId(io, args, idArg);
199
252
  const response = await fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`);
200
253
  const data = await requireOk(response, 'Manifest show');
201
254
  const manifest = (data.manifest ?? {});
@@ -206,15 +259,34 @@ export async function manifestsCommand(args, io) {
206
259
  return 0;
207
260
  }
208
261
  if (subcommand && MANIFEST_ACTIONS.has(subcommand)) {
209
- const id = positional[1];
210
- if (!id) {
262
+ const idArg = positional[1];
263
+ if (!idArg) {
211
264
  throw new Error(`Usage: orizu manifests ${subcommand} <id> [--json]`);
212
265
  }
213
- const response = await fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`, {
266
+ const id = await resolveManifestId(io, args, idArg);
267
+ const postAction = () => fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`, {
214
268
  method: 'POST',
215
269
  headers: { 'Content-Type': 'application/json' },
216
270
  body: JSON.stringify({ action: subcommand }),
217
271
  });
272
+ let response = await postAction();
273
+ if (subcommand === 'apply') {
274
+ // Bounded auto-retry for the in-flight merge job (ALI-1084 D5): apply
275
+ // is idempotent and a re-apply attaches to the SAME job, so retrying
276
+ // the documented "merge job in progress" 409 is always safe.
277
+ const sleep = io.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
278
+ const startedAt = Date.now();
279
+ let attempt = 1;
280
+ while ((await isMergeJobInProgress(response)) &&
281
+ Date.now() - startedAt + APPLY_RETRY_INTERVAL_MS <= APPLY_RETRY_BUDGET_MS) {
282
+ if (!io.json) {
283
+ io.print(`merge job in progress (attempt ${attempt}); retrying apply in ${APPLY_RETRY_INTERVAL_MS / 1000}s…`);
284
+ }
285
+ await sleep(APPLY_RETRY_INTERVAL_MS);
286
+ attempt += 1;
287
+ response = await postAction();
288
+ }
289
+ }
218
290
  const data = await requireOk(response, `Manifest ${subcommand}`);
219
291
  const manifest = (data.manifest ?? {});
220
292
  const lines = [manifestLine(manifest)];