orizu 0.5.14 → 0.5.16

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.
Files changed (51) hide show
  1. package/dist/artifact-archive.js +20 -4
  2. package/dist/egress-allowlist-cli.js +72 -10
  3. package/dist/github-setup.js +60 -6
  4. package/dist/help.js +26 -7
  5. package/dist/hosted-runtime-assets.js +8 -1
  6. package/dist/hosted-session-cli.js +197 -22
  7. package/dist/index.js +108 -68
  8. package/dist/kill-switch-cli.js +10 -0
  9. package/dist/merge-job.js +26 -3
  10. package/dist/optimizations-list-cli.js +85 -0
  11. package/dist/option-validation.js +54 -0
  12. package/dist/preview-runtime.js +93 -0
  13. package/dist/prompt-draft-push.js +3 -2
  14. package/dist/registration-tag-warning.js +49 -0
  15. package/dist/run-event-digest.js +319 -0
  16. package/dist/runner-dir-verify.js +848 -0
  17. package/dist/scorer-draft-push.js +3 -2
  18. package/dist/scores-accept-cli.js +87 -0
  19. package/dist/workbench-cli.js +35 -7
  20. package/dist/zip-draft-push.js +3 -2
  21. package/package.json +1 -1
  22. package/src/artifact-archive.ts +29 -4
  23. package/src/egress-allowlist-cli.ts +87 -13
  24. package/src/github-setup.ts +66 -7
  25. package/src/help.ts +26 -7
  26. package/src/hosted-runtime-assets.ts +14 -1
  27. package/src/hosted-session-cli.ts +215 -22
  28. package/src/index.ts +114 -80
  29. package/src/kill-switch-cli.ts +10 -0
  30. package/src/merge-job.ts +25 -6
  31. package/src/optimizations-list-cli.ts +117 -0
  32. package/src/option-validation.ts +63 -0
  33. package/src/preview-runtime.ts +94 -0
  34. package/src/prompt-draft-push.ts +5 -2
  35. package/src/registration-tag-warning.ts +58 -0
  36. package/src/run-event-digest.ts +333 -0
  37. package/src/runner-dir-verify.ts +1029 -0
  38. package/src/scorer-draft-push.ts +5 -2
  39. package/src/scores-accept-cli.ts +124 -0
  40. package/src/workbench-cli.ts +38 -7
  41. package/src/zip-draft-push.ts +5 -2
  42. package/vendor/orizu-gepa-python/src/orizu_gepa/cli.py +80 -3
  43. package/vendor/orizu-gepa-python/src/orizu_gepa/optimizer.py +288 -15
  44. package/vendor/orizu-gepa-python/src/orizu_gepa/runner.py +133 -8
  45. package/vendor/skills/orizu-cli/SKILL.md +4 -2
  46. package/vendor/skills/orizu-cli/references/building-apps.md +131 -1
  47. package/vendor/skills/orizu-cli/references/building-judges.md +18 -0
  48. package/vendor/skills/orizu-cli/references/cli-reference.md +2 -1
  49. package/vendor/skills/orizu-cli/references/optimization-with-gepa.md +3 -1
  50. package/vendor/skills/orizu-cli/references/prompt-control-plane.md +75 -0
  51. package/vendor/skills/orizu-cli/scripts/test-app.mjs +3 -0
@@ -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,9 +46,11 @@ 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';
53
+ import { formatRunEventDigest, formatRunEventTypeLine } from './run-event-digest.js';
51
54
  // Default hosted model — SINGLE source of truth in hosted-loop-lifecycle.ts
52
55
  // (ALI-1086); re-exported here so existing importers keep working.
53
56
  export { DEFAULT_HOSTED_MODEL };
@@ -62,6 +65,13 @@ export const DEFAULT_AGENT_TOKEN_TTL_MINUTES = 60;
62
65
  * before declaring the detached launch a failure. */
63
66
  export const DEFAULT_READINESS_TIMEOUT_MS = 30 * 1000;
64
67
  export const DEFAULT_READINESS_POLL_MS = 500;
68
+ /** Must match the coordinator Worker's session-start request ceiling. */
69
+ export const MAX_HOSTED_START_BODY_BYTES = 128 * 1024;
70
+ /** `workspace_sessions.id` and `workbench_runs.id` are PostgreSQL UUIDs. The
71
+ * server creates both before its coordinator poke, and the Worker injects the
72
+ * session id into the final DO body. A representative UUID makes the local
73
+ * projection byte-exact without creating either server-side row. */
74
+ const GENERATED_HOSTED_UUID_PLACEHOLDER = '00000000-0000-4000-8000-000000000000';
65
75
  /** Non-secret placeholder OpenCode uses to form requests; the firewall proxy
66
76
  * overrides the real Anthropic header (model-key brokering). NEVER a real key. */
67
77
  export const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder';
@@ -590,6 +600,10 @@ export async function startHostedSession(opts) {
590
600
  return base;
591
601
  }
592
602
  function argVal(args, flag) {
603
+ const inlinePrefix = `${flag}=`;
604
+ const inlineArg = args.find(arg => arg.startsWith(inlinePrefix));
605
+ if (inlineArg)
606
+ return inlineArg.slice(inlinePrefix.length);
593
607
  const index = args.indexOf(flag);
594
608
  if (index === -1 || index + 1 >= args.length)
595
609
  return null;
@@ -597,7 +611,7 @@ function argVal(args, flag) {
597
611
  return value.startsWith('--') ? null : value;
598
612
  }
599
613
  function hasFlag(args, flag) {
600
- return args.includes(flag);
614
+ return args.some(arg => arg === flag || arg.startsWith(`${flag}=`));
601
615
  }
602
616
  function numFlag(args, flag) {
603
617
  const raw = argVal(args, flag);
@@ -606,9 +620,11 @@ function numFlag(args, flag) {
606
620
  const parsed = Number(raw);
607
621
  return Number.isFinite(parsed) ? parsed : undefined;
608
622
  }
609
- const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--duration <min> (default 60, max 1440)] ' +
623
+ const HOSTED_USAGE = 'Usage: orizu session start --hosted (--task "<prompt>" | --task-file <path>) [--duration <min> (default 60, max 1440)] ' +
610
624
  '[--model <provider/model>] [--reasoning-effort <level>] [--project <team/project>] ' +
611
- '[--title <title>] [--tail]\n' +
625
+ '[--title <title>] [--tail [--quiet]]\n' +
626
+ '--tail streams compact per-event digests (agent text, tool calls + args, results); ' +
627
+ '--quiet restores the types-only stream; --json keeps full event payloads.\n' +
612
628
  'Default: the Orizu server provisions the sandbox (no VERCEL_* or model key needed on your machine); ' +
613
629
  'the session coordinator owns its lifetime (ALI-1055).\n' +
614
630
  'DEPRECATED escape hatch: --operator provisions from THIS machine (requires VERCEL_* + ANTHROPIC_API_KEY ' +
@@ -616,6 +632,41 @@ const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--d
616
632
  'Removal is planned after DO-coordinator validation (ALI-1055).\n' +
617
633
  'Note (v0, --operator only): if you disconnect, the agent token expires within its TTL (<=60m) and the run ' +
618
634
  'finishes cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).';
635
+ const HOSTED_START_OPTIONS = new Set([
636
+ '--hosted',
637
+ '--task',
638
+ '--task-file',
639
+ '--workspace',
640
+ '--duration',
641
+ '--model',
642
+ '--reasoning-effort',
643
+ '--project',
644
+ '--title',
645
+ '--tail',
646
+ '--quiet',
647
+ '--operator',
648
+ '--provider',
649
+ '--runtime',
650
+ '--image',
651
+ '--snapshot',
652
+ '--model-key',
653
+ '--json',
654
+ ]);
655
+ const HOSTED_START_VALUE_OPTIONS = new Set([
656
+ '--task',
657
+ '--task-file',
658
+ '--workspace',
659
+ '--duration',
660
+ '--model',
661
+ '--reasoning-effort',
662
+ '--project',
663
+ '--title',
664
+ '--provider',
665
+ '--runtime',
666
+ '--image',
667
+ '--snapshot',
668
+ '--model-key',
669
+ ]);
619
670
  /** Printed on EVERY `--operator` use (founder-locked: deprecate NOW, remove
620
671
  * after Phase 6 validation). Loud on purpose. */
621
672
  export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-provisioned hosted sessions) is deprecated; ' +
@@ -626,29 +677,110 @@ export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-
626
677
  * route and let the Durable Object coordinator own the sandbox lifetime. The
627
678
  * CLI never reads VERCEL_* or a model key on this path — the server holds them.
628
679
  */
629
- export async function startHostedViaServer(args, workspaceId, task, tail, io, fetcher = (path, init) => authedFetch(path, init)) {
680
+ /** Bounded reconcile of a start that the coordinator reported still-in-flight
681
+ * (R7 P4). Each retry re-pokes the SAME session by its returned sessionId, so
682
+ * a race that momentarily answers start_pending never spawns a duplicate.
683
+ *
684
+ * R8 P2 — the retry budget must OUTLIVE a stale start lease. If a start-lease
685
+ * owner crashed WITHOUT running its release, the lease lingers until its TTL
686
+ * (the coordinator's START_LEASE_TTL_MS = 3 × CALLBACK_TIMEOUT_MS = 30s), and
687
+ * every duplicate poke sees it and answers start_pending. So the CLI's OWN
688
+ * cumulative backoff (independent of server-side watch time) must exceed that
689
+ * TTL plus margin, so the CLI keeps polling until the lease expires and a
690
+ * retry then wins — rather than giving up while the crashed owner's lease
691
+ * still lingers. COORDINATOR_START_LEASE_TTL_MS mirrors the worker constant
692
+ * and is pinned to it by a contract test in the CLI suite. */
693
+ export const COORDINATOR_START_LEASE_TTL_MS = 30_000;
694
+ export const HOSTED_START_PENDING_RETRY_MS = 2_000;
695
+ const HOSTED_START_PENDING_TTL_MARGIN_MS = 6_000;
696
+ export const HOSTED_START_PENDING_MAX_RETRIES = Math.ceil((COORDINATOR_START_LEASE_TTL_MS + HOSTED_START_PENDING_TTL_MARGIN_MS) / HOSTED_START_PENDING_RETRY_MS);
697
+ export async function startHostedViaServer(args, workspaceId, task, tail, io, fetcher = (path, init) => authedFetch(path, init), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))) {
698
+ const durationMinutes = numFlag(args, '--duration');
699
+ const model = argVal(args, '--model') ?? undefined;
700
+ const reasoningEffort = argVal(args, '--reasoning-effort') ?? undefined;
701
+ const projectSlug = argVal(args, '--project') ?? undefined;
702
+ const title = argVal(args, '--title') ?? undefined;
630
703
  const payload = {
631
704
  workspaceId,
632
705
  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,
706
+ durationMinutes,
707
+ model,
708
+ reasoningEffort,
709
+ projectSlug,
710
+ title,
638
711
  };
639
- let response;
640
- try {
641
- response = await fetcher('/api/cli/hosted-sessions', {
642
- method: 'POST',
643
- headers: { 'Content-Type': 'application/json' },
644
- body: JSON.stringify(payload),
645
- });
712
+ const serializedPayload = JSON.stringify(payload);
713
+ const payloadBytes = new TextEncoder().encode(serializedPayload).byteLength;
714
+ if (payloadBytes > MAX_HOSTED_START_BODY_BYTES) {
715
+ const message = `Hosted task prompt is too large: request body is ${payloadBytes} bytes; ` +
716
+ `maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`;
717
+ if (io.json)
718
+ io.print(JSON.stringify({ ok: false, error: message }));
719
+ else
720
+ io.printErr?.(message);
721
+ return 1;
646
722
  }
647
- catch (error) {
648
- io.printErr?.(`hosted session request failed: ${error instanceof Error ? error.message : String(error)}`);
723
+ // Mirror the exact JSON shape that reaches the session Durable Object after
724
+ // the API creates its UUID run/session rows, normalizes optional strings, and
725
+ // the Worker injects sessionId. This second guard keeps near-boundary prompts
726
+ // from triggering server-side state that would only be compensated after a
727
+ // coordinator 413. Keep this field list in sync with pokeCoordinatorStart in
728
+ // app/api/cli/hosted-sessions/route.ts.
729
+ const normalizedModel = model?.trim() || undefined;
730
+ const normalizedReasoningEffort = reasoningEffort?.trim() || undefined;
731
+ const coordinatorPayload = JSON.stringify({
732
+ runId: GENERATED_HOSTED_UUID_PLACEHOLDER,
733
+ task,
734
+ durationMinutes: durationMinutes !== undefined && durationMinutes > 0
735
+ ? durationMinutes
736
+ : undefined,
737
+ model: normalizedModel,
738
+ reasoningEffort: normalizedReasoningEffort,
739
+ sessionId: GENERATED_HOSTED_UUID_PLACEHOLDER,
740
+ });
741
+ const coordinatorPayloadBytes = new TextEncoder().encode(coordinatorPayload).byteLength;
742
+ if (coordinatorPayloadBytes > MAX_HOSTED_START_BODY_BYTES) {
743
+ const message = `Hosted task prompt is too large: coordinator request body would be ${coordinatorPayloadBytes} bytes ` +
744
+ `after generated run/session IDs; maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`;
745
+ if (io.json)
746
+ io.print(JSON.stringify({ ok: false, error: message }));
747
+ else
748
+ io.printErr?.(message);
649
749
  return 1;
650
750
  }
651
- const body = (await response.json().catch(() => ({})));
751
+ // R7 P4: the coordinator may momentarily answer start_pending (a start for
752
+ // this session is racing). It returns the sessionId so we RECONCILE the SAME
753
+ // session — re-poking by that id rather than blindly re-initing a duplicate.
754
+ let response;
755
+ let body = {};
756
+ let reconcileSessionId;
757
+ for (let attempt = 0;; attempt++) {
758
+ const attemptBody = reconcileSessionId
759
+ ? JSON.stringify({ ...payload, sessionId: reconcileSessionId })
760
+ : serializedPayload;
761
+ try {
762
+ response = await fetcher('/api/cli/hosted-sessions', {
763
+ method: 'POST',
764
+ headers: { 'Content-Type': 'application/json' },
765
+ body: attemptBody,
766
+ });
767
+ }
768
+ catch (error) {
769
+ io.printErr?.(`hosted session request failed: ${error instanceof Error ? error.message : String(error)}`);
770
+ return 1;
771
+ }
772
+ body = (await response.json().catch(() => ({})));
773
+ const stillPending = response.status === 409 &&
774
+ body.coordinator === 'start_pending' &&
775
+ typeof body.sessionId === 'string' &&
776
+ body.sessionId.length > 0;
777
+ if (stillPending && attempt < HOSTED_START_PENDING_MAX_RETRIES) {
778
+ reconcileSessionId = body.sessionId;
779
+ await sleep(HOSTED_START_PENDING_RETRY_MS);
780
+ continue;
781
+ }
782
+ break;
783
+ }
652
784
  const errorMessage = typeof body.error === 'string' ? body.error : `${response.status}`;
653
785
  if (response.status === 503) {
654
786
  io.printErr?.(errorMessage);
@@ -673,12 +805,20 @@ export async function startHostedViaServer(args, workspaceId, task, tail, io, fe
673
805
  io.print(`stream it: orizu run tail --run ${runId}`);
674
806
  }
675
807
  if (tail && runId) {
808
+ // ALI-1045: the attached tail renders the SAME compact per-event digests
809
+ // `run tail` uses (agent text, tool + args, result + first output line) so
810
+ // the operator watching live sees what is happening without a second
811
+ // terminal. `--quiet` keeps the old types-only stream; `--json` keeps full
812
+ // fidelity.
813
+ const quiet = hasFlag(args, '--quiet');
676
814
  await tailWorkbenchRun({
677
815
  fetcher,
678
816
  runId,
679
817
  onEvent: event => io.print(io.json
680
818
  ? JSON.stringify(event)
681
- : `#${String(event.sequence ?? '?')} ${String(event.eventType ?? '')}`),
819
+ : quiet
820
+ ? formatRunEventTypeLine(event)
821
+ : formatRunEventDigest(event)),
682
822
  });
683
823
  }
684
824
  return 0;
@@ -709,7 +849,36 @@ export async function hostedCommand(args, io, deps = {}) {
709
849
  io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot | merge-job>');
710
850
  return 1;
711
851
  }
712
- const task = argVal(args, '--task');
852
+ const unknownOption = findUnknownOption(args, HOSTED_START_OPTIONS, HOSTED_START_VALUE_OPTIONS);
853
+ if (unknownOption) {
854
+ const followsTaskFlag = args.some((arg, index) => arg === '--task' &&
855
+ (args[index + 1] === unknownOption || args[index + 1]?.startsWith(`${unknownOption}=`)));
856
+ const taskValueHint = followsTaskFlag
857
+ ? '\nTask text beginning with a dash must use --task=<prompt> or --task-file <path>.'
858
+ : '';
859
+ io.printErr?.(`unknown option ${unknownOption}${taskValueHint}\n${HOSTED_USAGE}`);
860
+ return 1;
861
+ }
862
+ const inlineTask = argVal(args, '--task');
863
+ const taskFile = argVal(args, '--task-file');
864
+ if (hasFlag(args, '--task') && hasFlag(args, '--task-file')) {
865
+ io.printErr?.(`Use either --task or --task-file, not both.\n${HOSTED_USAGE}`);
866
+ return 1;
867
+ }
868
+ let task = inlineTask;
869
+ if (taskFile) {
870
+ try {
871
+ task = readFileSync(taskFile, 'utf8');
872
+ }
873
+ catch (error) {
874
+ io.printErr?.(`Could not read task file '${taskFile}': ${error instanceof Error ? error.message : String(error)}`);
875
+ return 1;
876
+ }
877
+ if (task.trim().length === 0) {
878
+ io.printErr?.(`Task file '${taskFile}' is empty or contains only whitespace. Add a task prompt and try again.`);
879
+ return 1;
880
+ }
881
+ }
713
882
  if (!task) {
714
883
  io.printErr?.(HOSTED_USAGE);
715
884
  return 1;
@@ -762,7 +931,13 @@ export async function hostedCommand(args, io, deps = {}) {
762
931
  // fail-fast in startHostedSession throws before any session/sandbox is made.
763
932
  modelApiKey: argVal(args, '--model-key') ?? (process.env.ANTHROPIC_API_KEY || undefined),
764
933
  logLine: line => io.print(line),
765
- onTailEvent: event => io.print(io.json ? JSON.stringify(event) : `#${String(event.sequence ?? '?')} ${String(event.eventType ?? '')}`),
934
+ // ALI-1045: compact per-event digests (shared with `run tail`); `--quiet`
935
+ // keeps the old types-only stream, `--json` keeps full fidelity.
936
+ onTailEvent: event => io.print(io.json
937
+ ? JSON.stringify(event)
938
+ : hasFlag(args, '--quiet')
939
+ ? formatRunEventTypeLine(event)
940
+ : formatRunEventDigest(event)),
766
941
  tailImpl: tail
767
942
  ? async ({ fetcher, runId, onEvent }) => {
768
943
  await tailWorkbenchRun({ fetcher, runId, onEvent: event => onEvent?.(event) });
package/dist/index.js CHANGED
@@ -29,6 +29,8 @@ import { connectorsCommand } from './connectors-cli.js';
29
29
  import { ARTIFACT_MAX_BYTES, runnerOptimizerCommand } from './artifact-pull.js';
30
30
  import { killSwitchCommand } from './kill-switch-cli.js';
31
31
  import { egressAllowlistCommand } from './egress-allowlist-cli.js';
32
+ import { acceptScoreRunCommand } from './scores-accept-cli.js';
33
+ import { listOptimizationRunsCommand } from './optimizations-list-cli.js';
32
34
  import { teamConnectorsCommand } from './team-connectors-cli.js';
33
35
  import { manifestsCommand } from './manifests-cli.js';
34
36
  import { workbenchCommand } from './workbench-cli.js';
@@ -36,6 +38,7 @@ import { hostedCommand } from './hosted-session-cli.js';
36
38
  import { workspaceSyncCommand } from './workspace-sync.js';
37
39
  import { runGitCredential } from './git-credential.js';
38
40
  import { pushPromptDraft } from './prompt-draft-push.js';
41
+ import { assertSnapshotManifestConfined, verifyGepaRunnerDirsFromArgs, verifyRunnerDirRegistered } from './runner-dir-verify.js';
39
42
  import { runScorersRegister } from './scorer-draft-push.js';
40
43
  import { runZipArtifactPush } from './zip-draft-push.js';
41
44
  import { runGithubLink, runInteractiveHostedSetup } from './github-setup.js';
@@ -1504,8 +1507,10 @@ async function submitScoreResults() {
1504
1507
  printJson(data);
1505
1508
  return;
1506
1509
  }
1510
+ // ALI-1175: label the stored provenance — a submitted score is a client-reported claim.
1507
1511
  printLine(`Submitted score ${sanitizeTerminalText(data.scoreRun.id)} ` +
1508
- `(${formatPercent(data.scoreRun.scoreValue)})`);
1512
+ `(${formatPercent(data.scoreRun.scoreValue)})` +
1513
+ (data.scoreRun.provenance ? ` [${sanitizeTerminalText(String(data.scoreRun.provenance).replace(/_/g, '-'))}]` : ''));
1509
1514
  }
1510
1515
  function looksLikeAggregateScoreObject(value) {
1511
1516
  return (value.scoreValue !== undefined ||
@@ -1640,11 +1645,23 @@ async function execScorer() {
1640
1645
  : scoreResult
1641
1646
  ? scoreResult.scoreValue
1642
1647
  : null;
1648
+ // ALI-1175 (round 5 thread 6 + round 6 thread 2): a scorers-exec score is an
1649
+ // agent-reported claim whether or not it was submitted — label both the
1650
+ // submitted (scoreRun.provenance) and the --no-submit (top-level provenance)
1651
+ // outputs, so a displayed value is never presented as unlabeled evidence.
1652
+ const execProvenance = scoreRun && typeof scoreRun.provenance === 'string'
1653
+ ? scoreRun.provenance
1654
+ : typeof data.provenance === 'string'
1655
+ ? data.provenance
1656
+ : null;
1657
+ const provenanceSuffix = execProvenance
1658
+ ? ` [${sanitizeTerminalText(execProvenance.replace(/_/g, '-'))}]`
1659
+ : '';
1643
1660
  if (scoreRun && typeof scoreRun.id === 'string') {
1644
- printLine(`Executed scorer ${sanitizeTerminalText(scorerVersionId)} -> score ${sanitizeTerminalText(scoreRun.id)} (${formatPercent(numberFromUnknown(scoreValue))})`);
1661
+ printLine(`Executed scorer ${sanitizeTerminalText(scorerVersionId)} -> score ${sanitizeTerminalText(scoreRun.id)} (${formatPercent(numberFromUnknown(scoreValue))})${provenanceSuffix}`);
1645
1662
  }
1646
1663
  else {
1647
- printLine(`Executed scorer ${sanitizeTerminalText(scorerVersionId)} (${formatPercent(numberFromUnknown(scoreValue))})`);
1664
+ printLine(`Executed scorer ${sanitizeTerminalText(scorerVersionId)} (${formatPercent(numberFromUnknown(scoreValue))})${provenanceSuffix}`);
1648
1665
  }
1649
1666
  if (outPath) {
1650
1667
  printLine(`Wrote scorer result to ${sanitizeTerminalText(outPath)}`);
@@ -1866,34 +1883,8 @@ async function exportOptimizationRun() {
1866
1883
  writeTextFileEnsuringDir(filename, `${JSON.stringify(data, null, 2)}\n`);
1867
1884
  printLine(`Saved optimization export to ${sanitizeTerminalText(filename)}`);
1868
1885
  }
1869
- // ALI-1073: enumerate the project's optimization runs (id, status, refs, and the
1870
- // promoted-version link if any) so an agent/human can discover a prior run.
1871
- async function listOptimizationRuns() {
1872
- const project = getArg('--project') || await resolveProjectSlug(null);
1873
- const response = await authedFetch(`/api/cli/optimization-runs?project=${encodeURIComponent(project)}`);
1874
- if (!response.ok) {
1875
- throw new Error(`Failed to list optimization runs: ${await response.text()}`);
1876
- }
1877
- const data = await parseJsonResponse(response, 'Optimization runs list');
1878
- if (hasJsonFlag()) {
1879
- printJson(data);
1880
- return;
1881
- }
1882
- const rows = data.optimizationRuns || [];
1883
- if (rows.length === 0) {
1884
- printLine('No optimization runs found for this project.');
1885
- return;
1886
- }
1887
- const idWidth = Math.max(2, ...rows.map(row => String(row.id).length));
1888
- const statusWidth = Math.max(6, ...rows.map(row => String(row.status).length));
1889
- printLine(`${'ID'.padEnd(idWidth)} ${'STATUS'.padEnd(statusWidth)} BEST PROMOTED_VERSION`);
1890
- printLine(`${'-'.repeat(idWidth)} ${'-'.repeat(statusWidth)} ${'-'.repeat(5)} ${'-'.repeat('PROMOTED_VERSION'.length)}`);
1891
- for (const row of rows) {
1892
- const best = row.bestScore === null || row.bestScore === undefined ? '-' : String(row.bestScore);
1893
- const promoted = row.resultPromptVersionId || '-';
1894
- printLine(`${String(row.id).padEnd(idWidth)} ${String(row.status).padEnd(statusWidth)} ${best.padEnd(5)} ${promoted}`);
1895
- }
1896
- }
1886
+ // ALI-1073 list moved to optimizations-list-cli.ts (line ratchet, ALI-976);
1887
+ // ALI-1175 labels its best scores as agent-reported evidence there.
1897
1888
  function removeFlagWithValue(args, flag) {
1898
1889
  const filtered = [];
1899
1890
  for (let index = 0; index < args.length; index += 1) {
@@ -1915,31 +1906,50 @@ function bundledOrizuGepaPythonPath() {
1915
1906
  async function runGepaOptimization() {
1916
1907
  const project = getArg('--project') || await resolveProjectSlug(null);
1917
1908
  const baseUrl = getBaseUrl();
1918
- // Uniform resolution (ALI-1090): honors ORIZU_TOKEN / ORIZU_TOKEN_FILE before
1919
- // credentials.json so hosted sandboxes can run optimizations. Read at spawn
1920
- // time — the child gets the freshest bearer available at launch.
1921
- const token = resolveAuthTokenForBaseUrl(baseUrl);
1922
1909
  const python = getArg('--python') || process.env.PYTHON || 'python3';
1923
1910
  const bundledPythonPath = bundledOrizuGepaPythonPath();
1924
1911
  let forwardedArgs = removeFlagWithValue(cliArgs.slice(2), '--python');
1925
- if (!forwardedArgs.includes('--project')) {
1926
- forwardedArgs = ['--project', project, ...forwardedArgs];
1927
- }
1928
- const pythonPathEntries = [
1929
- bundledPythonPath,
1930
- process.env.PYTHONPATH,
1931
- ].filter((entry) => Boolean(entry));
1932
- const result = spawnSync(python, ['-m', 'orizu_gepa.cli', ...forwardedArgs], {
1933
- stdio: 'inherit',
1934
- env: {
1935
- ...process.env,
1936
- ORIZU_API_URL: baseUrl,
1937
- ORIZU_TOKEN: token,
1938
- ORIZU_PROJECT: project,
1939
- PYTHONPATH: pythonPathEntries.join(delimiter),
1940
- PYTHONUNBUFFERED: process.env.PYTHONUNBUFFERED || '1',
1941
- },
1942
- });
1912
+ // ALI-1159 (ADR-007): GEPA executes ad-hoc local runner dirs while
1913
+ // attributing records to registered runner version ids — verify the bytes
1914
+ // ARE those versions before the optimizer spawns. Scans the forwarded argv
1915
+ // itself (both argparse flag forms), fail-closed on half-populated pairs,
1916
+ // and REWRITES the dir flags to verified snapshots (the exact hashed bytes)
1917
+ // so a post-hash mutation of the original dirs cannot change what runs.
1918
+ const verified = await verifyGepaRunnerDirsFromArgs(forwardedArgs);
1919
+ forwardedArgs = verified.args;
1920
+ let result;
1921
+ try {
1922
+ // Uniform resolution (ALI-1090): honors ORIZU_TOKEN / ORIZU_TOKEN_FILE before
1923
+ // credentials.json so hosted sandboxes can run optimizations. Read at spawn
1924
+ // time — AFTER the verification awaits above, so a hosted token-file
1925
+ // rotation crossing the hash + lookup round-trips cannot hand the child a
1926
+ // stale bearer.
1927
+ const token = resolveAuthTokenForBaseUrl(baseUrl);
1928
+ if (!forwardedArgs.includes('--project')) {
1929
+ forwardedArgs = ['--project', project, ...forwardedArgs];
1930
+ }
1931
+ const pythonPathEntries = [
1932
+ bundledPythonPath,
1933
+ process.env.PYTHONPATH,
1934
+ ].filter((entry) => Boolean(entry));
1935
+ result = spawnSync(python, ['-m', 'orizu_gepa.cli', ...forwardedArgs], {
1936
+ stdio: 'inherit',
1937
+ env: {
1938
+ ...process.env,
1939
+ ORIZU_API_URL: baseUrl,
1940
+ ORIZU_TOKEN: token,
1941
+ ORIZU_PROJECT: project,
1942
+ // Integrity handshake (ALI-1159): the Python entrypoint refuses
1943
+ // runner dirs that are not in this wrapper-verified list.
1944
+ ORIZU_VERIFIED_RUNNER_DIRS: JSON.stringify(verified.verifiedDirs),
1945
+ PYTHONPATH: pythonPathEntries.join(delimiter),
1946
+ PYTHONUNBUFFERED: process.env.PYTHONUNBUFFERED || '1',
1947
+ },
1948
+ });
1949
+ }
1950
+ finally {
1951
+ verified.cleanup();
1952
+ }
1943
1953
  if (result.error) {
1944
1954
  throw result.error;
1945
1955
  }
@@ -2718,12 +2728,25 @@ async function resolveSetupTeam(teamSlugArg, noInput, dryRun) {
2718
2728
  }
2719
2729
  return createTeamOnServer(name);
2720
2730
  }
2721
- async function resolveSetupProjects(team) {
2731
+ async function resolveSetupProjects(team, noInput, dryRun) {
2722
2732
  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.`);
2733
+ if (projects.length > 0) {
2734
+ return projects;
2735
+ }
2736
+ // ALI-1140: a just-created team (or a pre-existing empty one) has no projects
2737
+ // by definition — continue the wizard into creating the first project instead
2738
+ // of dead-ending on the path setup itself offered.
2739
+ if (noInput || dryRun) {
2740
+ 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
2741
  }
2726
- return projects;
2742
+ printLine(` Team '${sanitizeTerminalText(team.slug)}' has no projects yet. Let's create the first one.`);
2743
+ const name = await askText(' Project name?', '');
2744
+ if (!name) {
2745
+ throw new Error('Project name is required to create a project.');
2746
+ }
2747
+ const project = await createProjectOnServer(team.slug, name);
2748
+ printLine(` Created project ${sanitizeTerminalText(`${project.teamSlug}/${project.slug}`)}`);
2749
+ return [project];
2727
2750
  }
2728
2751
  function projectSeedsFromProjects(projects) {
2729
2752
  return projects.map(project => ({
@@ -2848,7 +2871,7 @@ async function setupCommand() {
2848
2871
  let projects;
2849
2872
  if (auth.state === 'signed-in' && !validateOnly) {
2850
2873
  const team = await resolveSetupTeam(teamSlug, noInput, dryRun);
2851
- const serverProjects = await resolveSetupProjects(team);
2874
+ const serverProjects = await resolveSetupProjects(team, noInput, dryRun);
2852
2875
  teamSlug = team.slug;
2853
2876
  teamId = team.id;
2854
2877
  projects = projectSeedsFromProjects(serverProjects);
@@ -2866,10 +2889,10 @@ async function setupCommand() {
2866
2889
  hostedAttached = (await runInteractiveHostedSetup({ teamSlug: team.slug, targetDir: workspaceRoot, local: hasArg('--local'), activeInstallation: link.status === 'active' }, { print: printLine, fetcher: authedFetch })).attached;
2867
2890
  }
2868
2891
  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).`);
2892
+ 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
2893
  }
2871
2894
  }
2872
- }
2895
+ } // 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
2896
  }
2874
2897
  else if (!teamSlug && !noInput && !validateOnly) {
2875
2898
  teamSlug = await askText(' Team slug?', 'local-team');
@@ -4622,10 +4645,7 @@ async function materializeRunnerVersion(runnerVersionId) {
4622
4645
  rmSync(tempDir, { recursive: true, force: true });
4623
4646
  throw new Error(`unzip failed: ${sanitizeTerminalText(result.stderr || result.stdout || '')}`);
4624
4647
  }
4625
- return {
4626
- runnerDir,
4627
- cleanup: () => rmSync(tempDir, { recursive: true, force: true }),
4628
- };
4648
+ return { runnerDir, cleanup: () => rmSync(tempDir, { recursive: true, force: true }) };
4629
4649
  }
4630
4650
  async function runnersExec() {
4631
4651
  const prompt = getArg('--prompt');
@@ -4667,12 +4687,28 @@ async function runnersExec() {
4667
4687
  throw new Error(`Failed to fetch runner execution context: ${await contextResponse.text()}`);
4668
4688
  }
4669
4689
  const context = await parseJsonResponse(contextResponse, 'Runner exec context');
4690
+ const resolvedRunnerVersionId = runnerVersion || context.prompt.runnerVersionId;
4691
+ if (!resolvedRunnerVersionId) {
4692
+ throw new Error('Runner exec context did not resolve a runner version id; pass --runner-version explicitly');
4693
+ }
4694
+ // ALI-1159 (ADR-007): results stamp the registered runner_version_id, so
4695
+ // ad-hoc --runner-dir bytes must BE that registered version — and execution
4696
+ // runs the verified SNAPSHOT (exact hashed bytes), never the mutable dir.
4670
4697
  const materializedRunner = runnerDirArg
4671
- ? { runnerDir: expandHomePath(runnerDirArg), cleanup: () => { } }
4672
- : await materializeRunnerVersion(runnerVersion || context.prompt.runnerVersionId);
4698
+ ? await (async () => {
4699
+ const verified = await verifyRunnerDirRegistered({
4700
+ runnerVersionId: resolvedRunnerVersionId,
4701
+ dir: runnerDirArg,
4702
+ flag: '--runner-dir',
4703
+ });
4704
+ return { runnerDir: verified.snapshotDir, cleanup: verified.cleanup };
4705
+ })()
4706
+ : await materializeRunnerVersion(resolvedRunnerVersionId);
4673
4707
  const runnerDir = materializedRunner.runnerDir;
4674
- const manifest = readRunnerManifest(runnerDir);
4675
4708
  try {
4709
+ assertSnapshotManifestConfined(runnerDir, runnerDirArg ? '--runner-dir' : '--runner-version');
4710
+ // Inside the try (codex round-6 P3): a bad manifest must not leak the dir.
4711
+ const manifest = readRunnerManifest(runnerDir);
4676
4712
  if (manifest.supports_body_kind && !manifest.supports_body_kind.includes(context.prompt.bodyKind)) {
4677
4713
  throw new Error(`Runner does not support prompt body kind '${context.prompt.bodyKind}'. Supported kinds: ${manifest.supports_body_kind.join(', ')}`);
4678
4714
  }
@@ -4983,6 +5019,10 @@ export async function main(rawArgs = process.argv.slice(2)) {
4983
5019
  await submitScoreResults();
4984
5020
  return;
4985
5021
  }
5022
+ if (command === 'scores' && subcommand === 'accept') {
5023
+ await acceptScoreRunCommand(cliArgs.slice(2), { json: hasJsonFlag(), print: printLine, resolveProjectSlug });
5024
+ return;
5025
+ }
4986
5026
  if (command === 'optimizations' && subcommand === 'start') {
4987
5027
  await startOptimizationRun();
4988
5028
  return;
@@ -4996,7 +5036,7 @@ export async function main(rawArgs = process.argv.slice(2)) {
4996
5036
  return;
4997
5037
  }
4998
5038
  if (command === 'optimizations' && subcommand === 'list') {
4999
- await listOptimizationRuns();
5039
+ await listOptimizationRunsCommand(cliArgs.slice(2), { json: hasJsonFlag(), print: printLine, resolveProjectSlug });
5000
5040
  return;
5001
5041
  }
5002
5042
  if (command === 'optimizations' &&
@@ -8,6 +8,16 @@
8
8
  * (killed sessions stay ended). Human ADMIN only: the server rejects agent
9
9
  * bearers and non-admins. Pure command logic with an injected fetcher so the
10
10
  * entry point owns argument parsing and human/JSON output.
11
+ *
12
+ * Containment (ALI-1166): the guarantee is session-end + per-request
13
+ * verification + token-row revocation at engage — a bearer bound to an ended
14
+ * session can never verify again, and ended sessions are terminal
15
+ * (DB-enforced; no post-kill resurrection).
16
+ * `tokensRevoked` in the summary counts the persisted agent token rows the
17
+ * engage revoked (trigger cascade + belt, one transaction); it may include
18
+ * expired or already-ended-session stragglers that were never live bearers at
19
+ * kill time. `--json` output is the server's summary passed through, including
20
+ * `containment: "session-end"`.
11
21
  */
12
22
  import { authedFetch } from './http.js';
13
23
  async function responseMessage(response) {
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}`);