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.
- package/dist/account-directory.d.ts +2 -2
- package/dist/account-directory.d.ts.map +1 -1
- package/dist/account-directory.js +7 -32
- package/dist/auth-commands.d.ts.map +1 -1
- package/dist/auth-commands.js +22 -30
- package/dist/credentials.d.ts +5 -0
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +84 -3
- package/dist/dataset-transfer-upload.d.ts.map +1 -1
- package/dist/dataset-transfer-upload.js +6 -1
- package/dist/directory-list-read.d.ts +37 -0
- package/dist/directory-list-read.d.ts.map +1 -0
- package/dist/directory-list-read.js +87 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +59 -41
- package/dist/hosted-harness-opencode.d.ts +31 -0
- package/dist/hosted-harness-opencode.d.ts.map +1 -1
- package/dist/hosted-harness-opencode.js +45 -7
- package/dist/hosted-loop.d.ts +39 -2
- package/dist/hosted-loop.d.ts.map +1 -1
- package/dist/hosted-loop.js +127 -18
- package/dist/hosted-session-seed.d.ts +2 -0
- package/dist/hosted-session-seed.d.ts.map +1 -1
- package/dist/hosted-session-seed.js +27 -10
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +39 -234
- package/dist/keyboard-select.d.ts.map +1 -1
- package/dist/keyboard-select.js +23 -45
- package/dist/project-create-workflow.d.ts +18 -0
- package/dist/project-create-workflow.d.ts.map +1 -0
- package/dist/project-create-workflow.js +121 -0
- package/dist/self-update-command.d.ts +8 -0
- package/dist/self-update-command.d.ts.map +1 -0
- package/dist/self-update-command.js +390 -0
- package/dist/self-update.d.ts +29 -16
- package/dist/self-update.d.ts.map +1 -1
- package/dist/self-update.js +55 -244
- package/dist/setup-account.d.ts.map +1 -1
- package/dist/setup-account.js +4 -1
- package/dist/setup-and-skills-commands.d.ts.map +1 -1
- package/dist/setup-and-skills-commands.js +88 -102
- package/dist/skill-install-workflow.d.ts +23 -0
- package/dist/skill-install-workflow.d.ts.map +1 -0
- package/dist/skill-install-workflow.js +184 -0
- package/dist/skill-installer.d.ts +8 -3
- package/dist/skill-installer.d.ts.map +1 -1
- package/dist/skill-installer.js +26 -33
- package/dist/stored-session-requests.d.ts +27 -0
- package/dist/stored-session-requests.d.ts.map +1 -0
- package/dist/stored-session-requests.js +264 -0
- package/dist/terminal-key-session.d.ts +23 -0
- package/dist/terminal-key-session.d.ts.map +1 -0
- package/dist/terminal-key-session.js +74 -0
- package/package.json +1 -1
- package/src/account-directory.ts +8 -42
- package/src/auth-commands.ts +20 -29
- package/src/credentials.ts +83 -3
- package/src/dataset-transfer-upload.ts +7 -1
- package/src/directory-list-read.ts +123 -0
- package/src/hosted-boot.ts +65 -43
- package/src/hosted-harness-opencode.ts +65 -9
- package/src/hosted-loop.ts +152 -18
- package/src/hosted-session-seed.ts +25 -8
- package/src/http.ts +39 -317
- package/src/keyboard-select.ts +18 -55
- package/src/project-create-workflow.ts +139 -0
- package/src/self-update-command.ts +419 -0
- package/src/self-update.ts +70 -266
- package/src/setup-account.ts +4 -1
- package/src/setup-and-skills-commands.ts +89 -121
- package/src/skill-install-workflow.ts +215 -0
- package/src/skill-installer.ts +31 -38
- package/src/stored-session-requests.ts +316 -0
- package/src/terminal-key-session.ts +88 -0
package/src/auth-commands.ts
CHANGED
|
@@ -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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/hosted-boot.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1280
|
+
const waitStartedAt = Date.now()
|
|
1236
1281
|
for (;;) {
|
|
1237
1282
|
if (opts.signal?.aborted) {
|
|
1238
|
-
|
|
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
|
|
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() -
|
|
1261
|
-
|
|
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:
|
|
1327
|
+
return { baseUrl, port, stop: started.stop, readyAfterMs: Date.now() - started.startedAt }
|
|
1272
1328
|
}
|