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.
@@ -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
- // Pre-terminal appends (still writable): flush coalesced tokens, then the
538
- // bounded transcript tail (the seal below refuses any later append).
539
- await flushTokens()
540
- if (opts?.transcript) {
541
- await postEvent(
542
- AGENT_TRANSCRIPT_EVENT_TYPE,
543
- { text: tail(opts.transcript, TRANSCRIPT_TAIL_MAX_CHARS), truncated: opts.transcript.length > TRANSCRIPT_TAIL_MAX_CHARS },
544
- true
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
- await runBeforeFinish()
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
- await runBeforeFinish()
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
- await runBeforeFinish()
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
- await sink.append(event)
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
- await sink.finish('failed', {
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
- export const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8'
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.includes(flag)
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: numFlag(args, '--duration'),
841
- model: argVal(args, '--model') ?? undefined,
842
- reasoningEffort: argVal(args, '--reasoning-effort') ?? undefined,
843
- projectSlug: argVal(args, '--project') ?? undefined,
844
- title: argVal(args, '--title') ?? undefined,
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: JSON.stringify(payload),
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 task = argVal(args, '--task')
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 === 0) {
3614
- throw new Error(`Team '${team.slug}' has no projects. Create a project before running setup.`)
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
- return projects
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)} Remove any partial clone in ${workspaceRoot} if one exists, then re-run \`orizu setup\` (or pass --local to scaffold a local-only workspace).`) } } }
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
- io.printErr?.(
449
- `merge-job: missing required env: ${resolved.missing.join(', ')} ` +
450
- '(the MergeJobCoordinator sandbox env contract)'
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
+ }
@@ -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 index = args.indexOf(flag)
778
- if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
779
- return null
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 args[index + 1]
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 a **team-curator READ scope**: you can read projects, datasets (+ content/artifact bytes), prompts, scorers, runners/optimizers, runs, and reports across your own team, and execute runs / submit scores / create artifacts / create promotion manifests as the agent actor.
203
- - **Human-only actions will 403**: manifest approve / reject / apply, production and default pointer flips, and team/member/billing/integration admin are deliberately outside the agent envelope. 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.
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.