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.
@@ -115,6 +115,26 @@ export class CriticalDeliveryError extends Error {
115
115
  this.name = 'CriticalDeliveryError';
116
116
  }
117
117
  }
118
+ /**
119
+ * Raised when `finish()` itself is undeliverable (ALI-1065 finding 2): a
120
+ * CriticalDeliveryError thrown WHILE DELIVERING A TERMINAL TRANSITION, carrying
121
+ * the intended status + finish options so the caller can retry the delivery
122
+ * with the ORIGINAL outcome. Before this, the error surfaced as a bare
123
+ * CriticalDeliveryError and the loop's generic catch re-finished the run
124
+ * 'failed' — a ~1s transport blip on the SUCCESS terminal recorded a succeeded
125
+ * run as failed. Subclasses CriticalDeliveryError so existing classification
126
+ * (drop-vs-surface) is unchanged.
127
+ */
128
+ export class TerminalDeliveryError extends CriticalDeliveryError {
129
+ intendedStatus;
130
+ finishOptions;
131
+ constructor(message, intendedStatus, finishOptions) {
132
+ super(message);
133
+ this.name = 'TerminalDeliveryError';
134
+ this.intendedStatus = intendedStatus;
135
+ this.finishOptions = finishOptions;
136
+ }
137
+ }
118
138
  /**
119
139
  * Raised on a 403 from the RunAPI. Named for what a 403 actually is — NOT "the
120
140
  * run is terminal" (that is 404/410). A 403 means the caller may no longer write
@@ -423,13 +443,28 @@ export function createRunEventSink(options) {
423
443
  diag(`finish(${status}) called on an already-sealed sink — ignored (idempotent)`);
424
444
  return;
425
445
  }
426
- // Pre-terminal appends (still writable): flush coalesced tokens, then the
427
- // bounded transcript tail (the seal below refuses any later append).
428
- await flushTokens();
429
- if (opts?.transcript) {
430
- await postEvent(AGENT_TRANSCRIPT_EVENT_TYPE, { text: tail(opts.transcript, TRANSCRIPT_TAIL_MAX_CHARS), truncated: opts.transcript.length > TRANSCRIPT_TAIL_MAX_CHARS }, true);
446
+ // ALI-1065 finding 2: a delivery failure ANYWHERE inside finish (the
447
+ // transcript tail is a critical append; the terminal PATCH is the
448
+ // transition itself) must carry the INTENDED status out to the caller.
449
+ // Without this, the loop's generic catch converted an undeliverable
450
+ // SUCCESS terminal into a recorded 'failed'. RunTerminalError/RunAuthError
451
+ // pass through unchanged — they are classifications, not delivery
452
+ // failures.
453
+ try {
454
+ // Pre-terminal appends (still writable): flush coalesced tokens, then the
455
+ // bounded transcript tail (the seal below refuses any later append).
456
+ await flushTokens();
457
+ if (opts?.transcript) {
458
+ await postEvent(AGENT_TRANSCRIPT_EVENT_TYPE, { text: tail(opts.transcript, TRANSCRIPT_TAIL_MAX_CHARS), truncated: opts.transcript.length > TRANSCRIPT_TAIL_MAX_CHARS }, true);
459
+ }
460
+ await patchTerminal(status, opts);
461
+ }
462
+ catch (error) {
463
+ if (error instanceof CriticalDeliveryError && !(error instanceof TerminalDeliveryError)) {
464
+ throw new TerminalDeliveryError(error.message, status, opts);
465
+ }
466
+ throw error;
431
467
  }
432
- await patchTerminal(status, opts);
433
468
  }
434
469
  return {
435
470
  append,
@@ -513,6 +548,42 @@ export async function drainHarnessToSink(events, sink, opts = {}) {
513
548
  if (opts.beforeFinish)
514
549
  await opts.beforeFinish();
515
550
  };
551
+ // ALI-1065 finding 3: the server now rejects appends to a terminal run with
552
+ // 410 (the run was cancelled/finished out from under this writer). The sink
553
+ // seals itself and raises RunTerminalError; the drain must treat that as a
554
+ // clean server-side termination — stop consuming and report 'cancelled' —
555
+ // not crash the loop into a spurious re-finish (Orizu records win; the
556
+ // stored status is whatever the server committed). The hook still runs so
557
+ // partial work is harvested, but with a sealed sink its own appends no-op.
558
+ const handleServerTerminal = async () => {
559
+ await runBeforeFinish();
560
+ return 'cancelled';
561
+ };
562
+ // finish() can ALSO surface RunTerminalError (its pre-terminal token flush /
563
+ // transcript append hits the 410 before the PATCH ever runs) — accept the
564
+ // server's termination the same way instead of crashing the drain. A finish
565
+ // whose PATCH meets an already-terminal run does NOT throw (patchTerminal
566
+ // seals on a terminal 409), so this wrapper changes nothing on that path.
567
+ //
568
+ // SEALED-BEFORE-FINISH: the beforeFinish hook's own best-effort appends
569
+ // (auto-harvest events) swallow errors — including the RunTerminalError of a
570
+ // 410 that SEALED the sink. finish() on a sealed sink is an idempotent no-op,
571
+ // so without this check the drain would report the intended status as
572
+ // delivered when in fact the server terminated the run out from under us.
573
+ const finishAccepting = async (status, finishOpts) => {
574
+ await runBeforeFinish();
575
+ if (sink.sealed)
576
+ return 'cancelled';
577
+ try {
578
+ await sink.finish(status, finishOpts);
579
+ return status;
580
+ }
581
+ catch (error) {
582
+ if (error instanceof RunTerminalError)
583
+ return 'cancelled';
584
+ throw error;
585
+ }
586
+ };
516
587
  for await (const event of events) {
517
588
  if (event.kind === 'execution_complete') {
518
589
  // A prompt the caller/agent stopped is a cancellation, not a failure: the
@@ -520,40 +591,42 @@ export async function drainHarnessToSink(events, sink, opts = {}) {
520
591
  // and stop()-initiated paths. A genuine failure (success:false, no abort
521
592
  // flag) still lands 'failed'.
522
593
  if (event.payload.aborted === true) {
523
- await runBeforeFinish();
524
- await sink.finish('cancelled', {
594
+ return finishAccepting('cancelled', {
525
595
  summary: opts.summary,
526
596
  evidence: opts.evidence,
527
597
  transcript: opts.transcript,
528
598
  });
529
- return 'cancelled';
530
599
  }
531
600
  const success = event.payload.success !== false;
532
601
  const status = success ? 'succeeded' : 'failed';
533
602
  const summary = success
534
603
  ? opts.summary
535
604
  : { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' };
536
- await runBeforeFinish();
537
- await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript });
538
- return status;
605
+ return finishAccepting(status, { summary, evidence: opts.evidence, transcript: opts.transcript });
539
606
  }
540
607
  if (event.kind === 'error') {
541
- await runBeforeFinish();
542
- await sink.finish('failed', {
608
+ return finishAccepting('failed', {
543
609
  summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
544
610
  evidence: opts.evidence,
545
611
  transcript: opts.transcript,
546
612
  });
547
- return 'failed';
548
613
  }
549
- await sink.append(event);
614
+ try {
615
+ await sink.append(event);
616
+ }
617
+ catch (error) {
618
+ if (error instanceof RunTerminalError)
619
+ return handleServerTerminal();
620
+ throw error;
621
+ }
550
622
  }
551
623
  // The harness contract guarantees a terminal event; reaching here means the
552
624
  // stream ended abnormally. Fail closed so the run never dangles non-terminal.
553
625
  await runBeforeFinish();
554
- await sink.finish('failed', {
626
+ if (sink.sealed)
627
+ return 'cancelled';
628
+ return finishAccepting('failed', {
555
629
  summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
556
630
  transcript: opts.transcript,
557
631
  });
558
- return 'failed';
559
632
  }
@@ -34,21 +34,25 @@
34
34
  * is injectable, so the whole flow runs in-process against fakes in the
35
35
  * local-sim end-to-end test.
36
36
  */
37
+ import { readFileSync } from 'fs';
37
38
  import { createLocalSimProvider, } from './sandbox-provider.js';
38
39
  import { bootstrapHostedSandbox, createBootstrapRunEventSink, } from './hosted-bootstrap.js';
39
40
  import { resumeRunEventSink } from './hosted-run-event-sink.js';
40
41
  import { AGENT_GIT_IDENTITY, BEARER_BASENAME } from './hosted-runtime-assets.js';
41
42
  import { createVercelProvider } from './vercel-sandbox-provider.js';
42
43
  import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js';
43
- import { hostedLoopCommand } from './hosted-loop.js';
44
+ import { DEFAULT_HOSTED_MODEL, hostedLoopCommand } from './hosted-loop.js';
44
45
  import { hostedBootCommand } from './hosted-boot.js';
45
46
  import { mergeJobCommand } from './merge-job.js';
46
47
  import { authedFetch } from './http.js';
47
48
  import { resolveBaseUrl } from './http.js';
49
+ import { findUnknownOption } from './option-validation.js';
48
50
  import { getWorkspaceRoot } from './workspace.js';
49
51
  import { attachedWorkspaceId } from './workspace-sync.js';
50
52
  import { tailWorkbenchRun } from './workbench-cli.js';
51
- export const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8';
53
+ // Default hosted model — SINGLE source of truth in hosted-loop-lifecycle.ts
54
+ // (ALI-1086); re-exported here so existing importers keep working.
55
+ export { DEFAULT_HOSTED_MODEL };
52
56
  export const DEFAULT_DURATION_MINUTES = 60;
53
57
  export const MAX_DURATION_MINUTES = 24 * 60;
54
58
  export const DEFAULT_ROTATION_INTERVAL_MS = 55 * 60 * 1000;
@@ -60,6 +64,13 @@ export const DEFAULT_AGENT_TOKEN_TTL_MINUTES = 60;
60
64
  * before declaring the detached launch a failure. */
61
65
  export const DEFAULT_READINESS_TIMEOUT_MS = 30 * 1000;
62
66
  export const DEFAULT_READINESS_POLL_MS = 500;
67
+ /** Must match the coordinator Worker's session-start request ceiling. */
68
+ export const MAX_HOSTED_START_BODY_BYTES = 128 * 1024;
69
+ /** `workspace_sessions.id` and `workbench_runs.id` are PostgreSQL UUIDs. The
70
+ * server creates both before its coordinator poke, and the Worker injects the
71
+ * session id into the final DO body. A representative UUID makes the local
72
+ * projection byte-exact without creating either server-side row. */
73
+ const GENERATED_HOSTED_UUID_PLACEHOLDER = '00000000-0000-4000-8000-000000000000';
63
74
  /** Non-secret placeholder OpenCode uses to form requests; the firewall proxy
64
75
  * overrides the real Anthropic header (model-key brokering). NEVER a real key. */
65
76
  export const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder';
@@ -588,6 +599,10 @@ export async function startHostedSession(opts) {
588
599
  return base;
589
600
  }
590
601
  function argVal(args, flag) {
602
+ const inlinePrefix = `${flag}=`;
603
+ const inlineArg = args.find(arg => arg.startsWith(inlinePrefix));
604
+ if (inlineArg)
605
+ return inlineArg.slice(inlinePrefix.length);
591
606
  const index = args.indexOf(flag);
592
607
  if (index === -1 || index + 1 >= args.length)
593
608
  return null;
@@ -595,7 +610,7 @@ function argVal(args, flag) {
595
610
  return value.startsWith('--') ? null : value;
596
611
  }
597
612
  function hasFlag(args, flag) {
598
- return args.includes(flag);
613
+ return args.some(arg => arg === flag || arg.startsWith(`${flag}=`));
599
614
  }
600
615
  function numFlag(args, flag) {
601
616
  const raw = argVal(args, flag);
@@ -604,7 +619,7 @@ function numFlag(args, flag) {
604
619
  const parsed = Number(raw);
605
620
  return Number.isFinite(parsed) ? parsed : undefined;
606
621
  }
607
- const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--duration <min> (default 60, max 1440)] ' +
622
+ const HOSTED_USAGE = 'Usage: orizu session start --hosted (--task "<prompt>" | --task-file <path>) [--duration <min> (default 60, max 1440)] ' +
608
623
  '[--model <provider/model>] [--reasoning-effort <level>] [--project <team/project>] ' +
609
624
  '[--title <title>] [--tail]\n' +
610
625
  'Default: the Orizu server provisions the sandbox (no VERCEL_* or model key needed on your machine); ' +
@@ -614,6 +629,40 @@ const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--d
614
629
  'Removal is planned after DO-coordinator validation (ALI-1055).\n' +
615
630
  'Note (v0, --operator only): if you disconnect, the agent token expires within its TTL (<=60m) and the run ' +
616
631
  'finishes cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).';
632
+ const HOSTED_START_OPTIONS = new Set([
633
+ '--hosted',
634
+ '--task',
635
+ '--task-file',
636
+ '--workspace',
637
+ '--duration',
638
+ '--model',
639
+ '--reasoning-effort',
640
+ '--project',
641
+ '--title',
642
+ '--tail',
643
+ '--operator',
644
+ '--provider',
645
+ '--runtime',
646
+ '--image',
647
+ '--snapshot',
648
+ '--model-key',
649
+ '--json',
650
+ ]);
651
+ const HOSTED_START_VALUE_OPTIONS = new Set([
652
+ '--task',
653
+ '--task-file',
654
+ '--workspace',
655
+ '--duration',
656
+ '--model',
657
+ '--reasoning-effort',
658
+ '--project',
659
+ '--title',
660
+ '--provider',
661
+ '--runtime',
662
+ '--image',
663
+ '--snapshot',
664
+ '--model-key',
665
+ ]);
617
666
  /** Printed on EVERY `--operator` use (founder-locked: deprecate NOW, remove
618
667
  * after Phase 6 validation). Loud on purpose. */
619
668
  export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-provisioned hosted sessions) is deprecated; ' +
@@ -625,21 +674,65 @@ export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-
625
674
  * CLI never reads VERCEL_* or a model key on this path — the server holds them.
626
675
  */
627
676
  export async function startHostedViaServer(args, workspaceId, task, tail, io, fetcher = (path, init) => authedFetch(path, init)) {
677
+ const durationMinutes = numFlag(args, '--duration');
678
+ const model = argVal(args, '--model') ?? undefined;
679
+ const reasoningEffort = argVal(args, '--reasoning-effort') ?? undefined;
680
+ const projectSlug = argVal(args, '--project') ?? undefined;
681
+ const title = argVal(args, '--title') ?? undefined;
628
682
  const payload = {
629
683
  workspaceId,
630
684
  task,
631
- durationMinutes: numFlag(args, '--duration'),
632
- model: argVal(args, '--model') ?? undefined,
633
- reasoningEffort: argVal(args, '--reasoning-effort') ?? undefined,
634
- projectSlug: argVal(args, '--project') ?? undefined,
635
- title: argVal(args, '--title') ?? undefined,
685
+ durationMinutes,
686
+ model,
687
+ reasoningEffort,
688
+ projectSlug,
689
+ title,
636
690
  };
691
+ const serializedPayload = JSON.stringify(payload);
692
+ const payloadBytes = new TextEncoder().encode(serializedPayload).byteLength;
693
+ if (payloadBytes > MAX_HOSTED_START_BODY_BYTES) {
694
+ const message = `Hosted task prompt is too large: request body is ${payloadBytes} bytes; ` +
695
+ `maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`;
696
+ if (io.json)
697
+ io.print(JSON.stringify({ ok: false, error: message }));
698
+ else
699
+ io.printErr?.(message);
700
+ return 1;
701
+ }
702
+ // Mirror the exact JSON shape that reaches the session Durable Object after
703
+ // the API creates its UUID run/session rows, normalizes optional strings, and
704
+ // the Worker injects sessionId. This second guard keeps near-boundary prompts
705
+ // from triggering server-side state that would only be compensated after a
706
+ // coordinator 413. Keep this field list in sync with pokeCoordinatorStart in
707
+ // app/api/cli/hosted-sessions/route.ts.
708
+ const normalizedModel = model?.trim() || undefined;
709
+ const normalizedReasoningEffort = reasoningEffort?.trim() || undefined;
710
+ const coordinatorPayload = JSON.stringify({
711
+ runId: GENERATED_HOSTED_UUID_PLACEHOLDER,
712
+ task,
713
+ durationMinutes: durationMinutes !== undefined && durationMinutes > 0
714
+ ? durationMinutes
715
+ : undefined,
716
+ model: normalizedModel,
717
+ reasoningEffort: normalizedReasoningEffort,
718
+ sessionId: GENERATED_HOSTED_UUID_PLACEHOLDER,
719
+ });
720
+ const coordinatorPayloadBytes = new TextEncoder().encode(coordinatorPayload).byteLength;
721
+ if (coordinatorPayloadBytes > MAX_HOSTED_START_BODY_BYTES) {
722
+ const message = `Hosted task prompt is too large: coordinator request body would be ${coordinatorPayloadBytes} bytes ` +
723
+ `after generated run/session IDs; maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`;
724
+ if (io.json)
725
+ io.print(JSON.stringify({ ok: false, error: message }));
726
+ else
727
+ io.printErr?.(message);
728
+ return 1;
729
+ }
637
730
  let response;
638
731
  try {
639
732
  response = await fetcher('/api/cli/hosted-sessions', {
640
733
  method: 'POST',
641
734
  headers: { 'Content-Type': 'application/json' },
642
- body: JSON.stringify(payload),
735
+ body: serializedPayload,
643
736
  });
644
737
  }
645
738
  catch (error) {
@@ -707,7 +800,36 @@ export async function hostedCommand(args, io, deps = {}) {
707
800
  io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot | merge-job>');
708
801
  return 1;
709
802
  }
710
- const task = argVal(args, '--task');
803
+ const unknownOption = findUnknownOption(args, HOSTED_START_OPTIONS, HOSTED_START_VALUE_OPTIONS);
804
+ if (unknownOption) {
805
+ const followsTaskFlag = args.some((arg, index) => arg === '--task' &&
806
+ (args[index + 1] === unknownOption || args[index + 1]?.startsWith(`${unknownOption}=`)));
807
+ const taskValueHint = followsTaskFlag
808
+ ? '\nTask text beginning with a dash must use --task=<prompt> or --task-file <path>.'
809
+ : '';
810
+ io.printErr?.(`unknown option ${unknownOption}${taskValueHint}\n${HOSTED_USAGE}`);
811
+ return 1;
812
+ }
813
+ const inlineTask = argVal(args, '--task');
814
+ const taskFile = argVal(args, '--task-file');
815
+ if (hasFlag(args, '--task') && hasFlag(args, '--task-file')) {
816
+ io.printErr?.(`Use either --task or --task-file, not both.\n${HOSTED_USAGE}`);
817
+ return 1;
818
+ }
819
+ let task = inlineTask;
820
+ if (taskFile) {
821
+ try {
822
+ task = readFileSync(taskFile, 'utf8');
823
+ }
824
+ catch (error) {
825
+ io.printErr?.(`Could not read task file '${taskFile}': ${error instanceof Error ? error.message : String(error)}`);
826
+ return 1;
827
+ }
828
+ if (task.trim().length === 0) {
829
+ io.printErr?.(`Task file '${taskFile}' is empty or contains only whitespace. Add a task prompt and try again.`);
830
+ return 1;
831
+ }
832
+ }
711
833
  if (!task) {
712
834
  io.printErr?.(HOSTED_USAGE);
713
835
  return 1;
package/dist/index.js CHANGED
@@ -2718,12 +2718,25 @@ async function resolveSetupTeam(teamSlugArg, noInput, dryRun) {
2718
2718
  }
2719
2719
  return createTeamOnServer(name);
2720
2720
  }
2721
- async function resolveSetupProjects(team) {
2721
+ async function resolveSetupProjects(team, noInput, dryRun) {
2722
2722
  const projects = await fetchProjects(team.slug);
2723
- if (projects.length === 0) {
2724
- throw new Error(`Team '${team.slug}' has no projects. Create a project before running setup.`);
2723
+ if (projects.length > 0) {
2724
+ return projects;
2725
+ }
2726
+ // ALI-1140: a just-created team (or a pre-existing empty one) has no projects
2727
+ // by definition — continue the wizard into creating the first project instead
2728
+ // of dead-ending on the path setup itself offered.
2729
+ if (noInput || dryRun) {
2730
+ throw new Error(`Team '${team.slug}' has no projects. Create one with \`orizu projects create --name <name> --team ${team.slug}\`, then re-run setup.`);
2725
2731
  }
2726
- return projects;
2732
+ printLine(` Team '${sanitizeTerminalText(team.slug)}' has no projects yet. Let's create the first one.`);
2733
+ const name = await askText(' Project name?', '');
2734
+ if (!name) {
2735
+ throw new Error('Project name is required to create a project.');
2736
+ }
2737
+ const project = await createProjectOnServer(team.slug, name);
2738
+ printLine(` Created project ${sanitizeTerminalText(`${project.teamSlug}/${project.slug}`)}`);
2739
+ return [project];
2727
2740
  }
2728
2741
  function projectSeedsFromProjects(projects) {
2729
2742
  return projects.map(project => ({
@@ -2848,7 +2861,7 @@ async function setupCommand() {
2848
2861
  let projects;
2849
2862
  if (auth.state === 'signed-in' && !validateOnly) {
2850
2863
  const team = await resolveSetupTeam(teamSlug, noInput, dryRun);
2851
- const serverProjects = await resolveSetupProjects(team);
2864
+ const serverProjects = await resolveSetupProjects(team, noInput, dryRun);
2852
2865
  teamSlug = team.slug;
2853
2866
  teamId = team.id;
2854
2867
  projects = projectSeedsFromProjects(serverProjects);
@@ -2866,10 +2879,10 @@ async function setupCommand() {
2866
2879
  hostedAttached = (await runInteractiveHostedSetup({ teamSlug: team.slug, targetDir: workspaceRoot, local: hasArg('--local'), activeInstallation: link.status === 'active' }, { print: printLine, fetcher: authedFetch })).attached;
2867
2880
  }
2868
2881
  catch (error) {
2869
- 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).`);
2882
+ 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).`);
2870
2883
  }
2871
2884
  }
2872
- }
2885
+ } // 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
2873
2886
  }
2874
2887
  else if (!teamSlug && !noInput && !validateOnly) {
2875
2888
  teamSlug = await askText(' Team slug?', 'local-team');
package/dist/merge-job.js CHANGED
@@ -39,6 +39,7 @@
39
39
  * (plus the shape-pattern net). Nothing here ever logs a secret.
40
40
  */
41
41
  import { mergeBranchWithDeployKey, } from './deploy-key-merge-core.js';
42
+ import { assertSecureTokenTransport } from './http.js';
42
43
  import { redactSecrets } from './secret-redaction.js';
43
44
  // -- Frozen env contract --------------------------------------------------------
44
45
  export const REQUIRED_MERGE_JOB_ENV_VARS = [
@@ -60,7 +61,27 @@ export function resolveMergeJobEnv(env) {
60
61
  const coordinatorUrl = req('ORIZU_COORDINATOR_URL');
61
62
  const baseUrl = req('ORIZU_BASE_URL');
62
63
  if (missing.length > 0)
63
- return { ok: false, missing };
64
+ return { ok: false, missing, invalid: [] };
65
+ const origins = [
66
+ ['ORIZU_COORDINATOR_URL', coordinatorUrl],
67
+ ['ORIZU_BASE_URL', baseUrl],
68
+ ];
69
+ const invalid = origins.flatMap(([name, value]) => {
70
+ try {
71
+ const parsed = new URL(value);
72
+ // Userinfo is never accepted, including on loopback: rejecting it at the
73
+ // env boundary keeps configured credentials out of every retry/error path.
74
+ if (parsed.username || parsed.password)
75
+ return [name];
76
+ assertSecureTokenTransport(value);
77
+ return [];
78
+ }
79
+ catch {
80
+ return [name];
81
+ }
82
+ });
83
+ if (invalid.length > 0)
84
+ return { ok: false, missing: [], invalid };
64
85
  return {
65
86
  ok: true,
66
87
  value: {
@@ -312,8 +333,10 @@ export async function runMergeJob(opts) {
312
333
  export async function mergeJobCommand(io) {
313
334
  const resolved = resolveMergeJobEnv(process.env);
314
335
  if (!resolved.ok) {
315
- io.printErr?.(`merge-job: missing required env: ${resolved.missing.join(', ')} ` +
316
- '(the MergeJobCoordinator sandbox env contract)');
336
+ const detail = resolved.missing.length > 0
337
+ ? `missing required env: ${resolved.missing.join(', ')}`
338
+ : `invalid or insecure origin env: ${resolved.invalid.join(', ')}`;
339
+ io.printErr?.(`merge-job: ${detail} (the MergeJobCoordinator sandbox env contract)`);
317
340
  return 1;
318
341
  }
319
342
  const log = (line) => io.printErr?.(`[merge-job] ${line}`);
@@ -0,0 +1,54 @@
1
+ function optionName(arg) {
2
+ const equalsIndex = arg.indexOf('=');
3
+ return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
4
+ }
5
+ function isNegativeNumericValue(arg) {
6
+ return arg.length > 1 && !arg.startsWith('--') && Number.isFinite(Number(arg));
7
+ }
8
+ function isUnambiguouslySingleDashValue(arg) {
9
+ // A short option cannot contain whitespace immediately after its dash. This
10
+ // keeps Markdown-list prompts such as `--task "- fix the test"` available
11
+ // without letting a misspelled option such as `--task -taks` get swallowed.
12
+ return /^-\s/.test(arg) || isNegativeNumericValue(arg);
13
+ }
14
+ function followsValueOption(args, index, valueOptions) {
15
+ if (index === 0)
16
+ return false;
17
+ const previous = args[index - 1];
18
+ return previous.startsWith('--') && !previous.includes('=') && valueOptions.has(previous);
19
+ }
20
+ /**
21
+ * Return the first dash-prefixed option that is not in a command's explicit
22
+ * allowlist. An unambiguously value-like single-dash token immediately
23
+ * following a value-taking option is data: this keeps inputs such as
24
+ * `--duration -5` and `--task "- fix the test"` available without allowing a
25
+ * stray token or mistyped short option.
26
+ *
27
+ * `valueOptions` permits the unambiguous `--option=value` form. A separate
28
+ * token beginning with `--`, or an option-like single-dash word, is parsed as
29
+ * an option so a missing value cannot swallow a typo. Callers whose value is
30
+ * itself option-like can use the inline form; Markdown list markers and finite
31
+ * negative numbers remain safe as separate values.
32
+ */
33
+ export function findUnknownOption(args, allowedOptions, valueOptions = new Set()) {
34
+ for (let index = 0; index < args.length; index += 1) {
35
+ const arg = args[index];
36
+ if (!arg.startsWith('-') || arg === '-')
37
+ continue;
38
+ if (!arg.startsWith('--')) {
39
+ if (isUnambiguouslySingleDashValue(arg) && followsValueOption(args, index, valueOptions))
40
+ continue;
41
+ return arg;
42
+ }
43
+ const name = optionName(arg);
44
+ if (!allowedOptions.has(name))
45
+ return name;
46
+ const hasInlineValue = arg.length > name.length;
47
+ if (hasInlineValue) {
48
+ if (!valueOptions.has(name))
49
+ return arg;
50
+ continue;
51
+ }
52
+ }
53
+ return null;
54
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { spawnSync } from 'child_process';
9
9
  import { authedFetch } from './http.js';
10
+ import { findUnknownOption } from './option-validation.js';
10
11
  import { getWorkspaceRoot, workspaceExists } from './workspace.js';
11
12
  import { attachedWorkspaceId, stringOrNull } from './workspace-sync.js';
12
13
  function defaultSessionGit(args, opts) {
@@ -41,6 +42,19 @@ const DEFAULT_TAIL_INTERVAL_SECONDS = 2;
41
42
  // hot-spin against the API while a non-terminal run is quiet. Draining full
42
43
  // pages intentionally skips the sleep — that is catch-up, not polling.
43
44
  const MIN_TAIL_INTERVAL_MS = 500;
45
+ const SESSION_START_OPTIONS = new Set(['--project', '--workspace', '--json']);
46
+ const WORKBENCH_VALUE_OPTIONS = new Set([
47
+ '--after',
48
+ '--interval',
49
+ '--message',
50
+ '--project',
51
+ '--run',
52
+ '--session',
53
+ '--status',
54
+ '--summary',
55
+ '--title',
56
+ '--workspace',
57
+ ]);
44
58
  function defaultSleep(ms) {
45
59
  return new Promise(resolve => setTimeout(resolve, ms));
46
60
  }
@@ -475,11 +489,19 @@ function hasFlag(args, flag) {
475
489
  return args.includes(flag);
476
490
  }
477
491
  function argValue(args, flag) {
478
- const index = args.indexOf(flag);
479
- if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
480
- return null;
492
+ const inlinePrefix = `${flag}=`;
493
+ for (let index = 0; index < args.length; index += 1) {
494
+ const arg = args[index];
495
+ if (arg.startsWith(inlinePrefix)) {
496
+ return arg.slice(inlinePrefix.length);
497
+ }
498
+ if (arg === flag) {
499
+ return index + 1 < args.length && !args[index + 1].startsWith('--')
500
+ ? args[index + 1]
501
+ : null;
502
+ }
481
503
  }
482
- return args[index + 1];
504
+ return null;
483
505
  }
484
506
  function numberArg(args, flag) {
485
507
  const raw = argValue(args, flag);
@@ -494,7 +516,7 @@ function positionalArgs(args) {
494
516
  for (let i = 0; i < args.length; i += 1) {
495
517
  const arg = args[i];
496
518
  if (arg.startsWith('--')) {
497
- if (argValue(args, arg) !== null) {
519
+ if (!arg.includes('=') && WORKBENCH_VALUE_OPTIONS.has(arg) && argValue(args.slice(i), arg) !== null) {
498
520
  i += 1;
499
521
  }
500
522
  continue;
@@ -700,6 +722,10 @@ async function dispatchWorkbenchCommand(args, io) {
700
722
  // not run from inside it (defaults to cwd).
701
723
  const workspaceDir = argValue(args, '--workspace') ?? undefined;
702
724
  if (subcommand === 'start') {
725
+ const unknownOption = findUnknownOption(args, SESSION_START_OPTIONS, WORKBENCH_VALUE_OPTIONS);
726
+ if (unknownOption) {
727
+ return emitJsonError(io, `unknown option ${unknownOption}\nUsage: orizu session start [--project <team/project>] [--workspace <dir>] [--json]`);
728
+ }
703
729
  const result = await runSessionStart({
704
730
  fetcher,
705
731
  cwd: workspaceDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {