orizu 0.5.49 → 0.5.50

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 (74) hide show
  1. package/dist/account-directory.d.ts +2 -2
  2. package/dist/account-directory.d.ts.map +1 -1
  3. package/dist/account-directory.js +7 -32
  4. package/dist/auth-commands.d.ts.map +1 -1
  5. package/dist/auth-commands.js +22 -30
  6. package/dist/credentials.d.ts +5 -0
  7. package/dist/credentials.d.ts.map +1 -1
  8. package/dist/credentials.js +84 -3
  9. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  10. package/dist/dataset-transfer-upload.js +6 -1
  11. package/dist/directory-list-read.d.ts +37 -0
  12. package/dist/directory-list-read.d.ts.map +1 -0
  13. package/dist/directory-list-read.js +87 -0
  14. package/dist/hosted-boot.d.ts.map +1 -1
  15. package/dist/hosted-boot.js +59 -41
  16. package/dist/hosted-harness-opencode.d.ts +31 -0
  17. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  18. package/dist/hosted-harness-opencode.js +45 -7
  19. package/dist/hosted-loop.d.ts +39 -2
  20. package/dist/hosted-loop.d.ts.map +1 -1
  21. package/dist/hosted-loop.js +127 -18
  22. package/dist/hosted-session-seed.d.ts +2 -0
  23. package/dist/hosted-session-seed.d.ts.map +1 -1
  24. package/dist/hosted-session-seed.js +27 -10
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +39 -234
  27. package/dist/keyboard-select.d.ts.map +1 -1
  28. package/dist/keyboard-select.js +23 -45
  29. package/dist/project-create-workflow.d.ts +18 -0
  30. package/dist/project-create-workflow.d.ts.map +1 -0
  31. package/dist/project-create-workflow.js +121 -0
  32. package/dist/self-update-command.d.ts +8 -0
  33. package/dist/self-update-command.d.ts.map +1 -0
  34. package/dist/self-update-command.js +390 -0
  35. package/dist/self-update.d.ts +29 -16
  36. package/dist/self-update.d.ts.map +1 -1
  37. package/dist/self-update.js +55 -244
  38. package/dist/setup-account.d.ts.map +1 -1
  39. package/dist/setup-account.js +4 -1
  40. package/dist/setup-and-skills-commands.d.ts.map +1 -1
  41. package/dist/setup-and-skills-commands.js +88 -102
  42. package/dist/skill-install-workflow.d.ts +23 -0
  43. package/dist/skill-install-workflow.d.ts.map +1 -0
  44. package/dist/skill-install-workflow.js +184 -0
  45. package/dist/skill-installer.d.ts +8 -3
  46. package/dist/skill-installer.d.ts.map +1 -1
  47. package/dist/skill-installer.js +26 -33
  48. package/dist/stored-session-requests.d.ts +27 -0
  49. package/dist/stored-session-requests.d.ts.map +1 -0
  50. package/dist/stored-session-requests.js +264 -0
  51. package/dist/terminal-key-session.d.ts +23 -0
  52. package/dist/terminal-key-session.d.ts.map +1 -0
  53. package/dist/terminal-key-session.js +74 -0
  54. package/package.json +1 -1
  55. package/src/account-directory.ts +8 -42
  56. package/src/auth-commands.ts +20 -29
  57. package/src/credentials.ts +83 -3
  58. package/src/dataset-transfer-upload.ts +7 -1
  59. package/src/directory-list-read.ts +123 -0
  60. package/src/hosted-boot.ts +65 -43
  61. package/src/hosted-harness-opencode.ts +65 -9
  62. package/src/hosted-loop.ts +152 -18
  63. package/src/hosted-session-seed.ts +25 -8
  64. package/src/http.ts +39 -317
  65. package/src/keyboard-select.ts +18 -55
  66. package/src/project-create-workflow.ts +139 -0
  67. package/src/self-update-command.ts +419 -0
  68. package/src/self-update.ts +70 -266
  69. package/src/setup-account.ts +4 -1
  70. package/src/setup-and-skills-commands.ts +89 -121
  71. package/src/skill-install-workflow.ts +215 -0
  72. package/src/skill-installer.ts +31 -38
  73. package/src/stored-session-requests.ts +316 -0
  74. package/src/terminal-key-session.ts +88 -0
@@ -11,20 +11,18 @@ import {
11
11
  resolveLogoutTarget,
12
12
  assertSecureTokenTransport,
13
13
  authedFetch,
14
- credentialRequestRedirectPolicy,
15
14
  } from './http.js'
16
15
  import {
16
+ clearUnreadableServerCredentials,
17
17
  hasResolvableAuth,
18
18
  rememberProcessSecret,
19
19
  saveServerCredentials,
20
20
  getServerCredentials,
21
- clearServerCredentials,
22
21
  resolveAuthTokenForBaseUrl,
23
22
  } from './credentials.js'
24
23
  import { printJson, printLine, printLoginProgress } from './cli-console-output.js'
25
24
  import { shouldUseHeadlessLogin, waitForHeadlessAuthorization } from './headless-login.js'
26
25
  import { renderCliAuthBrowserPage } from './cli-auth-browser-page.js'
27
- import { describeLogoutHttpFailure, describeLogoutTransportFailure } from './logout-diagnostic.js'
28
26
  import { resolveProjectSelection } from './account-directory.js'
29
27
 
30
28
  import type { LoginResponse } from './types.js'
@@ -312,6 +310,15 @@ async function logout(baseUrlOverride?: string) {
312
310
  const { baseUrl, credentialBaseUrl } = resolveLogoutTarget(baseUrlOverride)
313
311
  const credentials = getServerCredentials(credentialBaseUrl)
314
312
  if (!credentials) {
313
+ // A saved sign-in this CLI cannot read is removed: it may be a pasted token (ORI-2675).
314
+ if (clearUnreadableServerCredentials(credentialBaseUrl)) {
315
+ if (hasJsonFlag()) {
316
+ printJson({ status: 'removed-unreadable', server: baseUrl })
317
+ return
318
+ }
319
+ printLine(`Removed the saved sign-in for ${sanitizeTerminalText(baseUrl)}, which this CLI could not read.`)
320
+ return
321
+ }
315
322
  if (hasJsonFlag()) {
316
323
  printJson({ status: 'already-logged-out', server: baseUrl })
317
324
  return
@@ -319,34 +326,18 @@ async function logout(baseUrlOverride?: string) {
319
326
  printLine(`Already logged out for ${sanitizeTerminalText(baseUrl)}.`)
320
327
  return
321
328
  }
322
- const authorizationToken = 'accessToken' in credentials
323
- ? credentials.accessToken
324
- : credentials.apiKey
325
- const logoutSecrets = [
326
- authorizationToken,
327
- ...('refreshToken' in credentials ? [credentials.refreshToken] : []),
328
- ]
329
- let remoteLogoutError: string | null = null
330
- try {
331
- assertSecureTokenTransport(baseUrl)
332
- const response = await fetch(`${baseUrl}/api/cli/auth/logout`, {
333
- method: 'POST',
334
- headers: {
335
- 'Content-Type': 'application/json',
336
- Authorization: `Bearer ${authorizationToken}`,
337
- },
338
- body: 'refreshToken' in credentials
339
- ? JSON.stringify({ refreshToken: credentials.refreshToken })
340
- : undefined,
341
- ...credentialRequestRedirectPolicy(),
342
- })
343
- if (!response.ok) {
344
- remoteLogoutError = await describeLogoutHttpFailure(response, logoutSecrets)
329
+ const { storedLogout } = await import('./stored-session-requests.js')
330
+ const { remoteError: remoteLogoutError, localCredentialCleared } = await storedLogout(baseUrl, credentialBaseUrl, credentials)
331
+ // Another login saved for this server while logout was out is kept.
332
+ if (!localCredentialCleared && getServerCredentials(credentialBaseUrl)) {
333
+ if (hasJsonFlag()) {
334
+ printJson({ status: 'changed', server: baseUrl, remoteLogoutError })
335
+ process.exitCode = 1
336
+ return
345
337
  }
346
- } catch (error) {
347
- remoteLogoutError = describeLogoutTransportFailure(error, logoutSecrets)
338
+ if (remoteLogoutError) console.warn(`Warning: remote logout failed: ${remoteLogoutError}`)
339
+ throw new Error(`ORIZU_CREDENTIALS_CHANGED: another login was saved for ${sanitizeTerminalText(baseUrl)} during logout; it was kept. Run orizu logout again to remove it.`)
348
340
  }
349
- clearServerCredentials(credentialBaseUrl)
350
341
  if (hasJsonFlag()) {
351
342
  printJson({ status: 'logged-out', server: baseUrl, remoteLogoutError })
352
343
  return
@@ -387,6 +387,30 @@ export function loadCredentialsConfig(): StoredCredentialsV2 | StoredCredentials
387
387
  return null
388
388
  }
389
389
 
390
+ function isNonEmptyString(value: unknown): value is string {
391
+ return typeof value === 'string' && value.length > 0
392
+ }
393
+
394
+ /**
395
+ * A saved sign-in this CLI can use: a session (it has an access token) or an
396
+ * API key. Plain code, not Effect Schema: this file is loaded by every
397
+ * command, including hosted sessions signed with ORIZU_TOKEN, which must never
398
+ * load Effect.
399
+ */
400
+ function isServerCredentials(value: unknown): value is ServerCredentials {
401
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
402
+ const entry = value as Record<string, unknown>
403
+ if ('accessToken' in entry) {
404
+ return isNonEmptyString(entry.accessToken)
405
+ && isNonEmptyString(entry.refreshToken)
406
+ && typeof entry.expiresAt === 'number'
407
+ && Number.isFinite(entry.expiresAt)
408
+ }
409
+ return isNonEmptyString(entry.apiKey)
410
+ }
411
+
412
+ const warnedUnreadableServers = new Set<string>()
413
+
390
414
  export function getServerCredentials(baseUrl: string): ServerCredentials | null {
391
415
  const config = loadCredentialsConfig()
392
416
  if (!config) {
@@ -397,7 +421,19 @@ export function getServerCredentials(baseUrl: string): ServerCredentials | null
397
421
  return null
398
422
  }
399
423
 
400
- return config.servers[baseUrl] || null
424
+ const entry: unknown = config.servers[baseUrl]
425
+ if (entry === null || entry === undefined) return null
426
+ if (!isServerCredentials(entry)) {
427
+ // Never the value: a hand-edited file may hold a token here (ORI-2675).
428
+ if (!warnedUnreadableServers.has(baseUrl)) {
429
+ warnedUnreadableServers.add(baseUrl)
430
+ console.warn(
431
+ `Warning: the saved sign-in for ${baseUrl} is not in a format this CLI understands. Run \`orizu login --server ${baseUrl}\` to replace it, or \`orizu logout --server ${baseUrl}\` to remove it.`
432
+ )
433
+ }
434
+ return null
435
+ }
436
+ return entry
401
437
  }
402
438
 
403
439
  export function saveServerCredentials(baseUrl: string, credentials: ServerCredentials) {
@@ -433,6 +469,8 @@ export function updateServerCredentials(baseUrl: string, credentials: ServerCred
433
469
  }
434
470
 
435
471
  export function credentialsEqual(left: ServerCredentials, right: ServerCredentials): boolean {
472
+ // Entries read straight from the file may be malformed; those never match.
473
+ if (!isServerCredentials(left) || !isServerCredentials(right)) return false
436
474
  if ('apiKey' in left || 'apiKey' in right) {
437
475
  return 'apiKey' in left && 'apiKey' in right && left.apiKey === right.apiKey
438
476
  }
@@ -457,9 +495,29 @@ export function updateServerCredentialsIfCurrent(
457
495
  })
458
496
  }
459
497
 
498
+ function isServerAddress(value: unknown): value is string {
499
+ if (typeof value !== 'string') return false
500
+ try {
501
+ normalizeBaseUrl(value)
502
+ return true
503
+ } catch {
504
+ return false
505
+ }
506
+ }
507
+
508
+ // Never the value: the "Invalid server URL" error would print it (ORI-2675).
509
+ // No fallback to the default server either: with an app.orizu.ai sign-in
510
+ // saved, that would run the command as a different account.
511
+ function unreadableActiveServerError(): Error {
512
+ return new Error('The saved server in credentials.json isn\'t a valid address. Run `orizu login --server <url>`.')
513
+ }
514
+
460
515
  export function getActiveBaseUrl(): string | null {
461
516
  const config = loadCredentialsConfig()
462
- return config?.activeBaseUrl || null
517
+ const active: unknown = config?.activeBaseUrl
518
+ if (active === null || active === undefined || active === '') return null
519
+ if (isServerAddress(active)) return active
520
+ throw unreadableActiveServerError()
463
521
  }
464
522
 
465
523
  /** Resolve the selected server and move a retired production credential atomically. */
@@ -476,7 +534,11 @@ export function resolveCredentialServer(selected?: string): string | null {
476
534
  return withCredentialsTransactionLock(() => {
477
535
  const config = loadCredentialsConfig()
478
536
  // Another process may have selected a different server while we waited.
479
- const currentSource = selected ?? config?.activeBaseUrl
537
+ const storedSource: unknown = config?.activeBaseUrl
538
+ if (!selected && storedSource !== null && storedSource !== undefined && storedSource !== '' && !isServerAddress(storedSource)) {
539
+ throw unreadableActiveServerError()
540
+ }
541
+ const currentSource = selected ?? (isServerAddress(storedSource) ? storedSource : null)
480
542
  if (!currentSource) return null
481
543
  const currentTarget = canonicalServerUrl(currentSource)
482
544
  if (!config || normalizeBaseUrl(currentSource) === currentTarget) return currentTarget
@@ -645,6 +707,24 @@ export function clearServerCredentials(baseUrl: string): boolean {
645
707
  })
646
708
  }
647
709
 
710
+ /**
711
+ * Remove a saved sign-in this CLI cannot read (it may be a pasted token).
712
+ * Returns false when there is none, or when it has become readable since.
713
+ */
714
+ export function clearUnreadableServerCredentials(baseUrl: string): boolean {
715
+ return withCredentialsTransactionLock(() => {
716
+ const config = loadCredentialsConfig()
717
+ if (!config || !Object.hasOwn(config.servers, baseUrl)) return false
718
+ const entry: unknown = config.servers[baseUrl]
719
+ if (entry === null || entry === undefined || isServerCredentials(entry)) return false
720
+
721
+ delete config.servers[baseUrl]
722
+ if (config.activeBaseUrl === baseUrl) config.activeBaseUrl = null
723
+ writeCredentials(config)
724
+ return true
725
+ })
726
+ }
727
+
648
728
  export function clearCredentialsFile() {
649
729
  return withCredentialsTransactionLock(() => {
650
730
  const path = getCredentialsPath()
@@ -52,6 +52,12 @@ function isAuthContextChange(cause: unknown) {
52
52
  return cause instanceof Error && cause.message.startsWith('ORIZU_AUTH_CONTEXT_CHANGED:')
53
53
  }
54
54
 
55
+ // authedFetch sets this when the server answered 401 and the sign-in refresh
56
+ // then failed, so the request was not sent again: nothing was written.
57
+ function isRequestRejected(cause: unknown) {
58
+ return cause instanceof Error && (cause as { requestRejected?: unknown }).requestRejected === true
59
+ }
60
+
55
61
  // Only the existing authentication client's rejected-token refresh may replay a
56
62
  // request. No transient/transport retries belong around these non-idempotent writes.
57
63
  function post<T>(path: string, body: unknown, context: string, prefix: string, timeoutMs: number): Effect.Effect<T, DatasetWriteFailure> {
@@ -69,7 +75,7 @@ function post<T>(path: string, body: unknown, context: string, prefix: string, t
69
75
  return await parseJsonResponse<T>(response, context)
70
76
  },
71
77
  catch: cause => cause instanceof DatasetWriteFailure ? cause : new DatasetWriteFailure(
72
- cause instanceof Error ? cause.message : String(cause), requestStarted && !isAuthContextChange(cause) ? 'unknown' : 'rejected', { cause },
78
+ cause instanceof Error ? cause.message : String(cause), requestStarted && !isAuthContextChange(cause) && !isRequestRejected(cause) ? 'unknown' : 'rejected', { cause },
73
79
  ),
74
80
  }).pipe(Effect.timeoutOrElse({
75
81
  duration: timeoutMs,
@@ -0,0 +1,123 @@
1
+ // The team and project lists that setup, `teams list` and `projects list`
2
+ // read. Plain TypeScript on purpose: hosted sessions list teams and projects
3
+ // with ORIZU_TOKEN or ORIZU_TOKEN_FILE set, and that path loads no Effect.
4
+ import { parseJsonResponse } from './json-response.js'
5
+
6
+ type ListRequest = (path: string, init: RequestInit) => Promise<Response>
7
+
8
+ interface ListShape<T> {
9
+ /** How the list is named in messages: "team list". */
10
+ what: string
11
+ /** The words before a refused reply's text, as before: "Failed to fetch teams". */
12
+ failed: string
13
+ /** The label for a reply that isn't JSON: "Teams list". */
14
+ label: string
15
+ /** The reply's field holding the list: "teams". */
16
+ field: string
17
+ /** The item with any missing optional field filled in, or null when the CLI can't use it. */
18
+ read: (value: unknown) => T | null
19
+ }
20
+
21
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
22
+ value !== null && typeof value === 'object' && !Array.isArray(value)
23
+
24
+ // Every command that names a project reads these lists, and none of them read
25
+ // ORIZU_DATASET_TRANSFER_TIMEOUT_MS before, so they get a fixed limit. Setup
26
+ // passes the setting, as its team and project creates already use it.
27
+ const DEFAULT_LIST_TIMEOUT_MS = 120_000
28
+
29
+ const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0
30
+
31
+ /** A field the CLI only shows: absent, null or a string. */
32
+ const isOptionalString = (value: unknown): value is string | null | undefined =>
33
+ value === undefined || value === null || typeof value === 'string'
34
+
35
+ export interface ListedTeam {
36
+ id: string
37
+ name: string
38
+ slug: string
39
+ role: string
40
+ }
41
+
42
+ export interface ListedProject {
43
+ id: string
44
+ name: string
45
+ slug: string
46
+ teamId: string
47
+ teamName: string
48
+ teamSlug: string
49
+ role: string
50
+ }
51
+
52
+ // The CLI needs a team's id and slug. Its name and role are only shown, and a
53
+ // missing role was already shown as "-", so those may be missing; any other
54
+ // field the server sends is kept as it came.
55
+ export const TEAM_LIST: ListShape<ListedTeam> = {
56
+ what: 'team list',
57
+ failed: 'Failed to fetch teams',
58
+ label: 'Teams list',
59
+ field: 'teams',
60
+ read: value => {
61
+ if (!isRecord(value) || !isNonEmptyString(value.id) || !isNonEmptyString(value.slug)) return null
62
+ if (!isOptionalString(value.name) || !isOptionalString(value.role)) return null
63
+ return { ...value, id: value.id, slug: value.slug, name: value.name ?? value.slug, role: value.role ?? '' }
64
+ },
65
+ }
66
+
67
+ // Setup files each project by its slug and records its id, under its team's
68
+ // slug, so those three must be there; the rest are only shown.
69
+ export const PROJECT_LIST: ListShape<ListedProject> = {
70
+ what: 'project list',
71
+ failed: 'Failed to fetch projects',
72
+ label: 'Projects list',
73
+ field: 'projects',
74
+ read: value => {
75
+ if (!isRecord(value) || !isNonEmptyString(value.id) || !isNonEmptyString(value.slug) || !isNonEmptyString(value.teamSlug)) return null
76
+ if (!isOptionalString(value.name) || !isOptionalString(value.teamId) || !isOptionalString(value.teamName) || !isOptionalString(value.role)) return null
77
+ return {
78
+ ...value,
79
+ id: value.id,
80
+ slug: value.slug,
81
+ teamSlug: value.teamSlug,
82
+ name: value.name ?? value.slug,
83
+ teamId: value.teamId ?? '',
84
+ teamName: value.teamName ?? value.teamSlug,
85
+ role: value.role ?? '',
86
+ }
87
+ },
88
+ }
89
+
90
+ /**
91
+ * One request with a time limit, its reply read under the same limit. A list
92
+ * Orizu can't read stops here, before any caller writes a file.
93
+ */
94
+ export async function readDirectoryList<T>(
95
+ request: ListRequest,
96
+ path: string,
97
+ shape: ListShape<T>,
98
+ timeoutMs = DEFAULT_LIST_TIMEOUT_MS,
99
+ ): Promise<T[]> {
100
+ const controller = new AbortController()
101
+ let timedOut = false
102
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
103
+ try {
104
+ const response = await request(path, { signal: controller.signal })
105
+ if (!response.ok) throw new Error(`${shape.failed}: ${await response.text()}`)
106
+ const data = await parseJsonResponse<unknown>(response, shape.label)
107
+ const list = isRecord(data) ? data[shape.field] : undefined
108
+ const unreadable = new Error(`The server sent a ${shape.what} Orizu can't read. Run \`orizu update\` and try again; if it still fails, the server may be having trouble.`)
109
+ if (!Array.isArray(list)) throw unreadable
110
+ const items: T[] = []
111
+ for (const value of list) {
112
+ const item = shape.read(value)
113
+ if (item === null) throw unreadable
114
+ items.push(item)
115
+ }
116
+ return items
117
+ } catch (error: unknown) {
118
+ if (timedOut) throw new Error(`Orizu didn't get the ${shape.what} within ${timeoutMs / 1000} s. Check your connection and try again.`)
119
+ throw error
120
+ } finally {
121
+ clearTimeout(timer)
122
+ }
123
+ }
@@ -50,6 +50,8 @@ import {
50
50
  import {
51
51
  INJECTED_ENV_VARS_ENV,
52
52
  closeHostedLoopSession,
53
+ hostedSessionOpenCodeLogPath,
54
+ prestartHostedOpenCode,
53
55
  runHostedLoop,
54
56
  runHostedLoopTurn,
55
57
  startHostedLoopSession,
@@ -973,16 +975,20 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
973
975
  // work and closed however the boot ends.
974
976
  const liveChannels: HostedLiveChannel[] = []
975
977
  const idleWait = new AbortController()
978
+ // ORI-2512: stops anything the boot started in the background.
979
+ const bootCleanups: Array<() => void> = []
976
980
  try {
977
- return await runHostedBootWithLive(opts, liveChannels, idleWait.signal)
981
+ return await runHostedBootWithLive(opts, liveChannels, idleWait.signal, bootCleanups)
978
982
  } finally {
979
983
  idleWait.abort()
980
984
  for (const channel of liveChannels) channel.close()
985
+ for (const cleanup of bootCleanups) cleanup()
981
986
  }
982
987
  }
983
988
 
984
989
  async function runHostedBootWithLive(
985
- opts: RunHostedBootOptions, liveChannels: HostedLiveChannel[], idleWaitSignal: AbortSignal
990
+ opts: RunHostedBootOptions, liveChannels: HostedLiveChannel[], idleWaitSignal: AbortSignal,
991
+ bootCleanups: Array<() => void>
986
992
  ): Promise<HostedBootResult> {
987
993
  const env = opts.env
988
994
  const processEnv = opts.processEnv ?? process.env
@@ -1223,6 +1229,46 @@ async function runHostedBootWithLive(
1223
1229
 
1224
1230
  const helperValue = `!node ${helperScriptAbs} ${bootContextAbs}`
1225
1231
 
1232
+ // The loop's context (step 6), built before the clone so the background
1233
+ // start below uses the same settings. The bearer PROVIDER keeps the event
1234
+ // sink's bearer fresh from the same pull-mode source; the DO-provisioned
1235
+ // sandbox always runs the pre-baked image under an enforced egress policy, so
1236
+ // the loop's startup egress canary + deferred setup hook are both armed.
1237
+ const model = session.model ?? DEFAULT_HOSTED_MODEL
1238
+ const configuredOpenCodePort = resolveTestPositiveInteger(
1239
+ processEnv, 'ORIZU_HOSTED_TEST_OPENCODE_PORT', 65_535
1240
+ ) ?? (processEnv.ORIZU_OPENCODE_PORT
1241
+ ? Number(processEnv.ORIZU_OPENCODE_PORT)
1242
+ : undefined)
1243
+ if (configuredOpenCodePort !== undefined && (!Number.isInteger(configuredOpenCodePort) || configuredOpenCodePort < 1 || configuredOpenCodePort > 65_535)) {
1244
+ throw new Error('ORIZU_OPENCODE_PORT must be a valid TCP port')
1245
+ }
1246
+ const loopContext: HostedLoopContext = {
1247
+ apiBaseUrl: env.baseUrl,
1248
+ runId,
1249
+ bearerFile: bearerFileAbs,
1250
+ taskFile: taskFileAbs,
1251
+ workspaceDir,
1252
+ sessionBranch: session.repoBranch,
1253
+ repositoryRemote: repo.cloneUrl,
1254
+ repositoryCredentialHelper: helperValue,
1255
+ model,
1256
+ reasoningEffort: session.reasoningEffort ?? undefined,
1257
+ sessionOrigin: session.surface === 'hosted-web' ? 'hosted-web' : 'cli',
1258
+ messageId: `${runId}:task`,
1259
+ resumeAgentSessionId: session.agentSessionId ?? undefined,
1260
+ author: AGENT_GIT_IDENTITY,
1261
+ anthropicDummyKey: env.anthropicDummyKey ?? ANTHROPIC_DUMMY_KEY,
1262
+ // Derive the per-prompt max-duration cap from the session duration so a long
1263
+ // run is not killed at the hard-coded 90-min prompt cap (ALI-1061). Unset →
1264
+ // the harness default (5400s) floor holds.
1265
+ sandboxBudgetMs: session.durationMinutes != null ? session.durationMinutes * 60 * 1000 : undefined,
1266
+ prebaked: true,
1267
+ egressCanaryHost: (processEnv.NODE_ENV === 'test' ? processEnv.ORIZU_HOSTED_TEST_EGRESS_CANARY_HOST : undefined) ?? DEFAULT_EGRESS_CANARY_HOST,
1268
+ runSetupHook: true,
1269
+ opencodePort: configuredOpenCodePort,
1270
+ }
1271
+
1226
1272
  // 4b — ORI-2118: a hosted session's Artifacts copy is created empty (forks
1227
1273
  // of a busy team copy cannot be cloned). Fill it from the server-selected
1228
1274
  // origin before cloning. Selected saved history is checked on retries too.
@@ -1231,6 +1277,20 @@ async function runHostedBootWithLive(
1231
1277
  }
1232
1278
  // ORI-2520: a woken sandbox boots on the disk it slept with (see step 5).
1233
1279
  const wokenDisk = existsSync(workspaceDir) && readdirSync(workspaceDir).length > 0
1280
+ // ORI-2512 cut 3: start the egress canary and then OpenCode while the
1281
+ // repository clones. OpenCode runs in the (empty) working folder, which the
1282
+ // clone fills in place, and is asked nothing until after the clone. The
1283
+ // one-shot test seam (opts.runLoop) starts nothing early, but makes the
1284
+ // folder the same way, so it takes production's fill.
1285
+ mkdirp(workspaceDir)
1286
+ const prestart = opts.runLoop
1287
+ ? null
1288
+ : prestartHostedOpenCode(
1289
+ { ...loopContext, projectId: session.projectId },
1290
+ { logPath: hostedSessionOpenCodeLogPath({ sessionDir: sessionDirAbs, sessionId: env.sessionId }) }
1291
+ )
1292
+ // However the boot ends, nothing it started outlives it.
1293
+ if (prestart) bootCleanups.push(prestart.stop)
1234
1294
  // Set when the working folder came from the team copy (ORI-2512); settles
1235
1295
  // when the background fill of the session copy ends, and never rejects.
1236
1296
  let sessionCopyFilled: Promise<void> | null = null
@@ -1355,7 +1415,8 @@ const c = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1355
1415
  const checkedOut = assertWokenRepo(exec, workspaceDir, repo.cloneUrl)
1356
1416
  log(`woken disk: reusing the repo (checked out: ${checkedOut})`)
1357
1417
  } else {
1358
- const clone = exec('git', [
1418
+ // Not blocking, so the background start above keeps running (ORI-2512).
1419
+ const clone = await execAsync('git', [
1359
1420
  'clone',
1360
1421
  '--depth',
1361
1422
  '1',
@@ -1430,45 +1491,6 @@ const c = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1430
1491
  log,
1431
1492
  })
1432
1493
 
1433
- // 6 — Launch the hosted loop, reused in-process. The bearer PROVIDER keeps the
1434
- // event sink's bearer fresh from the same pull-mode source; the DO-provisioned
1435
- // sandbox always runs the pre-baked image under an enforced egress policy, so
1436
- // the loop's startup egress canary + deferred setup hook are both armed.
1437
- const model = session.model ?? DEFAULT_HOSTED_MODEL
1438
- const configuredOpenCodePort = resolveTestPositiveInteger(
1439
- processEnv, 'ORIZU_HOSTED_TEST_OPENCODE_PORT', 65_535
1440
- ) ?? (processEnv.ORIZU_OPENCODE_PORT
1441
- ? Number(processEnv.ORIZU_OPENCODE_PORT)
1442
- : undefined)
1443
- if (configuredOpenCodePort !== undefined && (!Number.isInteger(configuredOpenCodePort) || configuredOpenCodePort < 1 || configuredOpenCodePort > 65_535)) {
1444
- throw new Error('ORIZU_OPENCODE_PORT must be a valid TCP port')
1445
- }
1446
- const loopContext: HostedLoopContext = {
1447
- apiBaseUrl: env.baseUrl,
1448
- runId,
1449
- bearerFile: bearerFileAbs,
1450
- taskFile: taskFileAbs,
1451
- workspaceDir,
1452
- sessionBranch: session.repoBranch,
1453
- repositoryRemote: repo.cloneUrl,
1454
- repositoryCredentialHelper: helperValue,
1455
- model,
1456
- reasoningEffort: session.reasoningEffort ?? undefined,
1457
- sessionOrigin: session.surface === 'hosted-web' ? 'hosted-web' : 'cli',
1458
- messageId: `${runId}:task`,
1459
- resumeAgentSessionId: session.agentSessionId ?? undefined,
1460
- author: AGENT_GIT_IDENTITY,
1461
- anthropicDummyKey: env.anthropicDummyKey ?? ANTHROPIC_DUMMY_KEY,
1462
- // Derive the per-prompt max-duration cap from the session duration so a long
1463
- // run is not killed at the hard-coded 90-min prompt cap (ALI-1061). Unset →
1464
- // the harness default (5400s) floor holds.
1465
- sandboxBudgetMs: session.durationMinutes != null ? session.durationMinutes * 60 * 1000 : undefined,
1466
- prebaked: true,
1467
- egressCanaryHost: DEFAULT_EGRESS_CANARY_HOST,
1468
- runSetupHook: true,
1469
- opencodePort: configuredOpenCodePort,
1470
- }
1471
-
1472
1494
  const runLoop =
1473
1495
  opts.runLoop ??
1474
1496
  ((input): Promise<HostedLoopResult> =>
@@ -1523,7 +1545,7 @@ const c = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1523
1545
  projectId: session.projectId,
1524
1546
  sessionDir: sessionDirAbs,
1525
1547
  }
1526
- const hostedLoopSession = await startHostedLoopSession(sessionContext)
1548
+ const hostedLoopSession = await startHostedLoopSession(sessionContext, { prestart })
1527
1549
  let current = session.pendingTurn?.runId === runId
1528
1550
  ? { runId, ordinal: session.pendingTurn.ordinal, body: promptForPendingHostedTurn(session.pendingTurn) }
1529
1551
  : { runId, ordinal: 1, body: session.task }
@@ -1150,7 +1150,7 @@ export interface SpawnOpenCodeOptions extends OpenCodeConfigOptions {
1150
1150
  spawn?: (
1151
1151
  cmd: string[],
1152
1152
  opts: { cwd?: string; env: Record<string, string>; logPath?: string }
1153
- ) => { kill: () => void }
1153
+ ) => { kill: () => void; hasExited?: () => boolean; exitReason?: () => string | null }
1154
1154
  /** File the spawner should append opencode's stdout/stderr to. On readiness
1155
1155
  * timeout its tail is included in the thrown error — the bare "fetch failed"
1156
1156
  * this replaces was undiagnosable (ALI-1034). */
@@ -1203,6 +1203,33 @@ function readLogTail(logPath: string | undefined): string | null {
1203
1203
  * (see the SSE-fragility note on that constant).
1204
1204
  */
1205
1205
  export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<SpawnedOpenCode> {
1206
+ return waitOpenCodeReady(await startOpenCodeProcess(opts), opts)
1207
+ }
1208
+
1209
+ /** A started `opencode serve` that has not been asked anything yet. */
1210
+ export interface StartedOpenCode {
1211
+ baseUrl: string
1212
+ port: number
1213
+ stop: () => void
1214
+ /** True once the process has exited (when the spawner can tell). */
1215
+ hasExited: () => boolean
1216
+ /** Why it could not be started (for example ENOENT), when known. */
1217
+ exitReason: () => string | null
1218
+ startedAt: number
1219
+ }
1220
+
1221
+ /** `waitOpenCodeReady` found the process already gone; nothing answered. */
1222
+ export class OpenCodeExitedError extends Error {}
1223
+
1224
+ /**
1225
+ * The first half of `spawnOpenCode`: refresh the model catalog and start the
1226
+ * process, without asking it anything. ORI-2512: the hosted boot calls this
1227
+ * while the repository is still cloning. OpenCode ties its folder to a project
1228
+ * on the first request it gets, so nothing may ask it anything until the
1229
+ * folder holds the repository (measured on 1.18.35: a request into an empty
1230
+ * folder binds it to the `global` project for good).
1231
+ */
1232
+ export async function startOpenCodeProcess(opts: SpawnOpenCodeOptions): Promise<StartedOpenCode> {
1206
1233
  const port = opts.port ?? DEFAULT_OPENCODE_PORT
1207
1234
  const env: Record<string, string> = {
1208
1235
  OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
@@ -1226,16 +1253,34 @@ export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<Spawned
1226
1253
  stderr: 'ignore',
1227
1254
  })
1228
1255
  })
1229
- const child = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath })
1230
- const baseUrl = `http://localhost:${port}`
1256
+ const child: { kill: () => void; hasExited?: () => boolean; exitReason?: () => string | null } = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath })
1257
+ return {
1258
+ baseUrl: `http://localhost:${port}`,
1259
+ port,
1260
+ stop: () => child.kill(),
1261
+ hasExited: () => child.hasExited?.() ?? false,
1262
+ exitReason: () => child.exitReason?.() ?? null,
1263
+ startedAt: Date.now(),
1264
+ }
1265
+ }
1231
1266
 
1267
+ /**
1268
+ * The second half of `spawnOpenCode`: poll until the started server answers.
1269
+ * The budget counts from this call, not from the start, so a long clone
1270
+ * before it cannot use the budget up. `readyAfterMs` is from the start.
1271
+ */
1272
+ export async function waitOpenCodeReady(
1273
+ started: StartedOpenCode,
1274
+ opts: Pick<SpawnOpenCodeOptions, 'readyTimeoutMs' | 'readyPollMs' | 'signal' | 'fetchImpl' | 'logPath'>
1275
+ ): Promise<SpawnedOpenCode> {
1276
+ const { baseUrl, port } = started
1232
1277
  const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as typeof fetch)
1233
1278
  const timeoutMs = opts.readyTimeoutMs ?? OPENCODE_READY_TIMEOUT_MS
1234
1279
  const pollMs = opts.readyPollMs ?? OPENCODE_READY_POLL_MS
1235
- const startedAt = Date.now()
1280
+ const waitStartedAt = Date.now()
1236
1281
  for (;;) {
1237
1282
  if (opts.signal?.aborted) {
1238
- child.kill()
1283
+ started.stop()
1239
1284
  throw new Error('opencode serve readiness wait aborted')
1240
1285
  }
1241
1286
  try {
@@ -1253,12 +1298,23 @@ export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<Spawned
1253
1298
  clearTimeout(timer)
1254
1299
  }
1255
1300
  } catch {
1256
- // not listening yet — fall through to the timeout check + sleep
1301
+ // not listening yet — fall through to the exit and timeout checks + sleep
1302
+ }
1303
+ // A process that already exited will never answer: say so now rather
1304
+ // than at the end of the budget.
1305
+ if (started.hasExited()) {
1306
+ const tail = readLogTail(opts.logPath)
1307
+ const reason = started.exitReason()
1308
+ throw new OpenCodeExitedError(
1309
+ `opencode serve exited before it answered on ${baseUrl}` +
1310
+ (reason ? `: ${reason}` : '') +
1311
+ (tail ? ` — log tail: ${tail}` : ' (no log output captured)')
1312
+ )
1257
1313
  }
1258
1314
  // Soft budget: checked between probes, so the total wait can overrun by up
1259
1315
  // to one probe timeout + poll interval. Negligible against the default.
1260
- if (Date.now() - startedAt >= timeoutMs) {
1261
- child.kill()
1316
+ if (Date.now() - waitStartedAt >= timeoutMs) {
1317
+ started.stop()
1262
1318
  const tail = readLogTail(opts.logPath)
1263
1319
  throw new Error(
1264
1320
  `opencode serve did not become ready on ${baseUrl} within ${timeoutMs}ms` +
@@ -1268,5 +1324,5 @@ export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<Spawned
1268
1324
  await new Promise(resolve => setTimeout(resolve, pollMs))
1269
1325
  }
1270
1326
 
1271
- return { baseUrl, port, stop: () => child.kill(), readyAfterMs: Date.now() - startedAt }
1327
+ return { baseUrl, port, stop: started.stop, readyAfterMs: Date.now() - started.startedAt }
1272
1328
  }