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.
- package/dist/github-setup.js +60 -6
- package/dist/help.js +15 -3
- package/dist/hosted-session-cli.js +129 -9
- package/dist/index.js +20 -7
- package/dist/merge-job.js +26 -3
- package/dist/option-validation.js +54 -0
- package/dist/workbench-cli.js +31 -5
- package/package.json +1 -1
- package/src/github-setup.ts +66 -7
- package/src/help.ts +15 -3
- package/src/hosted-session-cli.ts +140 -9
- package/src/index.ts +22 -6
- package/src/merge-job.ts +25 -6
- package/src/option-validation.ts +63 -0
- package/src/workbench-cli.ts +34 -5
- package/vendor/skills/orizu-cli/SKILL.md +2 -2
package/dist/github-setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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.
|
|
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
|
|
634
|
-
model
|
|
635
|
-
reasoningEffort
|
|
636
|
-
projectSlug
|
|
637
|
-
title
|
|
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:
|
|
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
|
|
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
|
|
2724
|
-
|
|
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
|
-
|
|
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)}
|
|
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
|
-
|
|
316
|
-
|
|
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
|
+
}
|
package/dist/workbench-cli.js
CHANGED
|
@@ -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
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
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
package/src/github-setup.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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.
|
|
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
|
|
843
|
-
model
|
|
844
|
-
reasoningEffort
|
|
845
|
-
projectSlug
|
|
846
|
-
title
|
|
897
|
+
durationMinutes,
|
|
898
|
+
model,
|
|
899
|
+
reasoningEffort,
|
|
900
|
+
projectSlug,
|
|
901
|
+
title,
|
|
902
|
+
}
|
|
903
|
+
const serializedPayload = JSON.stringify(payload)
|
|
904
|
+
const payloadBytes = new TextEncoder().encode(serializedPayload).byteLength
|
|
905
|
+
if (payloadBytes > MAX_HOSTED_START_BODY_BYTES) {
|
|
906
|
+
const message =
|
|
907
|
+
`Hosted task prompt is too large: request body is ${payloadBytes} bytes; ` +
|
|
908
|
+
`maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`
|
|
909
|
+
if (io.json) io.print(JSON.stringify({ ok: false, error: message }))
|
|
910
|
+
else io.printErr?.(message)
|
|
911
|
+
return 1
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Mirror the exact JSON shape that reaches the session Durable Object after
|
|
915
|
+
// the API creates its UUID run/session rows, normalizes optional strings, and
|
|
916
|
+
// the Worker injects sessionId. This second guard keeps near-boundary prompts
|
|
917
|
+
// from triggering server-side state that would only be compensated after a
|
|
918
|
+
// coordinator 413. Keep this field list in sync with pokeCoordinatorStart in
|
|
919
|
+
// app/api/cli/hosted-sessions/route.ts.
|
|
920
|
+
const normalizedModel = model?.trim() || undefined
|
|
921
|
+
const normalizedReasoningEffort = reasoningEffort?.trim() || undefined
|
|
922
|
+
const coordinatorPayload = JSON.stringify({
|
|
923
|
+
runId: GENERATED_HOSTED_UUID_PLACEHOLDER,
|
|
924
|
+
task,
|
|
925
|
+
durationMinutes:
|
|
926
|
+
durationMinutes !== undefined && durationMinutes > 0
|
|
927
|
+
? durationMinutes
|
|
928
|
+
: undefined,
|
|
929
|
+
model: normalizedModel,
|
|
930
|
+
reasoningEffort: normalizedReasoningEffort,
|
|
931
|
+
sessionId: GENERATED_HOSTED_UUID_PLACEHOLDER,
|
|
932
|
+
})
|
|
933
|
+
const coordinatorPayloadBytes = new TextEncoder().encode(coordinatorPayload).byteLength
|
|
934
|
+
if (coordinatorPayloadBytes > MAX_HOSTED_START_BODY_BYTES) {
|
|
935
|
+
const message =
|
|
936
|
+
`Hosted task prompt is too large: coordinator request body would be ${coordinatorPayloadBytes} bytes ` +
|
|
937
|
+
`after generated run/session IDs; maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`
|
|
938
|
+
if (io.json) io.print(JSON.stringify({ ok: false, error: message }))
|
|
939
|
+
else io.printErr?.(message)
|
|
940
|
+
return 1
|
|
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:
|
|
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
|
|
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
|
|
3614
|
-
|
|
3613
|
+
if (projects.length > 0) {
|
|
3614
|
+
return projects
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
// ALI-1140: a just-created team (or a pre-existing empty one) has no projects
|
|
3618
|
+
// by definition — continue the wizard into creating the first project instead
|
|
3619
|
+
// of dead-ending on the path setup itself offered.
|
|
3620
|
+
if (noInput || dryRun) {
|
|
3621
|
+
throw new Error(
|
|
3622
|
+
`Team '${team.slug}' has no projects. Create one with \`orizu projects create --name <name> --team ${team.slug}\`, then re-run setup.`
|
|
3623
|
+
)
|
|
3615
3624
|
}
|
|
3616
3625
|
|
|
3617
|
-
|
|
3626
|
+
printLine(` Team '${sanitizeTerminalText(team.slug)}' has no projects yet. Let's create the first one.`)
|
|
3627
|
+
const name = await askText(' Project name?', '')
|
|
3628
|
+
if (!name) {
|
|
3629
|
+
throw new Error('Project name is required to create a project.')
|
|
3630
|
+
}
|
|
3631
|
+
const project = await createProjectOnServer(team.slug, name)
|
|
3632
|
+
printLine(` Created project ${sanitizeTerminalText(`${project.teamSlug}/${project.slug}`)}`)
|
|
3633
|
+
return [project]
|
|
3618
3634
|
}
|
|
3619
3635
|
|
|
3620
3636
|
function projectSeedsFromProjects(projects: Project[]): WorkspaceProjectSeed[] {
|
|
@@ -3757,13 +3773,13 @@ async function setupCommand() {
|
|
|
3757
3773
|
|
|
3758
3774
|
if (auth.state === 'signed-in' && !validateOnly) {
|
|
3759
3775
|
const team = await resolveSetupTeam(teamSlug, noInput, dryRun)
|
|
3760
|
-
const serverProjects = await resolveSetupProjects(team)
|
|
3776
|
+
const serverProjects = await resolveSetupProjects(team, noInput, dryRun)
|
|
3761
3777
|
teamSlug = team.slug
|
|
3762
3778
|
teamId = team.id
|
|
3763
3779
|
projects = projectSeedsFromProjects(serverProjects)
|
|
3764
3780
|
printLine(` ${formatProjectSetupProgress(projects.length)}`)
|
|
3765
3781
|
if (!hasArg('--local') && !noInput && !dryRun) { let link: GithubLinkResult | null = null; try { link = await runGithubLink(team.slug, { print: printLine, fetcher: authedFetch, openUrl: openInBrowser, confirm: (org) => askYesNo(` Connect this team to GitHub org '${org}'?`, true) }) } catch (error) { printLine(` GitHub link skipped: ${getErrorMessage(error)} Run \`orizu github link --team ${team.slug}\` to finish.`) }
|
|
3766
|
-
if (link) { try { hostedAttached = (await runInteractiveHostedSetup({ teamSlug: team.slug, targetDir: workspaceRoot, local: hasArg('--local'), activeInstallation: link.status === 'active' }, { print: printLine, fetcher: authedFetch })).attached } catch (error) { throw new Error(`Hosted setup failed: ${getErrorMessage(error)}
|
|
3782
|
+
if (link) { try { hostedAttached = (await runInteractiveHostedSetup({ teamSlug: team.slug, targetDir: workspaceRoot, local: hasArg('--local'), activeInstallation: link.status === 'active' }, { print: printLine, fetcher: authedFetch })).attached } catch (error) { throw new Error(`Hosted setup failed: ${getErrorMessage(error)} Re-running \`orizu setup\` is safe — provisioning adopts the existing workbench repo and resumes where it left off (or pass --local to scaffold a local-only workspace).`) } } } // hint must not blame local state (ALI-1143): provisioning failures are server-side, and both provisioning (adopt) and the clone step (skip-if-cloned, ALI-1069) are idempotent on re-run
|
|
3767
3783
|
} else if (!teamSlug && !noInput && !validateOnly) {
|
|
3768
3784
|
teamSlug = await askText(' Team slug?', 'local-team')
|
|
3769
3785
|
}
|
package/src/merge-job.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
type DeployKeyMergeResult,
|
|
45
45
|
type GitCommandRunner,
|
|
46
46
|
} from './deploy-key-merge-core.js'
|
|
47
|
+
import { assertSecureTokenTransport } from './http.js'
|
|
47
48
|
import { redactSecrets } from './secret-redaction.js'
|
|
48
49
|
|
|
49
50
|
export type MergeJobFetch = (url: string, init?: RequestInit) => Promise<Response>
|
|
@@ -67,7 +68,7 @@ export interface MergeJobEnv {
|
|
|
67
68
|
|
|
68
69
|
export function resolveMergeJobEnv(
|
|
69
70
|
env: Record<string, string | undefined>
|
|
70
|
-
): { ok: true; value: MergeJobEnv } | { ok: false; missing: string[] } {
|
|
71
|
+
): { ok: true; value: MergeJobEnv } | { ok: false; missing: string[]; invalid: string[] } {
|
|
71
72
|
const missing: string[] = []
|
|
72
73
|
const req = (name: string): string => {
|
|
73
74
|
const value = env[name]?.trim()
|
|
@@ -78,7 +79,25 @@ export function resolveMergeJobEnv(
|
|
|
78
79
|
const jobKey = req('ORIZU_MERGE_JOB_ID')
|
|
79
80
|
const coordinatorUrl = req('ORIZU_COORDINATOR_URL')
|
|
80
81
|
const baseUrl = req('ORIZU_BASE_URL')
|
|
81
|
-
if (missing.length > 0) return { ok: false, missing }
|
|
82
|
+
if (missing.length > 0) return { ok: false, missing, invalid: [] }
|
|
83
|
+
|
|
84
|
+
const origins = [
|
|
85
|
+
['ORIZU_COORDINATOR_URL', coordinatorUrl],
|
|
86
|
+
['ORIZU_BASE_URL', baseUrl],
|
|
87
|
+
] as const
|
|
88
|
+
const invalid = origins.flatMap(([name, value]) => {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(value)
|
|
91
|
+
// Userinfo is never accepted, including on loopback: rejecting it at the
|
|
92
|
+
// env boundary keeps configured credentials out of every retry/error path.
|
|
93
|
+
if (parsed.username || parsed.password) return [name]
|
|
94
|
+
assertSecureTokenTransport(value)
|
|
95
|
+
return []
|
|
96
|
+
} catch {
|
|
97
|
+
return [name]
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
if (invalid.length > 0) return { ok: false, missing: [], invalid }
|
|
82
101
|
return {
|
|
83
102
|
ok: true,
|
|
84
103
|
value: {
|
|
@@ -445,10 +464,10 @@ export interface MergeJobCommandIo {
|
|
|
445
464
|
export async function mergeJobCommand(io: MergeJobCommandIo): Promise<number> {
|
|
446
465
|
const resolved = resolveMergeJobEnv(process.env)
|
|
447
466
|
if (!resolved.ok) {
|
|
448
|
-
|
|
449
|
-
`
|
|
450
|
-
|
|
451
|
-
)
|
|
467
|
+
const detail = resolved.missing.length > 0
|
|
468
|
+
? `missing required env: ${resolved.missing.join(', ')}`
|
|
469
|
+
: `invalid or insecure origin env: ${resolved.invalid.join(', ')}`
|
|
470
|
+
io.printErr?.(`merge-job: ${detail} (the MergeJobCoordinator sandbox env contract)`)
|
|
452
471
|
return 1
|
|
453
472
|
}
|
|
454
473
|
const log = (line: string): void => io.printErr?.(`[merge-job] ${line}`)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function optionName(arg: string): string {
|
|
2
|
+
const equalsIndex = arg.indexOf('=')
|
|
3
|
+
return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function isNegativeNumericValue(arg: string): boolean {
|
|
7
|
+
return arg.length > 1 && !arg.startsWith('--') && Number.isFinite(Number(arg))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isUnambiguouslySingleDashValue(arg: string): boolean {
|
|
11
|
+
// A short option cannot contain whitespace immediately after its dash. This
|
|
12
|
+
// keeps Markdown-list prompts such as `--task "- fix the test"` available
|
|
13
|
+
// without letting a misspelled option such as `--task -taks` get swallowed.
|
|
14
|
+
return /^-\s/.test(arg) || isNegativeNumericValue(arg)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function followsValueOption(
|
|
18
|
+
args: readonly string[],
|
|
19
|
+
index: number,
|
|
20
|
+
valueOptions: ReadonlySet<string>
|
|
21
|
+
): boolean {
|
|
22
|
+
if (index === 0) return false
|
|
23
|
+
const previous = args[index - 1]
|
|
24
|
+
return previous.startsWith('--') && !previous.includes('=') && valueOptions.has(previous)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Return the first dash-prefixed option that is not in a command's explicit
|
|
29
|
+
* allowlist. An unambiguously value-like single-dash token immediately
|
|
30
|
+
* following a value-taking option is data: this keeps inputs such as
|
|
31
|
+
* `--duration -5` and `--task "- fix the test"` available without allowing a
|
|
32
|
+
* stray token or mistyped short option.
|
|
33
|
+
*
|
|
34
|
+
* `valueOptions` permits the unambiguous `--option=value` form. A separate
|
|
35
|
+
* token beginning with `--`, or an option-like single-dash word, is parsed as
|
|
36
|
+
* an option so a missing value cannot swallow a typo. Callers whose value is
|
|
37
|
+
* itself option-like can use the inline form; Markdown list markers and finite
|
|
38
|
+
* negative numbers remain safe as separate values.
|
|
39
|
+
*/
|
|
40
|
+
export function findUnknownOption(
|
|
41
|
+
args: readonly string[],
|
|
42
|
+
allowedOptions: ReadonlySet<string>,
|
|
43
|
+
valueOptions: ReadonlySet<string> = new Set()
|
|
44
|
+
): string | null {
|
|
45
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
46
|
+
const arg = args[index]
|
|
47
|
+
if (!arg.startsWith('-') || arg === '-') continue
|
|
48
|
+
if (!arg.startsWith('--')) {
|
|
49
|
+
if (isUnambiguouslySingleDashValue(arg) && followsValueOption(args, index, valueOptions)) continue
|
|
50
|
+
return arg
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const name = optionName(arg)
|
|
54
|
+
if (!allowedOptions.has(name)) return name
|
|
55
|
+
|
|
56
|
+
const hasInlineValue = arg.length > name.length
|
|
57
|
+
if (hasInlineValue) {
|
|
58
|
+
if (!valueOptions.has(name)) return arg
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
package/src/workbench-cli.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from 'child_process'
|
|
10
10
|
|
|
11
11
|
import { authedFetch } from './http.js'
|
|
12
|
+
import { findUnknownOption } from './option-validation.js'
|
|
12
13
|
import { getWorkspaceRoot, workspaceExists } from './workspace.js'
|
|
13
14
|
import { attachedWorkspaceId, stringOrNull } from './workspace-sync.js'
|
|
14
15
|
|
|
@@ -269,6 +270,19 @@ const DEFAULT_TAIL_INTERVAL_SECONDS = 2
|
|
|
269
270
|
// hot-spin against the API while a non-terminal run is quiet. Draining full
|
|
270
271
|
// pages intentionally skips the sleep — that is catch-up, not polling.
|
|
271
272
|
const MIN_TAIL_INTERVAL_MS = 500
|
|
273
|
+
const SESSION_START_OPTIONS = new Set(['--project', '--workspace', '--json'])
|
|
274
|
+
const WORKBENCH_VALUE_OPTIONS = new Set([
|
|
275
|
+
'--after',
|
|
276
|
+
'--interval',
|
|
277
|
+
'--message',
|
|
278
|
+
'--project',
|
|
279
|
+
'--run',
|
|
280
|
+
'--session',
|
|
281
|
+
'--status',
|
|
282
|
+
'--summary',
|
|
283
|
+
'--title',
|
|
284
|
+
'--workspace',
|
|
285
|
+
])
|
|
272
286
|
|
|
273
287
|
function defaultSleep(ms: number): Promise<void> {
|
|
274
288
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
@@ -774,11 +788,19 @@ function hasFlag(args: string[], flag: string): boolean {
|
|
|
774
788
|
}
|
|
775
789
|
|
|
776
790
|
function argValue(args: string[], flag: string): string | null {
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
791
|
+
const inlinePrefix = `${flag}=`
|
|
792
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
793
|
+
const arg = args[index]
|
|
794
|
+
if (arg.startsWith(inlinePrefix)) {
|
|
795
|
+
return arg.slice(inlinePrefix.length)
|
|
796
|
+
}
|
|
797
|
+
if (arg === flag) {
|
|
798
|
+
return index + 1 < args.length && !args[index + 1].startsWith('--')
|
|
799
|
+
? args[index + 1]
|
|
800
|
+
: null
|
|
801
|
+
}
|
|
780
802
|
}
|
|
781
|
-
return
|
|
803
|
+
return null
|
|
782
804
|
}
|
|
783
805
|
|
|
784
806
|
function numberArg(args: string[], flag: string): number | undefined {
|
|
@@ -795,7 +817,7 @@ function positionalArgs(args: string[]): string[] {
|
|
|
795
817
|
for (let i = 0; i < args.length; i += 1) {
|
|
796
818
|
const arg = args[i]
|
|
797
819
|
if (arg.startsWith('--')) {
|
|
798
|
-
if (argValue(args, arg) !== null) {
|
|
820
|
+
if (!arg.includes('=') && WORKBENCH_VALUE_OPTIONS.has(arg) && argValue(args.slice(i), arg) !== null) {
|
|
799
821
|
i += 1
|
|
800
822
|
}
|
|
801
823
|
continue
|
|
@@ -1017,6 +1039,13 @@ async function dispatchWorkbenchCommand(args: string[], io: WorkbenchCommandIo):
|
|
|
1017
1039
|
// not run from inside it (defaults to cwd).
|
|
1018
1040
|
const workspaceDir = argValue(args, '--workspace') ?? undefined
|
|
1019
1041
|
if (subcommand === 'start') {
|
|
1042
|
+
const unknownOption = findUnknownOption(args, SESSION_START_OPTIONS, WORKBENCH_VALUE_OPTIONS)
|
|
1043
|
+
if (unknownOption) {
|
|
1044
|
+
return emitJsonError(
|
|
1045
|
+
io,
|
|
1046
|
+
`unknown option ${unknownOption}\nUsage: orizu session start [--project <team/project>] [--workspace <dir>] [--json]`
|
|
1047
|
+
)
|
|
1048
|
+
}
|
|
1020
1049
|
const result = await runSessionStart({
|
|
1021
1050
|
fetcher,
|
|
1022
1051
|
cwd: workspaceDir,
|
|
@@ -199,5 +199,5 @@ Detailed walkthrough — GEPA mechanics, Orizu-tracked optimization, optional DS
|
|
|
199
199
|
|
|
200
200
|
- **Orizu is the source of truth for run metadata and pointers**: production/default prompt/scorer/runner pointers, version lineage, score runs, optimization runs, and report history all live in Orizu — query them with `orizu ...` commands (e.g. `orizu prompts list`, `orizu prompts versions`, `orizu runs ...`), never by reading repo files.
|
|
201
201
|
- **The repo is the working set (files only)**: source, configs, `App.tsx`, schemas, and local traces under `logs/<run-id>`. It is the scratch space you edit and commit; it does NOT hold the canonical pointer/run state — that is in Orizu.
|
|
202
|
-
- **Hosted sessions ship a pre-authenticated CLI**: inside a hosted agent sandbox the `orizu` CLI is already authenticated via `ORIZU_TOKEN_FILE` (with `ORIZU_BASE_URL` pointing at the API). You do NOT run `orizu login` — just call `orizu ...`. The bearer carries
|
|
203
|
-
- **Human-only actions will 403**: manifest
|
|
202
|
+
- **Hosted sessions ship a pre-authenticated CLI**: inside a hosted agent sandbox the `orizu` CLI is already authenticated via `ORIZU_TOKEN_FILE` (with `ORIZU_BASE_URL` pointing at the API). You do NOT run `orizu login` — just call `orizu ...`. The bearer carries **full team-curator authority while your session is active** (ADR-008): anything a curator-role human can read or write in your team — create/read/update projects, datasets (+ rows/artifact bytes), prompts, scorers, runners/optimizers, apps, tasks, runs, score runs, reports, and promotion-manifest proposals — you can too, across the whole team. (The full per-role access matrix lives in the Orizu repo at `docs/authorization-matrix.md`.)
|
|
203
|
+
- **Human-only actions will 403**: promotion-manifest **approval / apply / reject**, production and default pointer flips, session/credential lifecycle (starting or ending hosted sessions, minting tokens, deploy keys), integration/connector credential reads (secrets reach your sandbox only as environment variables resolved at boot — never via API/DB), and team/member/installation/billing management. Do not attempt them from a hosted session — they return 403 by design. Prepare a promotion manifest and hand it to a human curator to approve and apply.
|