orizu 0.5.14 → 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.
@@ -17,7 +17,7 @@ import { spawnSync } from 'child_process';
17
17
  import { existsSync, mkdirSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { authedFetch } from './http.js';
20
- import { assertWorkspaceDirUsable } from './workspace.js';
20
+ import { assertWorkspaceDirUsable, workspaceExists } from './workspace.js';
21
21
  /**
22
22
  * Strip terminal control characters from server-supplied strings before they
23
23
  * are printed (mirrors index.ts `sanitizeTerminalText`), so a hostile or
@@ -28,7 +28,36 @@ function sanitizeServerText(value) {
28
28
  return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
29
29
  }
30
30
  const DEFAULT_POLL_INTERVAL_MS = 2000;
31
- const DEFAULT_POLL_TIMEOUT_MS = 180000;
31
+ // ALI-1141: long enough for a slow org-picker + repo-selection walk, but the
32
+ // wait MUST be finite — when the App is already installed in the target org,
33
+ // GitHub only offers "Configure" and never redirects to our setup callback, so
34
+ // an unbounded poll would spin forever. (Still under the server's 15-minute
35
+ // nonce TTL, so the poll times out before the server would report `expired`.)
36
+ const DEFAULT_POLL_TIMEOUT_MS = 600000;
37
+ // ALI-1141: after this long with no callback, the likeliest cause is the
38
+ // already-installed org above — surface it instead of heartbeating silently.
39
+ const ALREADY_INSTALLED_HINT_MS = 90000;
40
+ /**
41
+ * Why the poll can never succeed when the App is already installed in the org
42
+ * (ALI-1141): GitHub's `installations/new?state=` page then only offers
43
+ * "Configure", and completing configuration does NOT redirect to the App's
44
+ * Setup URL with our state nonce — the callback that flips the row out of
45
+ * `pending` never fires. Printed as a mid-poll hint and folded into the
46
+ * timeout error so the user is never left with an unexplained hang.
47
+ */
48
+ function alreadyInstalledHintLines(teamSlug) {
49
+ return [
50
+ ' ⚠ GitHub has not called back yet. The most likely cause: the Orizu GitHub',
51
+ ' App is ALREADY INSTALLED in that org — GitHub then only offers',
52
+ ' "Configure", and finishing configuration never notifies Orizu, so this',
53
+ ' wait cannot succeed. Remedies:',
54
+ ' • Install the App into a DIFFERENT GitHub org for this team (each org',
55
+ ' installation can be linked to exactly one Orizu team).',
56
+ ' • If the org is not linked to any Orizu team, uninstall the Orizu App',
57
+ ' from the org (GitHub → org Settings → GitHub Apps) and re-run',
58
+ ` \`orizu github link --team ${teamSlug}\` to do a fresh install.`,
59
+ ];
60
+ }
32
61
  function defaultSleep(ms) {
33
62
  return new Promise(resolve => setTimeout(resolve, ms));
34
63
  }
@@ -83,10 +112,12 @@ export async function runGithubLink(teamSlug, io) {
83
112
  const sleep = io.sleep ?? defaultSleep;
84
113
  const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
85
114
  const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
86
- const deadline = now() + timeout;
115
+ const started = now();
116
+ const deadline = started + timeout;
87
117
  // Periodic heartbeat so a long install does not look hung; never echoes state.
88
118
  const HEARTBEAT_MS = 15000;
89
- let lastHeartbeat = now();
119
+ let lastHeartbeat = started;
120
+ let printedAlreadyInstalledHint = false;
90
121
  for (;;) {
91
122
  const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`);
92
123
  if (poll.ok) {
@@ -107,9 +138,20 @@ export async function runGithubLink(teamSlug, io) {
107
138
  // status 'pending' → keep waiting.
108
139
  }
109
140
  if (now() >= deadline) {
110
- throw new Error('Timed out waiting for the GitHub App install to complete. ' +
141
+ // Same actionable message as the mid-poll hint: an already-installed org
142
+ // is the one known way to reach this timeout with nothing else wrong.
143
+ throw new Error(`Timed out waiting for the GitHub App install to complete after ${Math.round(timeout / 60000)} minutes. ` +
144
+ 'If the Orizu GitHub App is already installed in that org, GitHub never notifies Orizu when you finish the "Configure" flow — ' +
145
+ 'install the App into a DIFFERENT GitHub org for this team (or uninstall it from the org first if no other Orizu team uses it). ' +
111
146
  `Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`);
112
147
  }
148
+ if (!printedAlreadyInstalledHint && now() - started >= ALREADY_INSTALLED_HINT_MS) {
149
+ for (const line of alreadyInstalledHintLines(teamSlug))
150
+ io.print(line);
151
+ io.print(` Still waiting (Ctrl+C to abort; resume later with \`orizu github link --team ${teamSlug}\`)…`);
152
+ printedAlreadyInstalledHint = true;
153
+ lastHeartbeat = now();
154
+ }
113
155
  if (now() - lastHeartbeat >= HEARTBEAT_MS) {
114
156
  io.print(' …still waiting for the GitHub install to complete.');
115
157
  lastHeartbeat = now();
@@ -216,7 +258,7 @@ export async function runHostedAttach(options, io) {
216
258
  io.print(` Cloning ${repoFullName}...`);
217
259
  // Clone with the helper inline + the workspace id in the env so the token is
218
260
  // brokered on demand and never materializes in the URL or on disk.
219
- gitOrThrow(io.git, [
261
+ const clone = io.git([
220
262
  '-c',
221
263
  `credential.helper=${CREDENTIAL_HELPER}`,
222
264
  '-c',
@@ -225,6 +267,18 @@ export async function runHostedAttach(options, io) {
225
267
  cleanUrl,
226
268
  options.targetDir,
227
269
  ], { env: { ORIZU_WORKSPACE_ID: options.workspaceId } });
270
+ if (clone.status !== 0) {
271
+ const detail = clone.stderr || `exit ${clone.status}`;
272
+ // A local-only workspace (`orizu setup --local`) has orizu.team.json but no
273
+ // `.git`, so it passes `assertWorkspaceDirUsable` yet fails the clone —
274
+ // `git clone` refuses the non-empty dir up front and writes nothing, so
275
+ // every byte in there is the user's workspace, not an incomplete clone.
276
+ // Never advise deleting it.
277
+ if (workspaceExists(options.targetDir)) {
278
+ throw new Error(`git clone failed: ${detail} ${options.targetDir} already holds a local Orizu workspace (orizu.team.json) without a hosted clone, so the workbench repo can't be cloned into it. Don't delete it — move it aside or re-run \`orizu setup\` with \`--workspace <empty-path>\` (or keep it local with \`--local\`).`);
279
+ }
280
+ throw new Error(`git clone failed: ${detail} Remove or empty the incomplete clone directory ${options.targetDir}, then re-run \`orizu setup\`.`);
281
+ }
228
282
  // Persist the helper repo-locally for future pull/push (cwd-resolved).
229
283
  gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
230
284
  gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
package/dist/help.js CHANGED
@@ -747,15 +747,27 @@ export const COMMAND_DOCS = [
747
747
  },
748
748
  {
749
749
  path: ['session', 'start'],
750
- usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json]',
751
- summary: 'Start a durable workspace session and print the session id. When run inside the workbench clone (or with --workspace), the remote session branch is fetched and checked out locally, and the exact next git commands are printed.',
750
+ usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json] | orizu session start --hosted (--task <prompt> | --task-file <path>) [--duration <min>] [--project <team/project>] [--tail]',
751
+ summary: 'Start a durable workspace session, or add --hosted with a task prompt to start a coordinator-managed hosted agent. Plain sessions check out their remote session branch locally when run inside the workbench clone (or with --workspace).',
752
752
  group: 'Sessions',
753
753
  options: [
754
754
  { name: '--project <team/project>', help: 'Optionally scope the session to a project in the attached workspace.' },
755
755
  { name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
756
+ { name: '--hosted', help: 'Start a coordinator-managed hosted agent session instead of a plain durable session.' },
757
+ { name: '--task <prompt>', help: 'Hosted agent task prompt. Mutually exclusive with --task-file.' },
758
+ { name: '--task-file <path>', help: 'Read the hosted agent task prompt from a UTF-8 file. Mutually exclusive with --task.' },
759
+ { name: '--duration <min>', help: 'Hosted sandbox duration in minutes (default 60, maximum 1440).' },
760
+ { name: '--model <provider/model>', help: 'Optional hosted model override.' },
761
+ { name: '--reasoning-effort <level>', help: 'Optional hosted model reasoning-effort override.' },
762
+ { name: '--title <title>', help: 'Optional hosted run title.' },
763
+ { name: '--tail', help: 'Follow the hosted run until it reaches a terminal state.' },
756
764
  { name: '--json', help: 'Emit the machine-readable session payload.' },
757
765
  ],
758
- examples: ['orizu session start', 'orizu session start --project highlight/hip --json'],
766
+ examples: [
767
+ 'orizu session start',
768
+ 'orizu session start --project highlight/hip --json',
769
+ 'orizu session start --hosted --task-file ./task.md --project highlight/hip --duration 90',
770
+ ],
759
771
  },
760
772
  {
761
773
  path: ['session', 'status'],
@@ -34,6 +34,7 @@
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';
@@ -45,6 +46,7 @@ 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';
@@ -62,6 +64,13 @@ export const DEFAULT_AGENT_TOKEN_TTL_MINUTES = 60;
62
64
  * before declaring the detached launch a failure. */
63
65
  export const DEFAULT_READINESS_TIMEOUT_MS = 30 * 1000;
64
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';
65
74
  /** Non-secret placeholder OpenCode uses to form requests; the firewall proxy
66
75
  * overrides the real Anthropic header (model-key brokering). NEVER a real key. */
67
76
  export const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder';
@@ -590,6 +599,10 @@ export async function startHostedSession(opts) {
590
599
  return base;
591
600
  }
592
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);
593
606
  const index = args.indexOf(flag);
594
607
  if (index === -1 || index + 1 >= args.length)
595
608
  return null;
@@ -597,7 +610,7 @@ function argVal(args, flag) {
597
610
  return value.startsWith('--') ? null : value;
598
611
  }
599
612
  function hasFlag(args, flag) {
600
- return args.includes(flag);
613
+ return args.some(arg => arg === flag || arg.startsWith(`${flag}=`));
601
614
  }
602
615
  function numFlag(args, flag) {
603
616
  const raw = argVal(args, flag);
@@ -606,7 +619,7 @@ function numFlag(args, flag) {
606
619
  const parsed = Number(raw);
607
620
  return Number.isFinite(parsed) ? parsed : undefined;
608
621
  }
609
- 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)] ' +
610
623
  '[--model <provider/model>] [--reasoning-effort <level>] [--project <team/project>] ' +
611
624
  '[--title <title>] [--tail]\n' +
612
625
  'Default: the Orizu server provisions the sandbox (no VERCEL_* or model key needed on your machine); ' +
@@ -616,6 +629,40 @@ const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--d
616
629
  'Removal is planned after DO-coordinator validation (ALI-1055).\n' +
617
630
  'Note (v0, --operator only): if you disconnect, the agent token expires within its TTL (<=60m) and the run ' +
618
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
+ ]);
619
666
  /** Printed on EVERY `--operator` use (founder-locked: deprecate NOW, remove
620
667
  * after Phase 6 validation). Loud on purpose. */
621
668
  export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-provisioned hosted sessions) is deprecated; ' +
@@ -627,21 +674,65 @@ export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-
627
674
  * CLI never reads VERCEL_* or a model key on this path — the server holds them.
628
675
  */
629
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;
630
682
  const payload = {
631
683
  workspaceId,
632
684
  task,
633
- durationMinutes: numFlag(args, '--duration'),
634
- model: argVal(args, '--model') ?? undefined,
635
- reasoningEffort: argVal(args, '--reasoning-effort') ?? undefined,
636
- projectSlug: argVal(args, '--project') ?? undefined,
637
- title: argVal(args, '--title') ?? undefined,
685
+ durationMinutes,
686
+ model,
687
+ reasoningEffort,
688
+ projectSlug,
689
+ title,
638
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
+ }
639
730
  let response;
640
731
  try {
641
732
  response = await fetcher('/api/cli/hosted-sessions', {
642
733
  method: 'POST',
643
734
  headers: { 'Content-Type': 'application/json' },
644
- body: JSON.stringify(payload),
735
+ body: serializedPayload,
645
736
  });
646
737
  }
647
738
  catch (error) {
@@ -709,7 +800,36 @@ export async function hostedCommand(args, io, deps = {}) {
709
800
  io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot | merge-job>');
710
801
  return 1;
711
802
  }
712
- 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
+ }
713
833
  if (!task) {
714
834
  io.printErr?.(HOSTED_USAGE);
715
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.14",
3
+ "version": "0.5.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@ import { existsSync, mkdirSync } from 'fs'
19
19
  import { join } from 'path'
20
20
 
21
21
  import { authedFetch } from './http.js'
22
- import { assertWorkspaceDirUsable } from './workspace.js'
22
+ import { assertWorkspaceDirUsable, workspaceExists } from './workspace.js'
23
23
 
24
24
  export type SetupFetcher = (path: string, init?: RequestInit) => Promise<Response>
25
25
 
@@ -56,7 +56,37 @@ export interface GithubLinkResult {
56
56
  }
57
57
 
58
58
  const DEFAULT_POLL_INTERVAL_MS = 2000
59
- const DEFAULT_POLL_TIMEOUT_MS = 180000
59
+ // ALI-1141: long enough for a slow org-picker + repo-selection walk, but the
60
+ // wait MUST be finite — when the App is already installed in the target org,
61
+ // GitHub only offers "Configure" and never redirects to our setup callback, so
62
+ // an unbounded poll would spin forever. (Still under the server's 15-minute
63
+ // nonce TTL, so the poll times out before the server would report `expired`.)
64
+ const DEFAULT_POLL_TIMEOUT_MS = 600000
65
+ // ALI-1141: after this long with no callback, the likeliest cause is the
66
+ // already-installed org above — surface it instead of heartbeating silently.
67
+ const ALREADY_INSTALLED_HINT_MS = 90000
68
+
69
+ /**
70
+ * Why the poll can never succeed when the App is already installed in the org
71
+ * (ALI-1141): GitHub's `installations/new?state=` page then only offers
72
+ * "Configure", and completing configuration does NOT redirect to the App's
73
+ * Setup URL with our state nonce — the callback that flips the row out of
74
+ * `pending` never fires. Printed as a mid-poll hint and folded into the
75
+ * timeout error so the user is never left with an unexplained hang.
76
+ */
77
+ function alreadyInstalledHintLines(teamSlug: string): string[] {
78
+ return [
79
+ ' ⚠ GitHub has not called back yet. The most likely cause: the Orizu GitHub',
80
+ ' App is ALREADY INSTALLED in that org — GitHub then only offers',
81
+ ' "Configure", and finishing configuration never notifies Orizu, so this',
82
+ ' wait cannot succeed. Remedies:',
83
+ ' • Install the App into a DIFFERENT GitHub org for this team (each org',
84
+ ' installation can be linked to exactly one Orizu team).',
85
+ ' • If the org is not linked to any Orizu team, uninstall the Orizu App',
86
+ ' from the org (GitHub → org Settings → GitHub Apps) and re-run',
87
+ ` \`orizu github link --team ${teamSlug}\` to do a fresh install.`,
88
+ ]
89
+ }
60
90
 
61
91
  function defaultSleep(ms: number): Promise<void> {
62
92
  return new Promise(resolve => setTimeout(resolve, ms))
@@ -118,10 +148,12 @@ export async function runGithubLink(teamSlug: string, io: GithubLinkIo): Promise
118
148
  const sleep = io.sleep ?? defaultSleep
119
149
  const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
120
150
  const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS
121
- const deadline = now() + timeout
151
+ const started = now()
152
+ const deadline = started + timeout
122
153
  // Periodic heartbeat so a long install does not look hung; never echoes state.
123
154
  const HEARTBEAT_MS = 15000
124
- let lastHeartbeat = now()
155
+ let lastHeartbeat = started
156
+ let printedAlreadyInstalledHint = false
125
157
 
126
158
  for (;;) {
127
159
  const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`)
@@ -149,11 +181,23 @@ export async function runGithubLink(teamSlug: string, io: GithubLinkIo): Promise
149
181
  // status 'pending' → keep waiting.
150
182
  }
151
183
  if (now() >= deadline) {
184
+ // Same actionable message as the mid-poll hint: an already-installed org
185
+ // is the one known way to reach this timeout with nothing else wrong.
152
186
  throw new Error(
153
- 'Timed out waiting for the GitHub App install to complete. ' +
187
+ `Timed out waiting for the GitHub App install to complete after ${Math.round(timeout / 60000)} minutes. ` +
188
+ 'If the Orizu GitHub App is already installed in that org, GitHub never notifies Orizu when you finish the "Configure" flow — ' +
189
+ 'install the App into a DIFFERENT GitHub org for this team (or uninstall it from the org first if no other Orizu team uses it). ' +
154
190
  `Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`
155
191
  )
156
192
  }
193
+ if (!printedAlreadyInstalledHint && now() - started >= ALREADY_INSTALLED_HINT_MS) {
194
+ for (const line of alreadyInstalledHintLines(teamSlug)) io.print(line)
195
+ io.print(
196
+ ` Still waiting (Ctrl+C to abort; resume later with \`orizu github link --team ${teamSlug}\`)…`
197
+ )
198
+ printedAlreadyInstalledHint = true
199
+ lastHeartbeat = now()
200
+ }
157
201
  if (now() - lastHeartbeat >= HEARTBEAT_MS) {
158
202
  io.print(' …still waiting for the GitHub install to complete.')
159
203
  lastHeartbeat = now()
@@ -338,8 +382,7 @@ export async function runHostedAttach(
338
382
  io.print(` Cloning ${repoFullName}...`)
339
383
  // Clone with the helper inline + the workspace id in the env so the token is
340
384
  // brokered on demand and never materializes in the URL or on disk.
341
- gitOrThrow(
342
- io.git,
385
+ const clone = io.git(
343
386
  [
344
387
  '-c',
345
388
  `credential.helper=${CREDENTIAL_HELPER}`,
@@ -351,6 +394,22 @@ export async function runHostedAttach(
351
394
  ],
352
395
  { env: { ORIZU_WORKSPACE_ID: options.workspaceId } }
353
396
  )
397
+ if (clone.status !== 0) {
398
+ const detail = clone.stderr || `exit ${clone.status}`
399
+ // A local-only workspace (`orizu setup --local`) has orizu.team.json but no
400
+ // `.git`, so it passes `assertWorkspaceDirUsable` yet fails the clone —
401
+ // `git clone` refuses the non-empty dir up front and writes nothing, so
402
+ // every byte in there is the user's workspace, not an incomplete clone.
403
+ // Never advise deleting it.
404
+ if (workspaceExists(options.targetDir)) {
405
+ throw new Error(
406
+ `git clone failed: ${detail} ${options.targetDir} already holds a local Orizu workspace (orizu.team.json) without a hosted clone, so the workbench repo can't be cloned into it. Don't delete it — move it aside or re-run \`orizu setup\` with \`--workspace <empty-path>\` (or keep it local with \`--local\`).`
407
+ )
408
+ }
409
+ throw new Error(
410
+ `git clone failed: ${detail} Remove or empty the incomplete clone directory ${options.targetDir}, then re-run \`orizu setup\`.`
411
+ )
412
+ }
354
413
  // Persist the helper repo-locally for future pull/push (cwd-resolved).
355
414
  gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir })
356
415
  gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir })
package/src/help.ts CHANGED
@@ -772,16 +772,28 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
772
772
  },
773
773
  {
774
774
  path: ['session', 'start'],
775
- usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json]',
775
+ usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json] | orizu session start --hosted (--task <prompt> | --task-file <path>) [--duration <min>] [--project <team/project>] [--tail]',
776
776
  summary:
777
- 'Start a durable workspace session and print the session id. When run inside the workbench clone (or with --workspace), the remote session branch is fetched and checked out locally, and the exact next git commands are printed.',
777
+ 'Start a durable workspace session, or add --hosted with a task prompt to start a coordinator-managed hosted agent. Plain sessions check out their remote session branch locally when run inside the workbench clone (or with --workspace).',
778
778
  group: 'Sessions',
779
779
  options: [
780
780
  { name: '--project <team/project>', help: 'Optionally scope the session to a project in the attached workspace.' },
781
781
  { name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
782
+ { name: '--hosted', help: 'Start a coordinator-managed hosted agent session instead of a plain durable session.' },
783
+ { name: '--task <prompt>', help: 'Hosted agent task prompt. Mutually exclusive with --task-file.' },
784
+ { name: '--task-file <path>', help: 'Read the hosted agent task prompt from a UTF-8 file. Mutually exclusive with --task.' },
785
+ { name: '--duration <min>', help: 'Hosted sandbox duration in minutes (default 60, maximum 1440).' },
786
+ { name: '--model <provider/model>', help: 'Optional hosted model override.' },
787
+ { name: '--reasoning-effort <level>', help: 'Optional hosted model reasoning-effort override.' },
788
+ { name: '--title <title>', help: 'Optional hosted run title.' },
789
+ { name: '--tail', help: 'Follow the hosted run until it reaches a terminal state.' },
782
790
  { name: '--json', help: 'Emit the machine-readable session payload.' },
783
791
  ],
784
- examples: ['orizu session start', 'orizu session start --project highlight/hip --json'],
792
+ examples: [
793
+ 'orizu session start',
794
+ 'orizu session start --project highlight/hip --json',
795
+ 'orizu session start --hosted --task-file ./task.md --project highlight/hip --duration 90',
796
+ ],
785
797
  },
786
798
  {
787
799
  path: ['session', 'status'],
@@ -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,
@@ -57,6 +59,7 @@ 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'
@@ -75,6 +78,13 @@ export const DEFAULT_AGENT_TOKEN_TTL_MINUTES = 60
75
78
  * before declaring the detached launch a failure. */
76
79
  export const DEFAULT_READINESS_TIMEOUT_MS = 30 * 1000
77
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'
78
88
  /** Non-secret placeholder OpenCode uses to form requests; the firewall proxy
79
89
  * overrides the real Anthropic header (model-key brokering). NEVER a real key. */
80
90
  export const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder'
@@ -787,6 +797,10 @@ export interface HostedCommandIo {
787
797
  }
788
798
 
789
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
+
790
804
  const index = args.indexOf(flag)
791
805
  if (index === -1 || index + 1 >= args.length) return null
792
806
  const value = args[index + 1]
@@ -794,7 +808,7 @@ function argVal(args: readonly string[], flag: string): string | null {
794
808
  }
795
809
 
796
810
  function hasFlag(args: readonly string[], flag: string): boolean {
797
- return args.includes(flag)
811
+ return args.some(arg => arg === flag || arg.startsWith(`${flag}=`))
798
812
  }
799
813
 
800
814
  function numFlag(args: readonly string[], flag: string): number | undefined {
@@ -805,7 +819,7 @@ function numFlag(args: readonly string[], flag: string): number | undefined {
805
819
  }
806
820
 
807
821
  const HOSTED_USAGE =
808
- '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)] ' +
809
823
  '[--model <provider/model>] [--reasoning-effort <level>] [--project <team/project>] ' +
810
824
  '[--title <title>] [--tail]\n' +
811
825
  'Default: the Orizu server provisions the sandbox (no VERCEL_* or model key needed on your machine); ' +
@@ -816,6 +830,42 @@ const HOSTED_USAGE =
816
830
  'Note (v0, --operator only): if you disconnect, the agent token expires within its TTL (<=60m) and the run ' +
817
831
  'finishes cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).'
818
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
+
819
869
  /** Printed on EVERY `--operator` use (founder-locked: deprecate NOW, remove
820
870
  * after Phase 6 validation). Loud on purpose. */
821
871
  export const OPERATOR_DEPRECATION_WARNING =
@@ -836,21 +886,65 @@ export async function startHostedViaServer(
836
886
  io: HostedCommandIo,
837
887
  fetcher: HostFetcher = (path, init) => authedFetch(path, init)
838
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
839
894
  const payload: Record<string, unknown> = {
840
895
  workspaceId,
841
896
  task,
842
- durationMinutes: numFlag(args, '--duration'),
843
- model: argVal(args, '--model') ?? undefined,
844
- reasoningEffort: argVal(args, '--reasoning-effort') ?? undefined,
845
- projectSlug: argVal(args, '--project') ?? undefined,
846
- 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
847
941
  }
848
942
  let response: Response
849
943
  try {
850
944
  response = await fetcher('/api/cli/hosted-sessions', {
851
945
  method: 'POST',
852
946
  headers: { 'Content-Type': 'application/json' },
853
- body: JSON.stringify(payload),
947
+ body: serializedPayload,
854
948
  })
855
949
  } catch (error) {
856
950
  io.printErr?.(`hosted session request failed: ${error instanceof Error ? error.message : String(error)}`)
@@ -937,7 +1031,44 @@ export async function hostedCommand(
937
1031
  return 1
938
1032
  }
939
1033
 
940
- 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
+ }
941
1072
  if (!task) {
942
1073
  io.printErr?.(HOSTED_USAGE)
943
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.