orizu 0.5.10 → 0.5.11
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/daytona-slice-rehearsal.js +2 -2
- package/dist/git-credential.js +10 -7
- package/dist/github-setup.js +18 -2
- package/dist/help.js +16 -11
- package/dist/hosted-boot.js +195 -18
- package/dist/hosted-bootstrap.js +18 -40
- package/dist/hosted-harness-opencode.js +4 -2
- package/dist/hosted-headless.js +17 -6
- package/dist/hosted-loop.js +43 -2
- package/dist/hosted-run-event-sink.js +49 -10
- package/dist/hosted-session-cli.js +4 -0
- package/dist/hosted-skill-staging.js +89 -0
- package/dist/index.js +64 -62
- package/dist/manifests-cli.js +7 -2
- package/dist/preview-runtime.js +21 -4
- package/dist/prompt-draft-push.js +95 -0
- package/dist/report-comments-cli.js +9 -2
- package/dist/sandbox-provider.js +8 -2
- package/dist/scorer-draft-push.js +110 -0
- package/dist/workbench-cli.js +234 -6
- package/dist/workspace-sync.js +9 -2
- package/dist/workspace.js +15 -43
- package/dist/zip-draft-push.js +125 -0
- package/package.json +7 -9
- package/src/daytona-slice-rehearsal.ts +2 -2
- package/src/git-credential.ts +10 -7
- package/src/github-setup.ts +24 -2
- package/src/help.ts +18 -11
- package/src/hosted-boot.ts +227 -17
- package/src/hosted-bootstrap.ts +19 -43
- package/src/hosted-harness-opencode.ts +4 -2
- package/src/hosted-headless.ts +26 -8
- package/src/hosted-loop-lifecycle.ts +8 -0
- package/src/hosted-loop.ts +53 -7
- package/src/hosted-run-event-sink.ts +58 -11
- package/src/hosted-session-cli.ts +4 -0
- package/src/hosted-skill-staging.ts +126 -0
- package/src/index.ts +79 -72
- package/src/manifests-cli.ts +8 -2
- package/src/preview-runtime.ts +27 -4
- package/src/prompt-draft-push.ts +143 -0
- package/src/report-comments-cli.ts +10 -2
- package/src/sandbox-provider.ts +8 -2
- package/src/scorer-draft-push.ts +155 -0
- package/src/workbench-cli.ts +326 -6
- package/src/workspace-sync.ts +9 -2
- package/src/workspace.ts +15 -45
- package/src/zip-draft-push.ts +177 -0
- package/vendor/skills/orizu-cli/references/building-apps.md +2 -0
|
@@ -51,17 +51,17 @@ export function seedLocalBareRepo(baseDir, config) {
|
|
|
51
51
|
const work = mkdtempSync(join(baseDir, 'seed-'));
|
|
52
52
|
gitOrThrow(['init', '--initial-branch=main', work]);
|
|
53
53
|
const ident = ['-c', 'user.email=seed@orizu.local', '-c', 'user.name=Seed'];
|
|
54
|
+
// ALI-1075: no `canonical`/`repoState` liveness fields — manifests carry
|
|
55
|
+
// machine-readable ids only; the DB label is the sole production pointer.
|
|
54
56
|
const teamManifest = {
|
|
55
57
|
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
|
56
58
|
kind: 'team',
|
|
57
59
|
slug: config.teamSlug,
|
|
58
|
-
canonical: { owner: 'repo', repoState: 'source', objectRef: null },
|
|
59
60
|
};
|
|
60
61
|
const projectManifest = {
|
|
61
62
|
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
|
62
63
|
kind: 'project',
|
|
63
64
|
slug: config.projectSlug,
|
|
64
|
-
canonical: { owner: 'repo', repoState: 'draft', objectRef: null },
|
|
65
65
|
};
|
|
66
66
|
writeFileSync(join(work, 'orizu.team.json'), `${JSON.stringify(teamManifest, null, 2)}\n`);
|
|
67
67
|
writeFileSync(join(work, '.gitignore'), '# Orizu workspace policy\n.orizu/\n');
|
package/dist/git-credential.js
CHANGED
|
@@ -45,17 +45,20 @@ export function resolveAttachedWorkspaceId(cwd) {
|
|
|
45
45
|
const manifestPath = join(dir, 'orizu.team.json');
|
|
46
46
|
if (existsSync(manifestPath)) {
|
|
47
47
|
const manifest = readJsonManifest(manifestPath);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
: null;
|
|
52
|
-
if (typeof serviceId === 'string' && serviceId)
|
|
53
|
-
return serviceId;
|
|
48
|
+
// `setup.attachedWorkspaceId` is the attachment id; `canonical.serviceId`
|
|
49
|
+
// is a legacy duplicate kept only as a read fallback for old repos
|
|
50
|
+
// (ALI-1075: fresh manifests carry no `canonical` block).
|
|
54
51
|
const setup = manifest?.setup;
|
|
55
52
|
const attached = setup && typeof setup === 'object' && !Array.isArray(setup)
|
|
56
53
|
? setup.attachedWorkspaceId
|
|
57
54
|
: null;
|
|
58
|
-
|
|
55
|
+
if (typeof attached === 'string' && attached)
|
|
56
|
+
return attached;
|
|
57
|
+
const canonical = manifest?.canonical;
|
|
58
|
+
const serviceId = canonical && typeof canonical === 'object' && !Array.isArray(canonical)
|
|
59
|
+
? canonical.serviceId
|
|
60
|
+
: null;
|
|
61
|
+
return typeof serviceId === 'string' && serviceId ? serviceId : null;
|
|
59
62
|
}
|
|
60
63
|
const parent = dirname(dir);
|
|
61
64
|
if (parent === dir)
|
package/dist/github-setup.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* is immediately rewritten to a clean origin.
|
|
15
15
|
*/
|
|
16
16
|
import { spawnSync } from 'child_process';
|
|
17
|
-
import { mkdirSync } from 'fs';
|
|
17
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
19
|
import { authedFetch } from './http.js';
|
|
20
20
|
import { assertWorkspaceDirUsable } from './workspace.js';
|
|
@@ -196,6 +196,23 @@ export async function runHostedAttach(options, io) {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
const cleanUrl = `https://github.com/${repoFullName}.git`;
|
|
199
|
+
const ensureOrizuDir = io.ensureOrizuDir ?? ((repoDir) => mkdirSync(join(repoDir, '.orizu'), { recursive: true }));
|
|
200
|
+
// Idempotent re-run (ALI-1069): `orizu setup` in an ALREADY-attached workspace
|
|
201
|
+
// reaches here with `targetDir` holding the prior clone (its `.git` plus the
|
|
202
|
+
// committed orizu.team.json — `assertWorkspaceDirUsable` deliberately exempts
|
|
203
|
+
// that dir as the idempotent case). `git clone` refuses ANY non-empty dir, so
|
|
204
|
+
// an unconditional clone would abort the re-run with a misleading "not an empty
|
|
205
|
+
// directory" error. Short-circuit: re-affirm the credential helper config
|
|
206
|
+
// (idempotent) and return without recloning.
|
|
207
|
+
const repoAlreadyCloned = io.repoAlreadyCloned ?? ((dir) => existsSync(join(dir, '.git')));
|
|
208
|
+
if (repoAlreadyCloned(options.targetDir)) {
|
|
209
|
+
io.print(' Workbench repo already attached — skipping clone.');
|
|
210
|
+
gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
|
|
211
|
+
gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
|
|
212
|
+
ensureOrizuDir(options.targetDir);
|
|
213
|
+
io.print(' Attached. Configured the Orizu credential helper for git.');
|
|
214
|
+
return { repoFullName, provisioned, targetDir: options.targetDir };
|
|
215
|
+
}
|
|
199
216
|
io.print(` Cloning ${repoFullName}...`);
|
|
200
217
|
// Clone with the helper inline + the workspace id in the env so the token is
|
|
201
218
|
// brokered on demand and never materializes in the URL or on disk.
|
|
@@ -211,7 +228,6 @@ export async function runHostedAttach(options, io) {
|
|
|
211
228
|
// Persist the helper repo-locally for future pull/push (cwd-resolved).
|
|
212
229
|
gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
|
|
213
230
|
gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
|
|
214
|
-
const ensureOrizuDir = io.ensureOrizuDir ?? ((repoDir) => mkdirSync(join(repoDir, '.orizu'), { recursive: true }));
|
|
215
231
|
ensureOrizuDir(options.targetDir);
|
|
216
232
|
io.print(' Attached. Configured the Orizu credential helper for git.');
|
|
217
233
|
return { repoFullName, provisioned, targetDir: options.targetDir };
|
package/dist/help.js
CHANGED
|
@@ -318,8 +318,8 @@ export const COMMAND_DOCS = [
|
|
|
318
318
|
},
|
|
319
319
|
{
|
|
320
320
|
path: ['prompts', 'push'],
|
|
321
|
-
usage: 'orizu prompts push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--json]',
|
|
322
|
-
summary: 'Push a local prompt artifact directory as a new version.',
|
|
321
|
+
usage: 'orizu prompts push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--json]',
|
|
322
|
+
summary: 'Push a local prompt artifact directory as a new version (with --session: a commit-first git draft).',
|
|
323
323
|
group: 'Prompts and judges',
|
|
324
324
|
},
|
|
325
325
|
{
|
|
@@ -354,8 +354,8 @@ export const COMMAND_DOCS = [
|
|
|
354
354
|
},
|
|
355
355
|
{
|
|
356
356
|
path: ['judges', 'push'],
|
|
357
|
-
usage: 'orizu judges push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--json]',
|
|
358
|
-
summary: 'Push a local judge artifact directory as a new version.',
|
|
357
|
+
usage: 'orizu judges push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--json]',
|
|
358
|
+
summary: 'Push a local judge artifact directory as a new version (with --session: a commit-first git draft).',
|
|
359
359
|
group: 'Prompts and judges',
|
|
360
360
|
},
|
|
361
361
|
{
|
|
@@ -747,23 +747,25 @@ export const COMMAND_DOCS = [
|
|
|
747
747
|
},
|
|
748
748
|
{
|
|
749
749
|
path: ['session', 'start'],
|
|
750
|
-
usage: 'orizu session start [--project <team/project>] [--json]',
|
|
751
|
-
summary: 'Start a durable workspace session
|
|
750
|
+
usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json]',
|
|
751
|
+
summary: 'Start a durable workspace session and print the session id. When run inside the workbench clone (or with --workspace), the remote session branch is fetched and checked out locally, and the exact next git commands are printed.',
|
|
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
|
+
{ name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
|
|
755
756
|
{ name: '--json', help: 'Emit the machine-readable session payload.' },
|
|
756
757
|
],
|
|
757
758
|
examples: ['orizu session start', 'orizu session start --project highlight/hip --json'],
|
|
758
759
|
},
|
|
759
760
|
{
|
|
760
761
|
path: ['session', 'status'],
|
|
761
|
-
usage: 'orizu session status [--session <id> | --status active|ended] [--json]',
|
|
762
|
-
summary: 'Inspect one session by id
|
|
762
|
+
usage: 'orizu session status [--session <id> | --status active|ended] [--workspace <dir>] [--json]',
|
|
763
|
+
summary: 'Inspect one session by id (including run summaries and, when run inside the workbench clone, the session branch with local ahead/behind vs origin), or list sessions for the attached workspace.',
|
|
763
764
|
group: 'Sessions',
|
|
764
765
|
options: [
|
|
765
766
|
{ name: '--session <id>', help: 'Resume by session id; does not require the original terminal.' },
|
|
766
767
|
{ name: '--status <status>', help: 'Filter attached-workspace sessions when --session is omitted.', choices: ['active', 'ended'] },
|
|
768
|
+
{ name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
|
|
767
769
|
{ name: '--json', help: 'Emit the machine-readable session or session-list payload.' },
|
|
768
770
|
],
|
|
769
771
|
examples: ['orizu session status --session sess_123 --json', 'orizu session status --status active'],
|
|
@@ -781,15 +783,18 @@ export const COMMAND_DOCS = [
|
|
|
781
783
|
},
|
|
782
784
|
{
|
|
783
785
|
path: ['session', 'finish'],
|
|
784
|
-
usage: 'orizu session finish --session <id> [--project <team/project>] [--json]',
|
|
785
|
-
summary: 'Finish a hosted session branch: no changes deletes the branch; changes create a repo_merge promotion manifest to review and approve (no GitHub PR).',
|
|
786
|
+
usage: 'orizu session finish --session <id> [--project <team/project>] [--push [--message <text>]] [--workspace <dir>] [--json]',
|
|
787
|
+
summary: 'Finish a hosted session branch: no changes deletes the branch; changes create a repo_merge promotion manifest to review and approve (no GitHub PR). Warns when your local session-branch checkout has uncommitted/unpushed work; --push stages, commits, and pushes it first.',
|
|
786
788
|
group: 'Sessions',
|
|
787
789
|
options: [
|
|
788
790
|
{ name: '--session <id>', help: 'Session id whose branch to finish.', required: true },
|
|
789
791
|
{ name: '--project <team/project>', help: 'Project scope for the manifest when the session is not already project-scoped.' },
|
|
792
|
+
{ name: '--push', help: 'Opt-in: stage, commit, and push local session-branch work before finishing (raw git stays the default workflow).' },
|
|
793
|
+
{ name: '--message <text>', help: 'Commit message for --push (a default is used when omitted).' },
|
|
794
|
+
{ name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
|
|
790
795
|
{ name: '--json', help: 'Emit the machine-readable outcome (no-changes or the created manifest).' },
|
|
791
796
|
],
|
|
792
|
-
examples: ['orizu session finish --session sess_123', 'orizu session finish --session sess_123 --
|
|
797
|
+
examples: ['orizu session finish --session sess_123', 'orizu session finish --session sess_123 --push --message "tune judge prompt"'],
|
|
793
798
|
},
|
|
794
799
|
{
|
|
795
800
|
path: ['run', 'start'],
|
package/dist/hosted-boot.js
CHANGED
|
@@ -34,6 +34,8 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } f
|
|
|
34
34
|
import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, REPO_CRED_CACHE_BASENAME, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
|
|
35
35
|
import { INJECTED_ENV_VARS_ENV, runHostedLoop, } from './hosted-loop.js';
|
|
36
36
|
import { DEFAULT_EGRESS_CANARY_HOST } from './hosted-loop-lifecycle.js';
|
|
37
|
+
import { resumeRunEventSink } from './hosted-run-event-sink.js';
|
|
38
|
+
import { stageOrizuSkill } from './hosted-skill-staging.js';
|
|
37
39
|
/** Non-secret placeholder model key (firewall brokers the real org key on
|
|
38
40
|
* egress). Byte-for-byte the operator path's constant
|
|
39
41
|
* (packages/cli/src/hosted-session-cli.ts `ANTHROPIC_DUMMY_KEY` and the
|
|
@@ -72,6 +74,10 @@ export function resolveHostedBootEnv(env) {
|
|
|
72
74
|
value: {
|
|
73
75
|
bootSecret,
|
|
74
76
|
agentTokenUrl,
|
|
77
|
+
// The boot-status route is the agent-token route's sibling on the same
|
|
78
|
+
// coordinator, same boot-secret auth (ALI-1060). Deriving it here means no
|
|
79
|
+
// new env var in the frozen contract / planSandboxEnv.
|
|
80
|
+
bootStatusUrl: deriveBootStatusUrl(agentTokenUrl),
|
|
75
81
|
envBundleUrl,
|
|
76
82
|
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
77
83
|
sessionId,
|
|
@@ -81,6 +87,51 @@ export function resolveHostedBootEnv(env) {
|
|
|
81
87
|
},
|
|
82
88
|
};
|
|
83
89
|
}
|
|
90
|
+
/** The boot-status route is the agent-token route's sibling; swap the trailing
|
|
91
|
+
* path segment. Falls back to appending when the shape is unexpected. */
|
|
92
|
+
export function deriveBootStatusUrl(agentTokenUrl) {
|
|
93
|
+
if (/\/agent-token\/?$/.test(agentTokenUrl)) {
|
|
94
|
+
return agentTokenUrl.replace(/\/agent-token\/?$/, '/boot-status');
|
|
95
|
+
}
|
|
96
|
+
return `${agentTokenUrl.replace(/\/+$/, '')}/boot-status`;
|
|
97
|
+
}
|
|
98
|
+
// -- Boot-status callback (ALI-1060) ------------------------------------------
|
|
99
|
+
/** Max chars of the failure reason reported to the coordinator. A boot reason
|
|
100
|
+
* is never a place for a secret; this bounds it anyway, and we scrub the boot
|
|
101
|
+
* secret defensively before sending. */
|
|
102
|
+
const MAX_BOOT_REASON_CHARS = 800;
|
|
103
|
+
/** Best-effort scrub + truncate for a reported failure reason: never leak the
|
|
104
|
+
* boot secret (the one credential the boot always holds), and keep it short. */
|
|
105
|
+
export function redactBootReason(reason, bootSecret) {
|
|
106
|
+
let out = reason;
|
|
107
|
+
if (bootSecret && out.includes(bootSecret)) {
|
|
108
|
+
out = out.split(bootSecret).join('[redacted]');
|
|
109
|
+
}
|
|
110
|
+
return out.slice(0, MAX_BOOT_REASON_CHARS);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Report the boot outcome to the coordinator's boot-status route (ALI-1060),
|
|
114
|
+
* authed with the boot secret (same as the agent-token pull). Best-effort: the
|
|
115
|
+
* DO's own readiness-timeout is the backstop if this never lands, so a failed
|
|
116
|
+
* report must never mask the boot result. NEVER logs the secret.
|
|
117
|
+
*/
|
|
118
|
+
export async function postBootStatus(opts) {
|
|
119
|
+
try {
|
|
120
|
+
const res = await opts.fetchImpl(opts.bootStatusUrl, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { Authorization: `Bearer ${opts.bootSecret}`, 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
status: opts.status,
|
|
125
|
+
...(opts.runId ? { runId: opts.runId } : {}),
|
|
126
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
opts.log?.(`boot-status ${opts.status} reported (${res.status})`);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
opts.log?.(`boot-status ${opts.status} report failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
84
135
|
/**
|
|
85
136
|
* Exchange the boot secret for a fresh Orizu agent bearer at the coordinator's
|
|
86
137
|
* agent-token route (`GET`, `Authorization: Bearer <boot secret>` → 200
|
|
@@ -187,6 +238,9 @@ async function bearerJson(fetchImpl, url, bearer, init = {}) {
|
|
|
187
238
|
function asString(value) {
|
|
188
239
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
189
240
|
}
|
|
241
|
+
function asPositiveNumber(value) {
|
|
242
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
|
|
243
|
+
}
|
|
190
244
|
/** GET the session (agent-capable, RLS-scoped) to learn its workspace, branch,
|
|
191
245
|
* task, and any existing run — the DO start body carries none of these. */
|
|
192
246
|
export async function resolveSession(opts) {
|
|
@@ -210,6 +264,7 @@ export async function resolveSession(opts) {
|
|
|
210
264
|
task,
|
|
211
265
|
model: asString(clientInfo.model),
|
|
212
266
|
reasoningEffort: asString(clientInfo.reasoningEffort),
|
|
267
|
+
durationMinutes: asPositiveNumber(clientInfo.durationMinutes),
|
|
213
268
|
runId,
|
|
214
269
|
};
|
|
215
270
|
}
|
|
@@ -233,8 +288,7 @@ export async function ensureRun(opts) {
|
|
|
233
288
|
return runId;
|
|
234
289
|
}
|
|
235
290
|
/** Mint a session_read repo token to learn the repo full name, then build the
|
|
236
|
-
* GitHub clone URL (mirrors the operator path's `defaultResolveRepo`).
|
|
237
|
-
* minted token is best-effort revoked — the credential helper mints its own. */
|
|
291
|
+
* GitHub clone URL (mirrors the operator path's `defaultResolveRepo`). */
|
|
238
292
|
export async function resolveRepo(opts) {
|
|
239
293
|
const url = `${opts.baseUrl}/api/cli/workspaces/${encodeURIComponent(opts.workspaceId)}/repo-token`;
|
|
240
294
|
const minted = await bearerJson(opts.fetchImpl, url, opts.bearer, {
|
|
@@ -245,20 +299,13 @@ export async function resolveRepo(opts) {
|
|
|
245
299
|
const repoFullName = asString(minted.repo);
|
|
246
300
|
if (!repoFullName)
|
|
247
301
|
throw new Error('repo-token response carried no repo');
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
body: JSON.stringify({ mintId, token }),
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
// best-effort revoke — the short TTL is the backstop.
|
|
260
|
-
}
|
|
261
|
-
}
|
|
302
|
+
// We deliberately do NOT early-revoke this probe token (ALI-1069): the
|
|
303
|
+
// repo-token DELETE route is HUMAN-ONLY (`requireCliSupabase` default-denies
|
|
304
|
+
// agent bearers), so an agent-bearer revoke would always 401 — a guaranteed
|
|
305
|
+
// failed request per boot, not a real revoke. Let the short (~60-min)
|
|
306
|
+
// session_read TTL expire on its own; the credential helper mints its own
|
|
307
|
+
// per-op tokens for the actual clone/fetch/push, so this probe token is never
|
|
308
|
+
// reused after we read `repo` from it.
|
|
262
309
|
return { repoFullName, cloneUrl: `https://github.com/${repoFullName}.git` };
|
|
263
310
|
}
|
|
264
311
|
const defaultExec = (cmd, args, opts) => {
|
|
@@ -274,6 +321,21 @@ function assertSafeGitValue(name, value) {
|
|
|
274
321
|
throw new Error(`unsafe characters in ${name}; refusing to pass to git`);
|
|
275
322
|
}
|
|
276
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Path-safety for the run id BEFORE it is interpolated into `.orizu-run/${runId}`
|
|
326
|
+
* (ALI-1060). ORIZU_RUN_ID can be CALLER-SUPPLIED (resume flows), and unlike the
|
|
327
|
+
* boot-created uuid it is untrusted — a `../` value would escape the run dir and
|
|
328
|
+
* let boot assets (0600 bearer/boot-secret files) land outside it. A run id is a
|
|
329
|
+
* single opaque segment: allow only `[A-Za-z0-9._-]` and reject any `..`, so no
|
|
330
|
+
* separators or traversal survive. Stricter than the operator path's shared
|
|
331
|
+
* shell-value assertion (which allows `/`), applied to the same value.
|
|
332
|
+
*/
|
|
333
|
+
const SAFE_RUN_ID = /^[A-Za-z0-9._-]+$/;
|
|
334
|
+
export function assertSafeRunId(runId) {
|
|
335
|
+
if (!SAFE_RUN_ID.test(runId) || runId.includes('..')) {
|
|
336
|
+
throw new Error('unsafe run id; refusing to build run-dir paths');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
277
339
|
function writeSecretFile(path, contents, write) {
|
|
278
340
|
write(path, contents.endsWith('\n') ? contents : `${contents}\n`);
|
|
279
341
|
}
|
|
@@ -334,6 +396,12 @@ export async function runHostedBoot(opts) {
|
|
|
334
396
|
fetchImpl,
|
|
335
397
|
existingRunId: env.runId ?? session.runId,
|
|
336
398
|
});
|
|
399
|
+
// Path-safety BEFORE the run id reaches any `.orizu-run/${runId}` path
|
|
400
|
+
// (ALI-1060): a caller-supplied ORIZU_RUN_ID must not escape the run dir.
|
|
401
|
+
assertSafeRunId(runId);
|
|
402
|
+
// Hand the run id + bearer to the caller so a throw AFTER this point can mark
|
|
403
|
+
// the run failed + report the boot failure (the DO is the backstop otherwise).
|
|
404
|
+
opts.onBootContext?.({ runId, bearer: bearer.token });
|
|
337
405
|
const repo = await resolveRepo({ baseUrl: env.baseUrl, workspaceId, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
|
|
338
406
|
log(`resolved run ${runId} on ${repo.repoFullName}@${session.repoBranch}`);
|
|
339
407
|
// 4 — Write the run-scoped assets: boot secret + initial bearer (0600), the
|
|
@@ -398,6 +466,37 @@ export async function runHostedBoot(opts) {
|
|
|
398
466
|
exec('git', ['-C', workspaceDir, 'config', 'user.name', AGENT_GIT_IDENTITY.name]);
|
|
399
467
|
exec('git', ['-C', workspaceDir, 'config', 'user.email', AGENT_GIT_IDENTITY.email]);
|
|
400
468
|
log(`cloned ${session.repoBranch}`);
|
|
469
|
+
// 5b — Stage the orizu-cli skill into the cloned repo so the agent discovers the
|
|
470
|
+
// Orizu workflows (ALI-1059). SHARED with the operator path via `stageOrizuSkill`
|
|
471
|
+
// (one resolution chain + the harvest-safe .git/info/exclude append). Non-fatal:
|
|
472
|
+
// a staging failure is logged, never aborts the boot. The DO `BootExec` is
|
|
473
|
+
// shell-less, so the staging script runs under `sh -c`.
|
|
474
|
+
try {
|
|
475
|
+
const skillStage = await stageOrizuSkill({
|
|
476
|
+
workspaceDir,
|
|
477
|
+
exec: async (command) => {
|
|
478
|
+
const res = exec('sh', ['-c', command]);
|
|
479
|
+
return { exitCode: res.status, stdout: res.stdout, stderr: res.stderr ?? '' };
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
log(`orizu-cli skill staged: ${skillStage.ok ? skillStage.method : 'unresolved'}`);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
log(`orizu-cli skill staging failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
486
|
+
}
|
|
487
|
+
// ALI-1060: the agent is now genuinely LIVE — bearer minted, connectors
|
|
488
|
+
// applied, session/run resolved, repo cloned, skill staged. Signal the
|
|
489
|
+
// coordinator's readiness gate BEFORE the loop so the DO stops waiting (and
|
|
490
|
+
// never tears down a healthy box on timeout). Best-effort; the DO's timeout is
|
|
491
|
+
// the backstop.
|
|
492
|
+
await postBootStatus({
|
|
493
|
+
bootStatusUrl: env.bootStatusUrl,
|
|
494
|
+
bootSecret: env.bootSecret,
|
|
495
|
+
status: 'ready',
|
|
496
|
+
runId,
|
|
497
|
+
fetchImpl,
|
|
498
|
+
log,
|
|
499
|
+
});
|
|
401
500
|
// 6 — Launch the hosted loop, reused in-process. The bearer PROVIDER keeps the
|
|
402
501
|
// event sink's bearer fresh from the same pull-mode source; the DO-provisioned
|
|
403
502
|
// sandbox always runs the pre-baked image under an enforced egress policy, so
|
|
@@ -414,6 +513,10 @@ export async function runHostedBoot(opts) {
|
|
|
414
513
|
messageId: `${runId}:task`,
|
|
415
514
|
author: AGENT_GIT_IDENTITY,
|
|
416
515
|
anthropicDummyKey: env.anthropicDummyKey ?? ANTHROPIC_DUMMY_KEY,
|
|
516
|
+
// Derive the per-prompt max-duration cap from the session duration so a long
|
|
517
|
+
// run is not killed at the hard-coded 90-min prompt cap (ALI-1061). Unset →
|
|
518
|
+
// the harness default (5400s) floor holds.
|
|
519
|
+
sandboxBudgetMs: session.durationMinutes != null ? session.durationMinutes * 60 * 1000 : undefined,
|
|
417
520
|
prebaked: true,
|
|
418
521
|
egressCanaryHost: DEFAULT_EGRESS_CANARY_HOST,
|
|
419
522
|
runSetupHook: true,
|
|
@@ -518,10 +621,18 @@ export async function hostedBootCommand(io) {
|
|
|
518
621
|
'(the DO sandbox env contract — see planSandboxEnv)');
|
|
519
622
|
return 1;
|
|
520
623
|
}
|
|
624
|
+
const env = resolved.value;
|
|
625
|
+
const log = (line) => io.printErr?.(`[hosted-boot] ${line}`);
|
|
626
|
+
// Captured once the run id + bearer are known, so a LATER throw can mark the
|
|
627
|
+
// run failed with an authenticated bearer (ALI-1060).
|
|
628
|
+
let bootCtx = null;
|
|
521
629
|
try {
|
|
522
630
|
const result = await runHostedBoot({
|
|
523
|
-
env
|
|
524
|
-
log
|
|
631
|
+
env,
|
|
632
|
+
log,
|
|
633
|
+
onBootContext: ctx => {
|
|
634
|
+
bootCtx = ctx;
|
|
635
|
+
},
|
|
525
636
|
});
|
|
526
637
|
io.print(io.json
|
|
527
638
|
? JSON.stringify({ ok: result.ok, runId: result.runId, status: result.loopStatus, error: result.error })
|
|
@@ -531,6 +642,72 @@ export async function hostedBootCommand(io) {
|
|
|
531
642
|
catch (error) {
|
|
532
643
|
const message = error instanceof Error ? error.message : String(error);
|
|
533
644
|
io.printErr?.(`hosted-boot failed: ${message}`);
|
|
645
|
+
// A boot THROW (before/around ensureRun) means the agent never came alive.
|
|
646
|
+
// Report it so the DO stops + destroys the sandbox NOW instead of waiting
|
|
647
|
+
// for the readiness timeout, and mark the run failed so it never stays
|
|
648
|
+
// 'running' forever (the DO also marks it — both are idempotent).
|
|
649
|
+
await reportBootFailure({ env, ctx: bootCtx, reason: message, log });
|
|
534
650
|
return 1;
|
|
535
651
|
}
|
|
536
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Failure fan-out on a boot throw (ALI-1060): mark the run terminally `failed`
|
|
655
|
+
* (the operator path's agent-bearer terminal PATCH) and always signal the
|
|
656
|
+
* coordinator's boot-status route so the DO tears the sandbox down promptly.
|
|
657
|
+
* Best-effort throughout — the boot secret is scrubbed from the reported reason.
|
|
658
|
+
*
|
|
659
|
+
* PRE-BEARER failures (a throw before `onBootContext` — `pullAgentBearer` /
|
|
660
|
+
* `fetchEnvBundle` blew up, so `ctx` is null) still have a run id: the server
|
|
661
|
+
* PRE-CREATES it and injects ORIZU_RUN_ID. We attempt a SHORT bearer pull here
|
|
662
|
+
* so we can self-mark that run failed rather than leaving it `running` until the
|
|
663
|
+
* DO's readiness timeout. If even that pull fails, the DO backstop (boot-status
|
|
664
|
+
* failure now, readiness timeout otherwise) is the last line.
|
|
665
|
+
*/
|
|
666
|
+
export async function reportBootFailure(opts) {
|
|
667
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
668
|
+
const reason = redactBootReason(opts.reason, opts.env.bootSecret);
|
|
669
|
+
// The run id is known from the boot context, or (pre-created / resume flows)
|
|
670
|
+
// straight from the env contract.
|
|
671
|
+
const runId = opts.ctx?.runId ?? opts.env.runId;
|
|
672
|
+
// Prefer the bearer we already hold; otherwise pull a fresh one (bounded — the
|
|
673
|
+
// DO is the backstop) so a pre-bearer failure can still self-mark the run.
|
|
674
|
+
let bearer = opts.ctx?.bearer ?? null;
|
|
675
|
+
if (!bearer && runId) {
|
|
676
|
+
try {
|
|
677
|
+
bearer = (await pullAgentBearer({
|
|
678
|
+
agentTokenUrl: opts.env.agentTokenUrl,
|
|
679
|
+
bootSecret: opts.env.bootSecret,
|
|
680
|
+
fetchImpl,
|
|
681
|
+
attempts: 2,
|
|
682
|
+
log: opts.log,
|
|
683
|
+
})).token;
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
opts.log(`failure-path bearer pull failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (bearer && runId) {
|
|
690
|
+
try {
|
|
691
|
+
const sink = await resumeRunEventSink({
|
|
692
|
+
apiBaseUrl: opts.env.baseUrl,
|
|
693
|
+
runId,
|
|
694
|
+
bearer,
|
|
695
|
+
fetchImpl,
|
|
696
|
+
});
|
|
697
|
+
await sink.finish('failed', { summary: { error: reason } });
|
|
698
|
+
opts.log('marked run failed');
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
opts.log(`run-failed mark failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
await postBootStatus({
|
|
705
|
+
bootStatusUrl: opts.env.bootStatusUrl,
|
|
706
|
+
bootSecret: opts.env.bootSecret,
|
|
707
|
+
status: 'failed',
|
|
708
|
+
runId,
|
|
709
|
+
reason,
|
|
710
|
+
fetchImpl,
|
|
711
|
+
log: opts.log,
|
|
712
|
+
});
|
|
713
|
+
}
|
package/dist/hosted-bootstrap.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* build ON TOP of this — `bootstrapHostedSandbox` is the single writer for the
|
|
23
23
|
* run at this stage and returns the run cursor so a follow-on can continue it.
|
|
24
24
|
*/
|
|
25
|
+
import { stageOrizuSkill } from './hosted-skill-staging.js';
|
|
25
26
|
import { sweepForTokenResidue } from './sandbox-hygiene.js';
|
|
26
27
|
import { redactSecrets } from './secret-redaction.js';
|
|
27
28
|
import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, PREBAKED_MARKER_PATH, REPO_CRED_CACHE_BASENAME, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
|
|
@@ -266,48 +267,25 @@ export async function bootstrapHostedSandbox(opts) {
|
|
|
266
267
|
});
|
|
267
268
|
}
|
|
268
269
|
// 4b — Stage the orizu-cli skill into the workspace so the agent discovers it
|
|
269
|
-
// (ALI-1044).
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// globally-installed CLI (the production path),
|
|
274
|
-
// (3) `$(npm root -g)/orizu/vendor/skills/orizu-cli` — belt fallback.
|
|
275
|
-
// Then SYMLINK it under <workspaceDir>/.claude/skills/orizu-cli, falling back
|
|
276
|
-
// to a copy if the symlink cannot be created. Non-fatal + recorded, exactly
|
|
277
|
-
// like the CLI install. Resolved paths stay in shell vars (never interpolated),
|
|
278
|
-
// so they cannot inject into the command.
|
|
270
|
+
// (ALI-1044). SHARED with the DO path (ALI-1059): the resolution chain + the
|
|
271
|
+
// .git/info/exclude append live in `stageOrizuSkill`; here we only adapt the
|
|
272
|
+
// sandbox exec seam and record the outcome. Non-fatal + recorded, exactly like
|
|
273
|
+
// the CLI install.
|
|
279
274
|
currentStep = 'skill_staged';
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`
|
|
289
|
-
`
|
|
290
|
-
`if [ -z "$src" ] || [ ! -d "$src" ]; then src="$(orizu skills path 2>/dev/null || true)"; fi`,
|
|
291
|
-
`if [ -z "$src" ] || [ ! -d "$src" ]; then r="$(npm root -g 2>/dev/null || true)"; if [ -n "$r" ] && [ -d "$r/orizu/vendor/skills/orizu-cli" ]; then src="$r/orizu/vendor/skills/orizu-cli"; fi; fi`,
|
|
292
|
-
`if [ -z "$src" ] || [ ! -d "$src" ]; then echo "NO_SOURCE"; exit 0; fi`,
|
|
293
|
-
`dest="${skillSkillsDirRel}/orizu-cli"`,
|
|
294
|
-
`rm -rf "$dest"`,
|
|
295
|
-
`if ln -s "$src" "$dest" 2>/dev/null; then echo "SYMLINK $src"; else cp -R "$src" "$dest" && echo "COPY $src"; fi`,
|
|
296
|
-
].join('\n'));
|
|
297
|
-
const stageOut = stage.stdout.trim();
|
|
298
|
-
const stagedMethod = stageOut.startsWith('SYMLINK')
|
|
299
|
-
? 'symlink'
|
|
300
|
-
: stageOut.startsWith('COPY')
|
|
301
|
-
? 'copy'
|
|
302
|
-
: null;
|
|
303
|
-
const stagedOk = stage.exitCode === 0 && stagedMethod !== null;
|
|
304
|
-
record('skill_staged', stagedOk, stagedOk
|
|
305
|
-
? `orizu-cli skill staged (${stagedMethod})`
|
|
306
|
-
: `skill staging unresolved: ${stageOut || `exit ${stage.exitCode}`}`);
|
|
275
|
+
const stage = await stageOrizuSkill({
|
|
276
|
+
workspaceDir,
|
|
277
|
+
exec: async (command) => {
|
|
278
|
+
const res = await session.exec(command);
|
|
279
|
+
return { exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr ?? '' };
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
record('skill_staged', stage.ok, stage.ok
|
|
283
|
+
? `orizu-cli skill staged (${stage.method})`
|
|
284
|
+
: `skill staging unresolved: ${stage.stdout.trim() || `exit ${stage.exitCode}`}`);
|
|
307
285
|
await sink.append('skill_staged', {
|
|
308
|
-
ok:
|
|
309
|
-
method:
|
|
310
|
-
dest:
|
|
286
|
+
ok: stage.ok,
|
|
287
|
+
method: stage.method,
|
|
288
|
+
dest: stage.dest,
|
|
311
289
|
outputTail: tail(stage.stdout, stage.stderr),
|
|
312
290
|
});
|
|
313
291
|
// 5 — Customer setup hook (fresh-boot only, non-fatal, output captured).
|
|
@@ -62,8 +62,10 @@ const DEFAULT_SSE_INACTIVITY_MS = 120_000;
|
|
|
62
62
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
63
63
|
// Wall-clock ceiling on a single prompt (port of bridge.py PROMPT_MAX_DURATION,
|
|
64
64
|
// 5400s). Runs alongside the inactivity deadline: inactivity catches a stalled
|
|
65
|
-
// stream, this catches a stream that stays busy forever.
|
|
66
|
-
|
|
65
|
+
// stream, this catches a stream that stays busy forever. Exported so the hosted
|
|
66
|
+
// loop can use it as the FLOOR when deriving a duration-aware cap (ALI-1061):
|
|
67
|
+
// the cap is never shortened below this default for short/undated runs.
|
|
68
|
+
export const DEFAULT_PROMPT_MAX_DURATION_MS = 5_400_000;
|
|
67
69
|
// Placeholder titles OpenCode auto-assigns ("New session - <ISO8601>Z" /
|
|
68
70
|
// "Child session - <ISO8601>Z"); noise, never forwarded (port of bridge.py
|
|
69
71
|
// OPENCODE_DEFAULT_TITLE_RE).
|
package/dist/hosted-headless.js
CHANGED
|
@@ -26,7 +26,7 @@ export class AgentStalledError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
/** Render the idle window for the error message. Minutes (the operator-facing
|
|
29
|
-
* unit; default window is
|
|
29
|
+
* unit; default window is 25m), rounded to one decimal so sub-minute test
|
|
30
30
|
* windows still read sensibly. */
|
|
31
31
|
export function formatIdleWindow(ms) {
|
|
32
32
|
const minutes = Math.round((ms / 60000) * 10) / 10;
|
|
@@ -34,19 +34,26 @@ export function formatIdleWindow(ms) {
|
|
|
34
34
|
}
|
|
35
35
|
const IDLE_SENTINEL = Symbol('idle');
|
|
36
36
|
/**
|
|
37
|
-
* Wrap `source` so that if no
|
|
38
|
-
* and `AgentStalledError` is thrown. The idle
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Wrap `source` so that if no PROGRESS arrives within `timeoutMs`, `onTimeout`
|
|
38
|
+
* runs and `AgentStalledError` is thrown. The idle deadline advances only on
|
|
39
|
+
* values `opts.isProgress` accepts (default: all), so it measures gaps between
|
|
40
|
+
* real progress events, not total duration — a stream of non-progress values
|
|
41
|
+
* (e.g. `question_auto_answered` deny-path events) passes through WITHOUT
|
|
42
|
+
* resetting the timer, so a stuck loop still stalls. The still-pending `next()`
|
|
43
|
+
* is not left as an unhandled rejection, and the underlying iterator is
|
|
41
44
|
* returned/closed on any early exit.
|
|
42
45
|
*/
|
|
43
46
|
export async function* withIdleWatchdog(source, opts) {
|
|
44
47
|
const iterator = source[Symbol.asyncIterator]();
|
|
48
|
+
const isProgress = opts.isProgress ?? (() => true);
|
|
49
|
+
// Absolute deadline so a non-progress value can be yielded WITHOUT extending
|
|
50
|
+
// the window (each iteration arms the timer for whatever remains).
|
|
51
|
+
let deadline = Date.now() + opts.timeoutMs;
|
|
45
52
|
try {
|
|
46
53
|
for (;;) {
|
|
47
54
|
let timer;
|
|
48
55
|
const idle = new Promise(resolve => {
|
|
49
|
-
timer = setTimeout(() => resolve(IDLE_SENTINEL),
|
|
56
|
+
timer = setTimeout(() => resolve(IDLE_SENTINEL), Math.max(0, deadline - Date.now()));
|
|
50
57
|
});
|
|
51
58
|
const nextResult = iterator.next();
|
|
52
59
|
let raced;
|
|
@@ -66,6 +73,10 @@ export async function* withIdleWatchdog(source, opts) {
|
|
|
66
73
|
}
|
|
67
74
|
if (raced.done)
|
|
68
75
|
return;
|
|
76
|
+
// Only genuine progress pushes the deadline out; non-progress values are
|
|
77
|
+
// relayed but leave the existing deadline intact.
|
|
78
|
+
if (isProgress(raced.value))
|
|
79
|
+
deadline = Date.now() + opts.timeoutMs;
|
|
69
80
|
yield raced.value;
|
|
70
81
|
}
|
|
71
82
|
}
|