orizu 0.5.13 → 0.5.15
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/github-setup.js +60 -6
- package/dist/help.js +15 -3
- package/dist/hosted-boot.js +22 -4
- package/dist/hosted-harness-opencode.js +144 -1
- package/dist/hosted-loop-lifecycle.js +20 -0
- package/dist/hosted-loop.js +96 -6
- package/dist/hosted-run-event-sink.js +91 -18
- package/dist/hosted-session-cli.js +133 -11
- package/dist/index.js +20 -7
- package/dist/merge-job.js +26 -3
- package/dist/option-validation.js +54 -0
- package/dist/workbench-cli.js +31 -5
- package/package.json +1 -1
- package/src/github-setup.ts +66 -7
- package/src/help.ts +15 -3
- package/src/hosted-boot.ts +26 -4
- package/src/hosted-harness-opencode.ts +204 -1
- package/src/hosted-loop-lifecycle.ts +21 -0
- package/src/hosted-loop.ts +116 -3
- package/src/hosted-run-event-sink.ts +89 -22
- package/src/hosted-session-cli.ts +144 -11
- package/src/index.ts +22 -6
- package/src/merge-job.ts +25 -6
- package/src/option-validation.ts +63 -0
- package/src/workbench-cli.ts +34 -5
- package/vendor/skills/orizu-cli/SKILL.md +2 -2
|
@@ -161,6 +161,27 @@ export class CriticalDeliveryError extends Error {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Raised when `finish()` itself is undeliverable (ALI-1065 finding 2): a
|
|
166
|
+
* CriticalDeliveryError thrown WHILE DELIVERING A TERMINAL TRANSITION, carrying
|
|
167
|
+
* the intended status + finish options so the caller can retry the delivery
|
|
168
|
+
* with the ORIGINAL outcome. Before this, the error surfaced as a bare
|
|
169
|
+
* CriticalDeliveryError and the loop's generic catch re-finished the run
|
|
170
|
+
* 'failed' — a ~1s transport blip on the SUCCESS terminal recorded a succeeded
|
|
171
|
+
* run as failed. Subclasses CriticalDeliveryError so existing classification
|
|
172
|
+
* (drop-vs-surface) is unchanged.
|
|
173
|
+
*/
|
|
174
|
+
export class TerminalDeliveryError extends CriticalDeliveryError {
|
|
175
|
+
readonly intendedStatus: TerminalStatus
|
|
176
|
+
readonly finishOptions: FinishOptions | undefined
|
|
177
|
+
constructor(message: string, intendedStatus: TerminalStatus, finishOptions?: FinishOptions) {
|
|
178
|
+
super(message)
|
|
179
|
+
this.name = 'TerminalDeliveryError'
|
|
180
|
+
this.intendedStatus = intendedStatus
|
|
181
|
+
this.finishOptions = finishOptions
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
/**
|
|
165
186
|
* Raised on a 403 from the RunAPI. Named for what a 403 actually is — NOT "the
|
|
166
187
|
* run is terminal" (that is 404/410). A 403 means the caller may no longer write
|
|
@@ -534,17 +555,31 @@ export function createRunEventSink(options: CreateRunEventSinkOptions): RunEvent
|
|
|
534
555
|
diag(`finish(${status}) called on an already-sealed sink — ignored (idempotent)`)
|
|
535
556
|
return
|
|
536
557
|
}
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
)
|
|
558
|
+
// ALI-1065 finding 2: a delivery failure ANYWHERE inside finish (the
|
|
559
|
+
// transcript tail is a critical append; the terminal PATCH is the
|
|
560
|
+
// transition itself) must carry the INTENDED status out to the caller.
|
|
561
|
+
// Without this, the loop's generic catch converted an undeliverable
|
|
562
|
+
// SUCCESS terminal into a recorded 'failed'. RunTerminalError/RunAuthError
|
|
563
|
+
// pass through unchanged — they are classifications, not delivery
|
|
564
|
+
// failures.
|
|
565
|
+
try {
|
|
566
|
+
// Pre-terminal appends (still writable): flush coalesced tokens, then the
|
|
567
|
+
// bounded transcript tail (the seal below refuses any later append).
|
|
568
|
+
await flushTokens()
|
|
569
|
+
if (opts?.transcript) {
|
|
570
|
+
await postEvent(
|
|
571
|
+
AGENT_TRANSCRIPT_EVENT_TYPE,
|
|
572
|
+
{ text: tail(opts.transcript, TRANSCRIPT_TAIL_MAX_CHARS), truncated: opts.transcript.length > TRANSCRIPT_TAIL_MAX_CHARS },
|
|
573
|
+
true
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
await patchTerminal(status, opts)
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (error instanceof CriticalDeliveryError && !(error instanceof TerminalDeliveryError)) {
|
|
579
|
+
throw new TerminalDeliveryError(error.message, status, opts)
|
|
580
|
+
}
|
|
581
|
+
throw error
|
|
546
582
|
}
|
|
547
|
-
await patchTerminal(status, opts)
|
|
548
583
|
}
|
|
549
584
|
|
|
550
585
|
return {
|
|
@@ -661,6 +696,39 @@ export async function drainHarnessToSink(
|
|
|
661
696
|
beforeFinishRan = true
|
|
662
697
|
if (opts.beforeFinish) await opts.beforeFinish()
|
|
663
698
|
}
|
|
699
|
+
// ALI-1065 finding 3: the server now rejects appends to a terminal run with
|
|
700
|
+
// 410 (the run was cancelled/finished out from under this writer). The sink
|
|
701
|
+
// seals itself and raises RunTerminalError; the drain must treat that as a
|
|
702
|
+
// clean server-side termination — stop consuming and report 'cancelled' —
|
|
703
|
+
// not crash the loop into a spurious re-finish (Orizu records win; the
|
|
704
|
+
// stored status is whatever the server committed). The hook still runs so
|
|
705
|
+
// partial work is harvested, but with a sealed sink its own appends no-op.
|
|
706
|
+
const handleServerTerminal = async (): Promise<TerminalStatus> => {
|
|
707
|
+
await runBeforeFinish()
|
|
708
|
+
return 'cancelled'
|
|
709
|
+
}
|
|
710
|
+
// finish() can ALSO surface RunTerminalError (its pre-terminal token flush /
|
|
711
|
+
// transcript append hits the 410 before the PATCH ever runs) — accept the
|
|
712
|
+
// server's termination the same way instead of crashing the drain. A finish
|
|
713
|
+
// whose PATCH meets an already-terminal run does NOT throw (patchTerminal
|
|
714
|
+
// seals on a terminal 409), so this wrapper changes nothing on that path.
|
|
715
|
+
//
|
|
716
|
+
// SEALED-BEFORE-FINISH: the beforeFinish hook's own best-effort appends
|
|
717
|
+
// (auto-harvest events) swallow errors — including the RunTerminalError of a
|
|
718
|
+
// 410 that SEALED the sink. finish() on a sealed sink is an idempotent no-op,
|
|
719
|
+
// so without this check the drain would report the intended status as
|
|
720
|
+
// delivered when in fact the server terminated the run out from under us.
|
|
721
|
+
const finishAccepting = async (status: TerminalStatus, finishOpts: FinishOptions): Promise<TerminalStatus> => {
|
|
722
|
+
await runBeforeFinish()
|
|
723
|
+
if (sink.sealed) return 'cancelled'
|
|
724
|
+
try {
|
|
725
|
+
await sink.finish(status, finishOpts)
|
|
726
|
+
return status
|
|
727
|
+
} catch (error) {
|
|
728
|
+
if (error instanceof RunTerminalError) return 'cancelled'
|
|
729
|
+
throw error
|
|
730
|
+
}
|
|
731
|
+
}
|
|
664
732
|
for await (const event of events) {
|
|
665
733
|
if (event.kind === 'execution_complete') {
|
|
666
734
|
// A prompt the caller/agent stopped is a cancellation, not a failure: the
|
|
@@ -668,40 +736,39 @@ export async function drainHarnessToSink(
|
|
|
668
736
|
// and stop()-initiated paths. A genuine failure (success:false, no abort
|
|
669
737
|
// flag) still lands 'failed'.
|
|
670
738
|
if (event.payload.aborted === true) {
|
|
671
|
-
|
|
672
|
-
await sink.finish('cancelled', {
|
|
739
|
+
return finishAccepting('cancelled', {
|
|
673
740
|
summary: opts.summary,
|
|
674
741
|
evidence: opts.evidence,
|
|
675
742
|
transcript: opts.transcript,
|
|
676
743
|
})
|
|
677
|
-
return 'cancelled'
|
|
678
744
|
}
|
|
679
745
|
const success = event.payload.success !== false
|
|
680
746
|
const status: TerminalStatus = success ? 'succeeded' : 'failed'
|
|
681
747
|
const summary = success
|
|
682
748
|
? opts.summary
|
|
683
749
|
: { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' }
|
|
684
|
-
|
|
685
|
-
await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript })
|
|
686
|
-
return status
|
|
750
|
+
return finishAccepting(status, { summary, evidence: opts.evidence, transcript: opts.transcript })
|
|
687
751
|
}
|
|
688
752
|
if (event.kind === 'error') {
|
|
689
|
-
|
|
690
|
-
await sink.finish('failed', {
|
|
753
|
+
return finishAccepting('failed', {
|
|
691
754
|
summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
|
|
692
755
|
evidence: opts.evidence,
|
|
693
756
|
transcript: opts.transcript,
|
|
694
757
|
})
|
|
695
|
-
return 'failed'
|
|
696
758
|
}
|
|
697
|
-
|
|
759
|
+
try {
|
|
760
|
+
await sink.append(event)
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error instanceof RunTerminalError) return handleServerTerminal()
|
|
763
|
+
throw error
|
|
764
|
+
}
|
|
698
765
|
}
|
|
699
766
|
// The harness contract guarantees a terminal event; reaching here means the
|
|
700
767
|
// stream ended abnormally. Fail closed so the run never dangles non-terminal.
|
|
701
768
|
await runBeforeFinish()
|
|
702
|
-
|
|
769
|
+
if (sink.sealed) return 'cancelled'
|
|
770
|
+
return finishAccepting('failed', {
|
|
703
771
|
summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
|
|
704
772
|
transcript: opts.transcript,
|
|
705
773
|
})
|
|
706
|
-
return 'failed'
|
|
707
774
|
}
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
* local-sim end-to-end test.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
import { readFileSync } from 'fs'
|
|
39
|
+
|
|
38
40
|
import {
|
|
39
41
|
createLocalSimProvider,
|
|
40
42
|
type SandboxEgressPolicy,
|
|
@@ -52,16 +54,19 @@ import { resumeRunEventSink } from './hosted-run-event-sink.js'
|
|
|
52
54
|
import { AGENT_GIT_IDENTITY, BEARER_BASENAME } from './hosted-runtime-assets.js'
|
|
53
55
|
import { createVercelProvider } from './vercel-sandbox-provider.js'
|
|
54
56
|
import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js'
|
|
55
|
-
import { hostedLoopCommand, type HostedLoopContext } from './hosted-loop.js'
|
|
57
|
+
import { DEFAULT_HOSTED_MODEL, hostedLoopCommand, type HostedLoopContext } from './hosted-loop.js'
|
|
56
58
|
import { hostedBootCommand } from './hosted-boot.js'
|
|
57
59
|
import { mergeJobCommand } from './merge-job.js'
|
|
58
60
|
import { authedFetch } from './http.js'
|
|
59
61
|
import { resolveBaseUrl } from './http.js'
|
|
62
|
+
import { findUnknownOption } from './option-validation.js'
|
|
60
63
|
import { getWorkspaceRoot } from './workspace.js'
|
|
61
64
|
import { attachedWorkspaceId } from './workspace-sync.js'
|
|
62
65
|
import { tailWorkbenchRun } from './workbench-cli.js'
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
// Default hosted model — SINGLE source of truth in hosted-loop-lifecycle.ts
|
|
68
|
+
// (ALI-1086); re-exported here so existing importers keep working.
|
|
69
|
+
export { DEFAULT_HOSTED_MODEL }
|
|
65
70
|
export const DEFAULT_DURATION_MINUTES = 60
|
|
66
71
|
export const MAX_DURATION_MINUTES = 24 * 60
|
|
67
72
|
export const DEFAULT_ROTATION_INTERVAL_MS = 55 * 60 * 1000
|
|
@@ -73,6 +78,13 @@ export const DEFAULT_AGENT_TOKEN_TTL_MINUTES = 60
|
|
|
73
78
|
* before declaring the detached launch a failure. */
|
|
74
79
|
export const DEFAULT_READINESS_TIMEOUT_MS = 30 * 1000
|
|
75
80
|
export const DEFAULT_READINESS_POLL_MS = 500
|
|
81
|
+
/** Must match the coordinator Worker's session-start request ceiling. */
|
|
82
|
+
export const MAX_HOSTED_START_BODY_BYTES = 128 * 1024
|
|
83
|
+
/** `workspace_sessions.id` and `workbench_runs.id` are PostgreSQL UUIDs. The
|
|
84
|
+
* server creates both before its coordinator poke, and the Worker injects the
|
|
85
|
+
* session id into the final DO body. A representative UUID makes the local
|
|
86
|
+
* projection byte-exact without creating either server-side row. */
|
|
87
|
+
const GENERATED_HOSTED_UUID_PLACEHOLDER = '00000000-0000-4000-8000-000000000000'
|
|
76
88
|
/** Non-secret placeholder OpenCode uses to form requests; the firewall proxy
|
|
77
89
|
* overrides the real Anthropic header (model-key brokering). NEVER a real key. */
|
|
78
90
|
export const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder'
|
|
@@ -785,6 +797,10 @@ export interface HostedCommandIo {
|
|
|
785
797
|
}
|
|
786
798
|
|
|
787
799
|
function argVal(args: readonly string[], flag: string): string | null {
|
|
800
|
+
const inlinePrefix = `${flag}=`
|
|
801
|
+
const inlineArg = args.find(arg => arg.startsWith(inlinePrefix))
|
|
802
|
+
if (inlineArg) return inlineArg.slice(inlinePrefix.length)
|
|
803
|
+
|
|
788
804
|
const index = args.indexOf(flag)
|
|
789
805
|
if (index === -1 || index + 1 >= args.length) return null
|
|
790
806
|
const value = args[index + 1]
|
|
@@ -792,7 +808,7 @@ function argVal(args: readonly string[], flag: string): string | null {
|
|
|
792
808
|
}
|
|
793
809
|
|
|
794
810
|
function hasFlag(args: readonly string[], flag: string): boolean {
|
|
795
|
-
return args.
|
|
811
|
+
return args.some(arg => arg === flag || arg.startsWith(`${flag}=`))
|
|
796
812
|
}
|
|
797
813
|
|
|
798
814
|
function numFlag(args: readonly string[], flag: string): number | undefined {
|
|
@@ -803,7 +819,7 @@ function numFlag(args: readonly string[], flag: string): number | undefined {
|
|
|
803
819
|
}
|
|
804
820
|
|
|
805
821
|
const HOSTED_USAGE =
|
|
806
|
-
'Usage: orizu session start --hosted --task "<prompt>" [--duration <min> (default 60, max 1440)] ' +
|
|
822
|
+
'Usage: orizu session start --hosted (--task "<prompt>" | --task-file <path>) [--duration <min> (default 60, max 1440)] ' +
|
|
807
823
|
'[--model <provider/model>] [--reasoning-effort <level>] [--project <team/project>] ' +
|
|
808
824
|
'[--title <title>] [--tail]\n' +
|
|
809
825
|
'Default: the Orizu server provisions the sandbox (no VERCEL_* or model key needed on your machine); ' +
|
|
@@ -814,6 +830,42 @@ const HOSTED_USAGE =
|
|
|
814
830
|
'Note (v0, --operator only): if you disconnect, the agent token expires within its TTL (<=60m) and the run ' +
|
|
815
831
|
'finishes cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).'
|
|
816
832
|
|
|
833
|
+
const HOSTED_START_OPTIONS = new Set([
|
|
834
|
+
'--hosted',
|
|
835
|
+
'--task',
|
|
836
|
+
'--task-file',
|
|
837
|
+
'--workspace',
|
|
838
|
+
'--duration',
|
|
839
|
+
'--model',
|
|
840
|
+
'--reasoning-effort',
|
|
841
|
+
'--project',
|
|
842
|
+
'--title',
|
|
843
|
+
'--tail',
|
|
844
|
+
'--operator',
|
|
845
|
+
'--provider',
|
|
846
|
+
'--runtime',
|
|
847
|
+
'--image',
|
|
848
|
+
'--snapshot',
|
|
849
|
+
'--model-key',
|
|
850
|
+
'--json',
|
|
851
|
+
])
|
|
852
|
+
|
|
853
|
+
const HOSTED_START_VALUE_OPTIONS = new Set([
|
|
854
|
+
'--task',
|
|
855
|
+
'--task-file',
|
|
856
|
+
'--workspace',
|
|
857
|
+
'--duration',
|
|
858
|
+
'--model',
|
|
859
|
+
'--reasoning-effort',
|
|
860
|
+
'--project',
|
|
861
|
+
'--title',
|
|
862
|
+
'--provider',
|
|
863
|
+
'--runtime',
|
|
864
|
+
'--image',
|
|
865
|
+
'--snapshot',
|
|
866
|
+
'--model-key',
|
|
867
|
+
])
|
|
868
|
+
|
|
817
869
|
/** Printed on EVERY `--operator` use (founder-locked: deprecate NOW, remove
|
|
818
870
|
* after Phase 6 validation). Loud on purpose. */
|
|
819
871
|
export const OPERATOR_DEPRECATION_WARNING =
|
|
@@ -834,21 +886,65 @@ export async function startHostedViaServer(
|
|
|
834
886
|
io: HostedCommandIo,
|
|
835
887
|
fetcher: HostFetcher = (path, init) => authedFetch(path, init)
|
|
836
888
|
): Promise<number> {
|
|
889
|
+
const durationMinutes = numFlag(args, '--duration')
|
|
890
|
+
const model = argVal(args, '--model') ?? undefined
|
|
891
|
+
const reasoningEffort = argVal(args, '--reasoning-effort') ?? undefined
|
|
892
|
+
const projectSlug = argVal(args, '--project') ?? undefined
|
|
893
|
+
const title = argVal(args, '--title') ?? undefined
|
|
837
894
|
const payload: Record<string, unknown> = {
|
|
838
895
|
workspaceId,
|
|
839
896
|
task,
|
|
840
|
-
durationMinutes
|
|
841
|
-
model
|
|
842
|
-
reasoningEffort
|
|
843
|
-
projectSlug
|
|
844
|
-
title
|
|
897
|
+
durationMinutes,
|
|
898
|
+
model,
|
|
899
|
+
reasoningEffort,
|
|
900
|
+
projectSlug,
|
|
901
|
+
title,
|
|
902
|
+
}
|
|
903
|
+
const serializedPayload = JSON.stringify(payload)
|
|
904
|
+
const payloadBytes = new TextEncoder().encode(serializedPayload).byteLength
|
|
905
|
+
if (payloadBytes > MAX_HOSTED_START_BODY_BYTES) {
|
|
906
|
+
const message =
|
|
907
|
+
`Hosted task prompt is too large: request body is ${payloadBytes} bytes; ` +
|
|
908
|
+
`maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`
|
|
909
|
+
if (io.json) io.print(JSON.stringify({ ok: false, error: message }))
|
|
910
|
+
else io.printErr?.(message)
|
|
911
|
+
return 1
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Mirror the exact JSON shape that reaches the session Durable Object after
|
|
915
|
+
// the API creates its UUID run/session rows, normalizes optional strings, and
|
|
916
|
+
// the Worker injects sessionId. This second guard keeps near-boundary prompts
|
|
917
|
+
// from triggering server-side state that would only be compensated after a
|
|
918
|
+
// coordinator 413. Keep this field list in sync with pokeCoordinatorStart in
|
|
919
|
+
// app/api/cli/hosted-sessions/route.ts.
|
|
920
|
+
const normalizedModel = model?.trim() || undefined
|
|
921
|
+
const normalizedReasoningEffort = reasoningEffort?.trim() || undefined
|
|
922
|
+
const coordinatorPayload = JSON.stringify({
|
|
923
|
+
runId: GENERATED_HOSTED_UUID_PLACEHOLDER,
|
|
924
|
+
task,
|
|
925
|
+
durationMinutes:
|
|
926
|
+
durationMinutes !== undefined && durationMinutes > 0
|
|
927
|
+
? durationMinutes
|
|
928
|
+
: undefined,
|
|
929
|
+
model: normalizedModel,
|
|
930
|
+
reasoningEffort: normalizedReasoningEffort,
|
|
931
|
+
sessionId: GENERATED_HOSTED_UUID_PLACEHOLDER,
|
|
932
|
+
})
|
|
933
|
+
const coordinatorPayloadBytes = new TextEncoder().encode(coordinatorPayload).byteLength
|
|
934
|
+
if (coordinatorPayloadBytes > MAX_HOSTED_START_BODY_BYTES) {
|
|
935
|
+
const message =
|
|
936
|
+
`Hosted task prompt is too large: coordinator request body would be ${coordinatorPayloadBytes} bytes ` +
|
|
937
|
+
`after generated run/session IDs; maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`
|
|
938
|
+
if (io.json) io.print(JSON.stringify({ ok: false, error: message }))
|
|
939
|
+
else io.printErr?.(message)
|
|
940
|
+
return 1
|
|
845
941
|
}
|
|
846
942
|
let response: Response
|
|
847
943
|
try {
|
|
848
944
|
response = await fetcher('/api/cli/hosted-sessions', {
|
|
849
945
|
method: 'POST',
|
|
850
946
|
headers: { 'Content-Type': 'application/json' },
|
|
851
|
-
body:
|
|
947
|
+
body: serializedPayload,
|
|
852
948
|
})
|
|
853
949
|
} catch (error) {
|
|
854
950
|
io.printErr?.(`hosted session request failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -935,7 +1031,44 @@ export async function hostedCommand(
|
|
|
935
1031
|
return 1
|
|
936
1032
|
}
|
|
937
1033
|
|
|
938
|
-
const
|
|
1034
|
+
const unknownOption = findUnknownOption(args, HOSTED_START_OPTIONS, HOSTED_START_VALUE_OPTIONS)
|
|
1035
|
+
if (unknownOption) {
|
|
1036
|
+
const followsTaskFlag = args.some(
|
|
1037
|
+
(arg, index) =>
|
|
1038
|
+
arg === '--task' &&
|
|
1039
|
+
(args[index + 1] === unknownOption || args[index + 1]?.startsWith(`${unknownOption}=`))
|
|
1040
|
+
)
|
|
1041
|
+
const taskValueHint = followsTaskFlag
|
|
1042
|
+
? '\nTask text beginning with a dash must use --task=<prompt> or --task-file <path>.'
|
|
1043
|
+
: ''
|
|
1044
|
+
io.printErr?.(`unknown option ${unknownOption}${taskValueHint}\n${HOSTED_USAGE}`)
|
|
1045
|
+
return 1
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const inlineTask = argVal(args, '--task')
|
|
1049
|
+
const taskFile = argVal(args, '--task-file')
|
|
1050
|
+
if (hasFlag(args, '--task') && hasFlag(args, '--task-file')) {
|
|
1051
|
+
io.printErr?.(`Use either --task or --task-file, not both.\n${HOSTED_USAGE}`)
|
|
1052
|
+
return 1
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
let task = inlineTask
|
|
1056
|
+
if (taskFile) {
|
|
1057
|
+
try {
|
|
1058
|
+
task = readFileSync(taskFile, 'utf8')
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
io.printErr?.(
|
|
1061
|
+
`Could not read task file '${taskFile}': ${error instanceof Error ? error.message : String(error)}`
|
|
1062
|
+
)
|
|
1063
|
+
return 1
|
|
1064
|
+
}
|
|
1065
|
+
if (task.trim().length === 0) {
|
|
1066
|
+
io.printErr?.(
|
|
1067
|
+
`Task file '${taskFile}' is empty or contains only whitespace. Add a task prompt and try again.`
|
|
1068
|
+
)
|
|
1069
|
+
return 1
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
939
1072
|
if (!task) {
|
|
940
1073
|
io.printErr?.(HOSTED_USAGE)
|
|
941
1074
|
return 1
|
package/src/index.ts
CHANGED
|
@@ -3607,14 +3607,30 @@ async function resolveSetupTeam(teamSlugArg: string | null, noInput: boolean, dr
|
|
|
3607
3607
|
return createTeamOnServer(name)
|
|
3608
3608
|
}
|
|
3609
3609
|
|
|
3610
|
-
async function resolveSetupProjects(team: Team): Promise<Project[]> {
|
|
3610
|
+
async function resolveSetupProjects(team: Team, noInput: boolean, dryRun: boolean): Promise<Project[]> {
|
|
3611
3611
|
const projects = await fetchProjects(team.slug)
|
|
3612
3612
|
|
|
3613
|
-
if (projects.length
|
|
3614
|
-
|
|
3613
|
+
if (projects.length > 0) {
|
|
3614
|
+
return projects
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
// ALI-1140: a just-created team (or a pre-existing empty one) has no projects
|
|
3618
|
+
// by definition — continue the wizard into creating the first project instead
|
|
3619
|
+
// of dead-ending on the path setup itself offered.
|
|
3620
|
+
if (noInput || dryRun) {
|
|
3621
|
+
throw new Error(
|
|
3622
|
+
`Team '${team.slug}' has no projects. Create one with \`orizu projects create --name <name> --team ${team.slug}\`, then re-run setup.`
|
|
3623
|
+
)
|
|
3615
3624
|
}
|
|
3616
3625
|
|
|
3617
|
-
|
|
3626
|
+
printLine(` Team '${sanitizeTerminalText(team.slug)}' has no projects yet. Let's create the first one.`)
|
|
3627
|
+
const name = await askText(' Project name?', '')
|
|
3628
|
+
if (!name) {
|
|
3629
|
+
throw new Error('Project name is required to create a project.')
|
|
3630
|
+
}
|
|
3631
|
+
const project = await createProjectOnServer(team.slug, name)
|
|
3632
|
+
printLine(` Created project ${sanitizeTerminalText(`${project.teamSlug}/${project.slug}`)}`)
|
|
3633
|
+
return [project]
|
|
3618
3634
|
}
|
|
3619
3635
|
|
|
3620
3636
|
function projectSeedsFromProjects(projects: Project[]): WorkspaceProjectSeed[] {
|
|
@@ -3757,13 +3773,13 @@ async function setupCommand() {
|
|
|
3757
3773
|
|
|
3758
3774
|
if (auth.state === 'signed-in' && !validateOnly) {
|
|
3759
3775
|
const team = await resolveSetupTeam(teamSlug, noInput, dryRun)
|
|
3760
|
-
const serverProjects = await resolveSetupProjects(team)
|
|
3776
|
+
const serverProjects = await resolveSetupProjects(team, noInput, dryRun)
|
|
3761
3777
|
teamSlug = team.slug
|
|
3762
3778
|
teamId = team.id
|
|
3763
3779
|
projects = projectSeedsFromProjects(serverProjects)
|
|
3764
3780
|
printLine(` ${formatProjectSetupProgress(projects.length)}`)
|
|
3765
3781
|
if (!hasArg('--local') && !noInput && !dryRun) { let link: GithubLinkResult | null = null; try { link = await runGithubLink(team.slug, { print: printLine, fetcher: authedFetch, openUrl: openInBrowser, confirm: (org) => askYesNo(` Connect this team to GitHub org '${org}'?`, true) }) } catch (error) { printLine(` GitHub link skipped: ${getErrorMessage(error)} Run \`orizu github link --team ${team.slug}\` to finish.`) }
|
|
3766
|
-
if (link) { try { hostedAttached = (await runInteractiveHostedSetup({ teamSlug: team.slug, targetDir: workspaceRoot, local: hasArg('--local'), activeInstallation: link.status === 'active' }, { print: printLine, fetcher: authedFetch })).attached } catch (error) { throw new Error(`Hosted setup failed: ${getErrorMessage(error)}
|
|
3782
|
+
if (link) { try { hostedAttached = (await runInteractiveHostedSetup({ teamSlug: team.slug, targetDir: workspaceRoot, local: hasArg('--local'), activeInstallation: link.status === 'active' }, { print: printLine, fetcher: authedFetch })).attached } catch (error) { throw new Error(`Hosted setup failed: ${getErrorMessage(error)} Re-running \`orizu setup\` is safe — provisioning adopts the existing workbench repo and resumes where it left off (or pass --local to scaffold a local-only workspace).`) } } } // hint must not blame local state (ALI-1143): provisioning failures are server-side, and both provisioning (adopt) and the clone step (skip-if-cloned, ALI-1069) are idempotent on re-run
|
|
3767
3783
|
} else if (!teamSlug && !noInput && !validateOnly) {
|
|
3768
3784
|
teamSlug = await askText(' Team slug?', 'local-team')
|
|
3769
3785
|
}
|
package/src/merge-job.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
type DeployKeyMergeResult,
|
|
45
45
|
type GitCommandRunner,
|
|
46
46
|
} from './deploy-key-merge-core.js'
|
|
47
|
+
import { assertSecureTokenTransport } from './http.js'
|
|
47
48
|
import { redactSecrets } from './secret-redaction.js'
|
|
48
49
|
|
|
49
50
|
export type MergeJobFetch = (url: string, init?: RequestInit) => Promise<Response>
|
|
@@ -67,7 +68,7 @@ export interface MergeJobEnv {
|
|
|
67
68
|
|
|
68
69
|
export function resolveMergeJobEnv(
|
|
69
70
|
env: Record<string, string | undefined>
|
|
70
|
-
): { ok: true; value: MergeJobEnv } | { ok: false; missing: string[] } {
|
|
71
|
+
): { ok: true; value: MergeJobEnv } | { ok: false; missing: string[]; invalid: string[] } {
|
|
71
72
|
const missing: string[] = []
|
|
72
73
|
const req = (name: string): string => {
|
|
73
74
|
const value = env[name]?.trim()
|
|
@@ -78,7 +79,25 @@ export function resolveMergeJobEnv(
|
|
|
78
79
|
const jobKey = req('ORIZU_MERGE_JOB_ID')
|
|
79
80
|
const coordinatorUrl = req('ORIZU_COORDINATOR_URL')
|
|
80
81
|
const baseUrl = req('ORIZU_BASE_URL')
|
|
81
|
-
if (missing.length > 0) return { ok: false, missing }
|
|
82
|
+
if (missing.length > 0) return { ok: false, missing, invalid: [] }
|
|
83
|
+
|
|
84
|
+
const origins = [
|
|
85
|
+
['ORIZU_COORDINATOR_URL', coordinatorUrl],
|
|
86
|
+
['ORIZU_BASE_URL', baseUrl],
|
|
87
|
+
] as const
|
|
88
|
+
const invalid = origins.flatMap(([name, value]) => {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(value)
|
|
91
|
+
// Userinfo is never accepted, including on loopback: rejecting it at the
|
|
92
|
+
// env boundary keeps configured credentials out of every retry/error path.
|
|
93
|
+
if (parsed.username || parsed.password) return [name]
|
|
94
|
+
assertSecureTokenTransport(value)
|
|
95
|
+
return []
|
|
96
|
+
} catch {
|
|
97
|
+
return [name]
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
if (invalid.length > 0) return { ok: false, missing: [], invalid }
|
|
82
101
|
return {
|
|
83
102
|
ok: true,
|
|
84
103
|
value: {
|
|
@@ -445,10 +464,10 @@ export interface MergeJobCommandIo {
|
|
|
445
464
|
export async function mergeJobCommand(io: MergeJobCommandIo): Promise<number> {
|
|
446
465
|
const resolved = resolveMergeJobEnv(process.env)
|
|
447
466
|
if (!resolved.ok) {
|
|
448
|
-
|
|
449
|
-
`
|
|
450
|
-
|
|
451
|
-
)
|
|
467
|
+
const detail = resolved.missing.length > 0
|
|
468
|
+
? `missing required env: ${resolved.missing.join(', ')}`
|
|
469
|
+
: `invalid or insecure origin env: ${resolved.invalid.join(', ')}`
|
|
470
|
+
io.printErr?.(`merge-job: ${detail} (the MergeJobCoordinator sandbox env contract)`)
|
|
452
471
|
return 1
|
|
453
472
|
}
|
|
454
473
|
const log = (line: string): void => io.printErr?.(`[merge-job] ${line}`)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function optionName(arg: string): string {
|
|
2
|
+
const equalsIndex = arg.indexOf('=')
|
|
3
|
+
return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function isNegativeNumericValue(arg: string): boolean {
|
|
7
|
+
return arg.length > 1 && !arg.startsWith('--') && Number.isFinite(Number(arg))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isUnambiguouslySingleDashValue(arg: string): boolean {
|
|
11
|
+
// A short option cannot contain whitespace immediately after its dash. This
|
|
12
|
+
// keeps Markdown-list prompts such as `--task "- fix the test"` available
|
|
13
|
+
// without letting a misspelled option such as `--task -taks` get swallowed.
|
|
14
|
+
return /^-\s/.test(arg) || isNegativeNumericValue(arg)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function followsValueOption(
|
|
18
|
+
args: readonly string[],
|
|
19
|
+
index: number,
|
|
20
|
+
valueOptions: ReadonlySet<string>
|
|
21
|
+
): boolean {
|
|
22
|
+
if (index === 0) return false
|
|
23
|
+
const previous = args[index - 1]
|
|
24
|
+
return previous.startsWith('--') && !previous.includes('=') && valueOptions.has(previous)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Return the first dash-prefixed option that is not in a command's explicit
|
|
29
|
+
* allowlist. An unambiguously value-like single-dash token immediately
|
|
30
|
+
* following a value-taking option is data: this keeps inputs such as
|
|
31
|
+
* `--duration -5` and `--task "- fix the test"` available without allowing a
|
|
32
|
+
* stray token or mistyped short option.
|
|
33
|
+
*
|
|
34
|
+
* `valueOptions` permits the unambiguous `--option=value` form. A separate
|
|
35
|
+
* token beginning with `--`, or an option-like single-dash word, is parsed as
|
|
36
|
+
* an option so a missing value cannot swallow a typo. Callers whose value is
|
|
37
|
+
* itself option-like can use the inline form; Markdown list markers and finite
|
|
38
|
+
* negative numbers remain safe as separate values.
|
|
39
|
+
*/
|
|
40
|
+
export function findUnknownOption(
|
|
41
|
+
args: readonly string[],
|
|
42
|
+
allowedOptions: ReadonlySet<string>,
|
|
43
|
+
valueOptions: ReadonlySet<string> = new Set()
|
|
44
|
+
): string | null {
|
|
45
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
46
|
+
const arg = args[index]
|
|
47
|
+
if (!arg.startsWith('-') || arg === '-') continue
|
|
48
|
+
if (!arg.startsWith('--')) {
|
|
49
|
+
if (isUnambiguouslySingleDashValue(arg) && followsValueOption(args, index, valueOptions)) continue
|
|
50
|
+
return arg
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const name = optionName(arg)
|
|
54
|
+
if (!allowedOptions.has(name)) return name
|
|
55
|
+
|
|
56
|
+
const hasInlineValue = arg.length > name.length
|
|
57
|
+
if (hasInlineValue) {
|
|
58
|
+
if (!valueOptions.has(name)) return arg
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
package/src/workbench-cli.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from 'child_process'
|
|
10
10
|
|
|
11
11
|
import { authedFetch } from './http.js'
|
|
12
|
+
import { findUnknownOption } from './option-validation.js'
|
|
12
13
|
import { getWorkspaceRoot, workspaceExists } from './workspace.js'
|
|
13
14
|
import { attachedWorkspaceId, stringOrNull } from './workspace-sync.js'
|
|
14
15
|
|
|
@@ -269,6 +270,19 @@ const DEFAULT_TAIL_INTERVAL_SECONDS = 2
|
|
|
269
270
|
// hot-spin against the API while a non-terminal run is quiet. Draining full
|
|
270
271
|
// pages intentionally skips the sleep — that is catch-up, not polling.
|
|
271
272
|
const MIN_TAIL_INTERVAL_MS = 500
|
|
273
|
+
const SESSION_START_OPTIONS = new Set(['--project', '--workspace', '--json'])
|
|
274
|
+
const WORKBENCH_VALUE_OPTIONS = new Set([
|
|
275
|
+
'--after',
|
|
276
|
+
'--interval',
|
|
277
|
+
'--message',
|
|
278
|
+
'--project',
|
|
279
|
+
'--run',
|
|
280
|
+
'--session',
|
|
281
|
+
'--status',
|
|
282
|
+
'--summary',
|
|
283
|
+
'--title',
|
|
284
|
+
'--workspace',
|
|
285
|
+
])
|
|
272
286
|
|
|
273
287
|
function defaultSleep(ms: number): Promise<void> {
|
|
274
288
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
@@ -774,11 +788,19 @@ function hasFlag(args: string[], flag: string): boolean {
|
|
|
774
788
|
}
|
|
775
789
|
|
|
776
790
|
function argValue(args: string[], flag: string): string | null {
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
791
|
+
const inlinePrefix = `${flag}=`
|
|
792
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
793
|
+
const arg = args[index]
|
|
794
|
+
if (arg.startsWith(inlinePrefix)) {
|
|
795
|
+
return arg.slice(inlinePrefix.length)
|
|
796
|
+
}
|
|
797
|
+
if (arg === flag) {
|
|
798
|
+
return index + 1 < args.length && !args[index + 1].startsWith('--')
|
|
799
|
+
? args[index + 1]
|
|
800
|
+
: null
|
|
801
|
+
}
|
|
780
802
|
}
|
|
781
|
-
return
|
|
803
|
+
return null
|
|
782
804
|
}
|
|
783
805
|
|
|
784
806
|
function numberArg(args: string[], flag: string): number | undefined {
|
|
@@ -795,7 +817,7 @@ function positionalArgs(args: string[]): string[] {
|
|
|
795
817
|
for (let i = 0; i < args.length; i += 1) {
|
|
796
818
|
const arg = args[i]
|
|
797
819
|
if (arg.startsWith('--')) {
|
|
798
|
-
if (argValue(args, arg) !== null) {
|
|
820
|
+
if (!arg.includes('=') && WORKBENCH_VALUE_OPTIONS.has(arg) && argValue(args.slice(i), arg) !== null) {
|
|
799
821
|
i += 1
|
|
800
822
|
}
|
|
801
823
|
continue
|
|
@@ -1017,6 +1039,13 @@ async function dispatchWorkbenchCommand(args: string[], io: WorkbenchCommandIo):
|
|
|
1017
1039
|
// not run from inside it (defaults to cwd).
|
|
1018
1040
|
const workspaceDir = argValue(args, '--workspace') ?? undefined
|
|
1019
1041
|
if (subcommand === 'start') {
|
|
1042
|
+
const unknownOption = findUnknownOption(args, SESSION_START_OPTIONS, WORKBENCH_VALUE_OPTIONS)
|
|
1043
|
+
if (unknownOption) {
|
|
1044
|
+
return emitJsonError(
|
|
1045
|
+
io,
|
|
1046
|
+
`unknown option ${unknownOption}\nUsage: orizu session start [--project <team/project>] [--workspace <dir>] [--json]`
|
|
1047
|
+
)
|
|
1048
|
+
}
|
|
1020
1049
|
const result = await runSessionStart({
|
|
1021
1050
|
fetcher,
|
|
1022
1051
|
cwd: workspaceDir,
|
|
@@ -199,5 +199,5 @@ Detailed walkthrough — GEPA mechanics, Orizu-tracked optimization, optional DS
|
|
|
199
199
|
|
|
200
200
|
- **Orizu is the source of truth for run metadata and pointers**: production/default prompt/scorer/runner pointers, version lineage, score runs, optimization runs, and report history all live in Orizu — query them with `orizu ...` commands (e.g. `orizu prompts list`, `orizu prompts versions`, `orizu runs ...`), never by reading repo files.
|
|
201
201
|
- **The repo is the working set (files only)**: source, configs, `App.tsx`, schemas, and local traces under `logs/<run-id>`. It is the scratch space you edit and commit; it does NOT hold the canonical pointer/run state — that is in Orizu.
|
|
202
|
-
- **Hosted sessions ship a pre-authenticated CLI**: inside a hosted agent sandbox the `orizu` CLI is already authenticated via `ORIZU_TOKEN_FILE` (with `ORIZU_BASE_URL` pointing at the API). You do NOT run `orizu login` — just call `orizu ...`. The bearer carries
|
|
203
|
-
- **Human-only actions will 403**: manifest
|
|
202
|
+
- **Hosted sessions ship a pre-authenticated CLI**: inside a hosted agent sandbox the `orizu` CLI is already authenticated via `ORIZU_TOKEN_FILE` (with `ORIZU_BASE_URL` pointing at the API). You do NOT run `orizu login` — just call `orizu ...`. The bearer carries **full team-curator authority while your session is active** (ADR-008): anything a curator-role human can read or write in your team — create/read/update projects, datasets (+ rows/artifact bytes), prompts, scorers, runners/optimizers, apps, tasks, runs, score runs, reports, and promotion-manifest proposals — you can too, across the whole team. (The full per-role access matrix lives in the Orizu repo at `docs/authorization-matrix.md`.)
|
|
203
|
+
- **Human-only actions will 403**: promotion-manifest **approval / apply / reject**, production and default pointer flips, session/credential lifecycle (starting or ending hosted sessions, minting tokens, deploy keys), integration/connector credential reads (secrets reach your sandbox only as environment variables resolved at boot — never via API/DB), and team/member/installation/billing management. Do not attempt them from a hosted session — they return 403 by design. Prepare a promotion manifest and hand it to a human curator to approve and apply.
|