orizu 0.5.9 → 0.5.10
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.js +1 -51
- package/dist/hosted-boot.js +536 -0
- package/dist/hosted-bootstrap.js +1 -1
- package/dist/hosted-loop-lifecycle.js +172 -0
- package/dist/hosted-loop.js +18 -150
- package/dist/hosted-runtime-assets.js +25 -1
- package/dist/hosted-session-cli.js +97 -8
- package/dist/index.js +18 -0
- package/dist/sandbox-hygiene.js +60 -0
- package/dist/sandbox-provider.js +7 -1
- package/dist/team-connectors-cli.js +140 -0
- package/dist/vercel-sandbox-provider.js +7 -1
- package/package.json +4 -2
- package/src/daytona-slice.ts +1 -70
- package/src/hosted-boot.ts +736 -0
- package/src/hosted-bootstrap.ts +1 -1
- package/src/hosted-loop-lifecycle.ts +290 -0
- package/src/hosted-loop.ts +43 -267
- package/src/hosted-runtime-assets.ts +42 -1
- package/src/hosted-session-cli.ts +126 -8
- package/src/index.ts +17 -0
- package/src/sandbox-hygiene.ts +79 -0
- package/src/sandbox-provider.ts +7 -1
- package/src/team-connectors-cli.ts +171 -0
- package/src/vercel-sandbox-provider.ts +7 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted-loop LIFECYCLE seam (ALI-1015 Phase 1, per ADR-005 Resolution).
|
|
3
|
+
*
|
|
4
|
+
* The orchestration-facing parts of the hosted loop, extracted VERBATIM from
|
|
5
|
+
* `hosted-loop.ts` so they can be re-exported through the shared
|
|
6
|
+
* `lib/hosted-runtime/` surface and driven by a server-side coordinator (the
|
|
7
|
+
* per-session Durable Object) as well as the CLI. This module deliberately
|
|
8
|
+
* carries NO harness/runtime machinery: its only import is the TYPE-ONLY
|
|
9
|
+
* `HarnessEvent` (erased at compile time), so a Worker bundle that pulls the
|
|
10
|
+
* lifecycle seam never drags in the OpenCode/Claude-SDK drivers or any Node
|
|
11
|
+
* child-process code.
|
|
12
|
+
*
|
|
13
|
+
* Contents (moved, not rewritten — behavior identical):
|
|
14
|
+
* - `HostedLoopContext` — the run-scoped boot context the host orchestrator
|
|
15
|
+
* writes for the in-sandbox loop;
|
|
16
|
+
* - `buildOpenCodeSpawnEnv` — the pre-authenticated agent env (ALI-1044);
|
|
17
|
+
* - the G5 egress canary: `runEgressCanary`, `egressCanaryAllowedHost`,
|
|
18
|
+
* `defaultProbeEgress`, and their types/constants;
|
|
19
|
+
* - `SetupHookOutcome` — the deferred customer-setup-hook contract.
|
|
20
|
+
*
|
|
21
|
+
* `hosted-loop.ts` re-exports everything here, so the CLI's import surface
|
|
22
|
+
* (`@/packages/cli/src/hosted-loop`) is unchanged.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Environment injected into the spawned OpenCode server so the AGENT's bash sees a
|
|
26
|
+
* PRE-AUTHENTICATED `orizu` CLI (ALI-1044). ORIZU_TOKEN_FILE points at the 0600
|
|
27
|
+
* rotated bearer file and ORIZU_BASE_URL at the Orizu API — the exact vars the
|
|
28
|
+
* CLI's auth resolution reads (resolveEnvBearerToken + resolveBaseUrl). The dummy
|
|
29
|
+
* ANTHROPIC key (model-key brokering, G3) is preserved. No real secret is carried
|
|
30
|
+
* here: the bearer stays on disk and is read fresh per request from the file.
|
|
31
|
+
*/
|
|
32
|
+
export function buildOpenCodeSpawnEnv(context) {
|
|
33
|
+
const env = {};
|
|
34
|
+
if (context.anthropicDummyKey) {
|
|
35
|
+
env.ANTHROPIC_API_KEY = context.anthropicDummyKey;
|
|
36
|
+
}
|
|
37
|
+
if (context.bearerFile) {
|
|
38
|
+
env.ORIZU_TOKEN_FILE = context.bearerFile;
|
|
39
|
+
}
|
|
40
|
+
if (context.apiBaseUrl) {
|
|
41
|
+
env.ORIZU_BASE_URL = context.apiBaseUrl;
|
|
42
|
+
}
|
|
43
|
+
return Object.keys(env).length > 0 ? env : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Default egress-canary probe host (a known non-allowlisted destination). */
|
|
46
|
+
export const DEFAULT_EGRESS_CANARY_HOST = 'example.com';
|
|
47
|
+
/** Bound (ms) on the canary probe — a live firewall resets/times out fast; a
|
|
48
|
+
* reachable host answers well within this. */
|
|
49
|
+
const EGRESS_CANARY_TIMEOUT_MS = 5000;
|
|
50
|
+
/**
|
|
51
|
+
* Default probe: attempt a bounded HTTPS GET to `host`. A live default-deny
|
|
52
|
+
* firewall makes this THROW for a DENIED host (connection reset / timeout / DNS
|
|
53
|
+
* failure) → `reachable: false`. Any response at all — even an error status —
|
|
54
|
+
* proves the host was reachable → `reachable: true`. Used for BOTH the allowed
|
|
55
|
+
* (positive-control) and denied probes.
|
|
56
|
+
*/
|
|
57
|
+
export async function defaultProbeEgress(host) {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), EGRESS_CANARY_TIMEOUT_MS);
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(`https://${host}/`, {
|
|
62
|
+
method: 'GET',
|
|
63
|
+
redirect: 'manual',
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
return { reachable: true, detail: `reachable (HTTP ${response.status})` };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { reachable: false, detail: `blocked (${error instanceof Error ? error.name : 'error'})` };
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Run the startup egress canary as a POSITIVE CONTROL and emit the proof event
|
|
77
|
+
* through the (single-writer) sink. Probes BOTH a known-allowed host and the
|
|
78
|
+
* known-denied host, then decides:
|
|
79
|
+
*
|
|
80
|
+
* - allowed REACHABLE and denied BLOCKED → the firewall is live AND the network
|
|
81
|
+
* works → emit `egress_blocked` and PROCEED (the ONLY healthy outcome);
|
|
82
|
+
* - denied REACHABLE → egress is NOT enforced → emit `egress_allowed`, FAIL
|
|
83
|
+
* CLOSED;
|
|
84
|
+
* - allowed ALSO UNREACHABLE (denied blocked but the positive control failed) →
|
|
85
|
+
* we cannot tell policy-blocking from a total network outage → emit
|
|
86
|
+
* `egress_allowed`, FAIL CLOSED.
|
|
87
|
+
*
|
|
88
|
+
* The `egress_blocked` kind therefore means EXACTLY "proceeded, positive control
|
|
89
|
+
* passed"; `egress_allowed` means "failed closed" (the payload `result` field
|
|
90
|
+
* distinguishes an unexpectedly-reachable denied host from an unreachable
|
|
91
|
+
* positive control). Old behavior — treating ANY denied-host throw (incl. a
|
|
92
|
+
* timeout under a total outage) as "blocked → proceed" — failed OPEN; this now
|
|
93
|
+
* fails closed on that ambiguity.
|
|
94
|
+
*/
|
|
95
|
+
export async function runEgressCanary(targets, sink, probe, now = () => Date.now()) {
|
|
96
|
+
const [allowed, denied] = await Promise.all([probe(targets.allowedHost), probe(targets.deniedHost)]);
|
|
97
|
+
const checkedAt = new Date(now()).toISOString();
|
|
98
|
+
const positiveControl = {
|
|
99
|
+
host: targets.allowedHost,
|
|
100
|
+
reachable: allowed.reachable,
|
|
101
|
+
detail: allowed.detail,
|
|
102
|
+
};
|
|
103
|
+
// FAIL CLOSED: a denied host answered — the policy is not enforcing egress.
|
|
104
|
+
if (denied.reachable) {
|
|
105
|
+
await sink.append({
|
|
106
|
+
kind: 'egress_allowed',
|
|
107
|
+
critical: true,
|
|
108
|
+
payload: {
|
|
109
|
+
host: targets.deniedHost,
|
|
110
|
+
result: 'unexpectedly_reachable',
|
|
111
|
+
detail: denied.detail,
|
|
112
|
+
positiveControl,
|
|
113
|
+
checkedAt,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
proceed: false,
|
|
118
|
+
reason: `denied host ${targets.deniedHost} was reachable (${denied.detail}) — egress is not enforced`,
|
|
119
|
+
allowed,
|
|
120
|
+
denied,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// FAIL CLOSED: the denied host was blocked, but the positive control ALSO did
|
|
124
|
+
// not answer — the environment/network is broken and we cannot distinguish a
|
|
125
|
+
// policy block from a total outage. This is the case the old code got wrong.
|
|
126
|
+
if (!allowed.reachable) {
|
|
127
|
+
await sink.append({
|
|
128
|
+
kind: 'egress_allowed',
|
|
129
|
+
critical: true,
|
|
130
|
+
payload: {
|
|
131
|
+
host: targets.deniedHost,
|
|
132
|
+
result: 'positive_control_unreachable',
|
|
133
|
+
detail: denied.detail,
|
|
134
|
+
positiveControl,
|
|
135
|
+
checkedAt,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
proceed: false,
|
|
140
|
+
reason: `positive control ${targets.allowedHost} was unreachable (${allowed.detail}) — cannot distinguish egress enforcement from a network outage`,
|
|
141
|
+
allowed,
|
|
142
|
+
denied,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// HEALTHY: allowed reachable AND denied blocked → the firewall is live.
|
|
146
|
+
await sink.append({
|
|
147
|
+
kind: 'egress_blocked',
|
|
148
|
+
critical: true,
|
|
149
|
+
payload: {
|
|
150
|
+
host: targets.deniedHost,
|
|
151
|
+
result: 'blocked',
|
|
152
|
+
detail: denied.detail,
|
|
153
|
+
positiveControl,
|
|
154
|
+
checkedAt,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
return { proceed: true, reason: null, allowed, denied };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Derive the known-ALLOWED positive-control host from the run's Orizu API base —
|
|
161
|
+
* always allowlisted (it is the agent's control-plane path) and ours, so it is
|
|
162
|
+
* the safest "must be reachable" signal. Falls back to a lenient host parse if
|
|
163
|
+
* the base is not a well-formed URL.
|
|
164
|
+
*/
|
|
165
|
+
export function egressCanaryAllowedHost(apiBaseUrl) {
|
|
166
|
+
try {
|
|
167
|
+
return new URL(apiBaseUrl).hostname;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return apiBaseUrl.replace(/^[a-z]+:\/\//i, '').split('/')[0].split(':')[0];
|
|
171
|
+
}
|
|
172
|
+
}
|
package/dist/hosted-loop.js
CHANGED
|
@@ -42,155 +42,11 @@ import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.j
|
|
|
42
42
|
import { OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
|
|
43
43
|
import { createClaudeAgentHarness } from './hosted-harness-claude.js';
|
|
44
44
|
import { drainHarnessToSink, resumeRunEventSink, } from './hosted-run-event-sink.js';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* ANTHROPIC key (model-key brokering, G3) is preserved. No real secret is carried
|
|
51
|
-
* here: the bearer stays on disk and is read fresh per request from the file.
|
|
52
|
-
*/
|
|
53
|
-
export function buildOpenCodeSpawnEnv(context) {
|
|
54
|
-
const env = {};
|
|
55
|
-
if (context.anthropicDummyKey) {
|
|
56
|
-
env.ANTHROPIC_API_KEY = context.anthropicDummyKey;
|
|
57
|
-
}
|
|
58
|
-
if (context.bearerFile) {
|
|
59
|
-
env.ORIZU_TOKEN_FILE = context.bearerFile;
|
|
60
|
-
}
|
|
61
|
-
if (context.apiBaseUrl) {
|
|
62
|
-
env.ORIZU_BASE_URL = context.apiBaseUrl;
|
|
63
|
-
}
|
|
64
|
-
return Object.keys(env).length > 0 ? env : undefined;
|
|
65
|
-
}
|
|
66
|
-
/** Default egress-canary probe host (a known non-allowlisted destination). */
|
|
67
|
-
export const DEFAULT_EGRESS_CANARY_HOST = 'example.com';
|
|
68
|
-
/** Bound (ms) on the canary probe — a live firewall resets/times out fast; a
|
|
69
|
-
* reachable host answers well within this. */
|
|
70
|
-
const EGRESS_CANARY_TIMEOUT_MS = 5000;
|
|
71
|
-
/**
|
|
72
|
-
* Default probe: attempt a bounded HTTPS GET to `host`. A live default-deny
|
|
73
|
-
* firewall makes this THROW for a DENIED host (connection reset / timeout / DNS
|
|
74
|
-
* failure) → `reachable: false`. Any response at all — even an error status —
|
|
75
|
-
* proves the host was reachable → `reachable: true`. Used for BOTH the allowed
|
|
76
|
-
* (positive-control) and denied probes.
|
|
77
|
-
*/
|
|
78
|
-
async function defaultProbeEgress(host) {
|
|
79
|
-
const controller = new AbortController();
|
|
80
|
-
const timer = setTimeout(() => controller.abort(), EGRESS_CANARY_TIMEOUT_MS);
|
|
81
|
-
try {
|
|
82
|
-
const response = await fetch(`https://${host}/`, {
|
|
83
|
-
method: 'GET',
|
|
84
|
-
redirect: 'manual',
|
|
85
|
-
signal: controller.signal,
|
|
86
|
-
});
|
|
87
|
-
return { reachable: true, detail: `reachable (HTTP ${response.status})` };
|
|
88
|
-
}
|
|
89
|
-
catch (error) {
|
|
90
|
-
return { reachable: false, detail: `blocked (${error instanceof Error ? error.name : 'error'})` };
|
|
91
|
-
}
|
|
92
|
-
finally {
|
|
93
|
-
clearTimeout(timer);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Run the startup egress canary as a POSITIVE CONTROL and emit the proof event
|
|
98
|
-
* through the (single-writer) sink. Probes BOTH a known-allowed host and the
|
|
99
|
-
* known-denied host, then decides:
|
|
100
|
-
*
|
|
101
|
-
* - allowed REACHABLE and denied BLOCKED → the firewall is live AND the network
|
|
102
|
-
* works → emit `egress_blocked` and PROCEED (the ONLY healthy outcome);
|
|
103
|
-
* - denied REACHABLE → egress is NOT enforced → emit `egress_allowed`, FAIL
|
|
104
|
-
* CLOSED;
|
|
105
|
-
* - allowed ALSO UNREACHABLE (denied blocked but the positive control failed) →
|
|
106
|
-
* we cannot tell policy-blocking from a total network outage → emit
|
|
107
|
-
* `egress_allowed`, FAIL CLOSED.
|
|
108
|
-
*
|
|
109
|
-
* The `egress_blocked` kind therefore means EXACTLY "proceeded, positive control
|
|
110
|
-
* passed"; `egress_allowed` means "failed closed" (the payload `result` field
|
|
111
|
-
* distinguishes an unexpectedly-reachable denied host from an unreachable
|
|
112
|
-
* positive control). Old behavior — treating ANY denied-host throw (incl. a
|
|
113
|
-
* timeout under a total outage) as "blocked → proceed" — failed OPEN; this now
|
|
114
|
-
* fails closed on that ambiguity.
|
|
115
|
-
*/
|
|
116
|
-
export async function runEgressCanary(targets, sink, probe, now = () => Date.now()) {
|
|
117
|
-
const [allowed, denied] = await Promise.all([probe(targets.allowedHost), probe(targets.deniedHost)]);
|
|
118
|
-
const checkedAt = new Date(now()).toISOString();
|
|
119
|
-
const positiveControl = {
|
|
120
|
-
host: targets.allowedHost,
|
|
121
|
-
reachable: allowed.reachable,
|
|
122
|
-
detail: allowed.detail,
|
|
123
|
-
};
|
|
124
|
-
// FAIL CLOSED: a denied host answered — the policy is not enforcing egress.
|
|
125
|
-
if (denied.reachable) {
|
|
126
|
-
await sink.append({
|
|
127
|
-
kind: 'egress_allowed',
|
|
128
|
-
critical: true,
|
|
129
|
-
payload: {
|
|
130
|
-
host: targets.deniedHost,
|
|
131
|
-
result: 'unexpectedly_reachable',
|
|
132
|
-
detail: denied.detail,
|
|
133
|
-
positiveControl,
|
|
134
|
-
checkedAt,
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
return {
|
|
138
|
-
proceed: false,
|
|
139
|
-
reason: `denied host ${targets.deniedHost} was reachable (${denied.detail}) — egress is not enforced`,
|
|
140
|
-
allowed,
|
|
141
|
-
denied,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
// FAIL CLOSED: the denied host was blocked, but the positive control ALSO did
|
|
145
|
-
// not answer — the environment/network is broken and we cannot distinguish a
|
|
146
|
-
// policy block from a total outage. This is the case the old code got wrong.
|
|
147
|
-
if (!allowed.reachable) {
|
|
148
|
-
await sink.append({
|
|
149
|
-
kind: 'egress_allowed',
|
|
150
|
-
critical: true,
|
|
151
|
-
payload: {
|
|
152
|
-
host: targets.deniedHost,
|
|
153
|
-
result: 'positive_control_unreachable',
|
|
154
|
-
detail: denied.detail,
|
|
155
|
-
positiveControl,
|
|
156
|
-
checkedAt,
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
return {
|
|
160
|
-
proceed: false,
|
|
161
|
-
reason: `positive control ${targets.allowedHost} was unreachable (${allowed.detail}) — cannot distinguish egress enforcement from a network outage`,
|
|
162
|
-
allowed,
|
|
163
|
-
denied,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
// HEALTHY: allowed reachable AND denied blocked → the firewall is live.
|
|
167
|
-
await sink.append({
|
|
168
|
-
kind: 'egress_blocked',
|
|
169
|
-
critical: true,
|
|
170
|
-
payload: {
|
|
171
|
-
host: targets.deniedHost,
|
|
172
|
-
result: 'blocked',
|
|
173
|
-
detail: denied.detail,
|
|
174
|
-
positiveControl,
|
|
175
|
-
checkedAt,
|
|
176
|
-
},
|
|
177
|
-
});
|
|
178
|
-
return { proceed: true, reason: null, allowed, denied };
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Derive the known-ALLOWED positive-control host from the run's Orizu API base —
|
|
182
|
-
* always allowlisted (it is the agent's control-plane path) and ours, so it is
|
|
183
|
-
* the safest "must be reachable" signal. Falls back to a lenient host parse if
|
|
184
|
-
* the base is not a well-formed URL.
|
|
185
|
-
*/
|
|
186
|
-
export function egressCanaryAllowedHost(apiBaseUrl) {
|
|
187
|
-
try {
|
|
188
|
-
return new URL(apiBaseUrl).hostname;
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
return apiBaseUrl.replace(/^[a-z]+:\/\//i, '').split('/')[0].split(':')[0];
|
|
192
|
-
}
|
|
193
|
-
}
|
|
45
|
+
import { buildOpenCodeSpawnEnv, defaultProbeEgress, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
|
|
46
|
+
// The lifecycle/orchestration seam moved to `hosted-loop-lifecycle.ts`
|
|
47
|
+
// (ALI-1015 Phase 1) so `lib/hosted-runtime/` can share it with a server-side
|
|
48
|
+
// coordinator. Re-exported here so this module's public surface is unchanged.
|
|
49
|
+
export { DEFAULT_EGRESS_CANARY_HOST, buildOpenCodeSpawnEnv, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
|
|
194
50
|
/**
|
|
195
51
|
* Default deferred-setup-hook runner (in-sandbox): run `bash .orizu/setup.sh` in
|
|
196
52
|
* the workspace if present. Non-fatal — a missing hook or a non-zero exit is
|
|
@@ -225,9 +81,21 @@ function resolveIdleTimeoutMs(override) {
|
|
|
225
81
|
}
|
|
226
82
|
/** Model-key env vars whose value must be scrubbed from every run event. */
|
|
227
83
|
const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
|
|
84
|
+
/**
|
|
85
|
+
* Env var naming OTHER env vars whose values were injected as secrets — the
|
|
86
|
+
* server env-bundle pull (ALI-1055: `GET /api/coordinator/sessions/:id/
|
|
87
|
+
* env-bundle` returns `redactEnvVars`) writes it as a comma-separated list of
|
|
88
|
+
* names (e.g. `ANTHROPIC_API_KEY,BRAINTRUST_API_KEY`) so every pulled secret
|
|
89
|
+
* (model key + connector credentials) lands on the run-event redaction list.
|
|
90
|
+
*/
|
|
91
|
+
export const INJECTED_ENV_VARS_ENV = 'ORIZU_INJECTED_ENV_VARS';
|
|
228
92
|
function redactionListFromEnv() {
|
|
229
93
|
const secrets = [];
|
|
230
|
-
|
|
94
|
+
const injected = (process.env[INJECTED_ENV_VARS_ENV] ?? '')
|
|
95
|
+
.split(',')
|
|
96
|
+
.map(name => name.trim())
|
|
97
|
+
.filter(name => name.length > 0);
|
|
98
|
+
for (const key of [...MODEL_KEY_ENV_VARS, ...injected]) {
|
|
231
99
|
const value = process.env[key];
|
|
232
100
|
if (value)
|
|
233
101
|
secrets.push(value);
|
|
@@ -175,9 +175,33 @@ async function requestToken(url, bearer, purpose, sessionId) {
|
|
|
175
175
|
return { cred: null, status: res.status }
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
// Resolve the Orizu bearer used to mint a repo token. PULL MODE (ALI-1057): when
|
|
179
|
+
// ctx.agentTokenUrl is set, exchange the durable BOOT SECRET (0600 file) for a
|
|
180
|
+
// FRESH short-lived bearer over HTTP on every mint — the DO-path credential
|
|
181
|
+
// identity is the boot secret, not a host-written 0600 bearer file. FILE MODE:
|
|
182
|
+
// read the 0600 bearer file exactly as before. The rest of mint() is unchanged.
|
|
183
|
+
async function resolveOrizuBearer(ctx) {
|
|
184
|
+
if (ctx.agentTokenUrl) {
|
|
185
|
+
let secret = ''
|
|
186
|
+
try { secret = fs.readFileSync(ctx.bootSecretFile, 'utf8').trim() } catch (_) { secret = '' }
|
|
187
|
+
if (!secret) throw new Error('empty boot secret file')
|
|
188
|
+
const res = await fetch(ctx.agentTokenUrl, {
|
|
189
|
+
method: 'GET',
|
|
190
|
+
headers: { Authorization: 'Bearer ' + secret },
|
|
191
|
+
})
|
|
192
|
+
if (!res.ok) throw new Error('agent-token pull failed (' + res.status + ')')
|
|
193
|
+
let data = null
|
|
194
|
+
try { data = await res.json() } catch (_) { data = null }
|
|
195
|
+
if (!data || !data.token) throw new Error('agent-token pull returned no token')
|
|
196
|
+
return String(data.token)
|
|
197
|
+
}
|
|
179
198
|
const bearer = fs.readFileSync(ctx.bearerFile, 'utf8').trim()
|
|
180
199
|
if (!bearer) throw new Error('empty bearer file')
|
|
200
|
+
return bearer
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function mint(ctx) {
|
|
204
|
+
const bearer = await resolveOrizuBearer(ctx)
|
|
181
205
|
const base = String(ctx.apiBaseUrl || '').replace(/\\/$/, '')
|
|
182
206
|
const url = base + '/api/cli/workspaces/' + encodeURIComponent(ctx.workspaceId) + '/repo-token'
|
|
183
207
|
const purposes = ctx.tokenPurposes || {}
|
|
@@ -41,6 +41,7 @@ import { AGENT_GIT_IDENTITY, BEARER_BASENAME } from './hosted-runtime-assets.js'
|
|
|
41
41
|
import { createVercelProvider } from './vercel-sandbox-provider.js';
|
|
42
42
|
import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js';
|
|
43
43
|
import { hostedLoopCommand } from './hosted-loop.js';
|
|
44
|
+
import { hostedBootCommand } from './hosted-boot.js';
|
|
44
45
|
import { authedFetch } from './http.js';
|
|
45
46
|
import { resolveBaseUrl } from './http.js';
|
|
46
47
|
import { getWorkspaceRoot } from './workspace.js';
|
|
@@ -599,24 +600,102 @@ function numFlag(args, flag) {
|
|
|
599
600
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
600
601
|
}
|
|
601
602
|
const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--duration <min> (default 60, max 1440)] ' +
|
|
602
|
-
'[--model <provider/model>] [--
|
|
603
|
-
'[--
|
|
604
|
-
'
|
|
605
|
-
'
|
|
606
|
-
'
|
|
607
|
-
'
|
|
603
|
+
'[--model <provider/model>] [--reasoning-effort <level>] [--project <team/project>] ' +
|
|
604
|
+
'[--title <title>] [--tail]\n' +
|
|
605
|
+
'Default: the Orizu server provisions the sandbox (no VERCEL_* or model key needed on your machine); ' +
|
|
606
|
+
'the session coordinator owns its lifetime (ALI-1055).\n' +
|
|
607
|
+
'DEPRECATED escape hatch: --operator provisions from THIS machine (requires VERCEL_* + ANTHROPIC_API_KEY ' +
|
|
608
|
+
'or --model-key; extra flags: [--provider vercel|local-sim] [--image <ref>] [--snapshot <id>]). ' +
|
|
609
|
+
'Removal is planned after DO-coordinator validation (ALI-1055).\n' +
|
|
610
|
+
'Note (v0, --operator only): if you disconnect, the agent token expires within its TTL (<=60m) and the run ' +
|
|
611
|
+
'finishes cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).';
|
|
612
|
+
/** Printed on EVERY `--operator` use (founder-locked: deprecate NOW, remove
|
|
613
|
+
* after Phase 6 validation). Loud on purpose. */
|
|
614
|
+
export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-provisioned hosted sessions) is deprecated; ' +
|
|
615
|
+
'removal planned after DO-coordinator validation — ALI-1055. ' +
|
|
616
|
+
'The default server path needs no VERCEL_* or model key on your machine.';
|
|
617
|
+
/**
|
|
618
|
+
* Default hosted path (ALI-1055 cutover): POST the server's hosted-sessions
|
|
619
|
+
* route and let the Durable Object coordinator own the sandbox lifetime. The
|
|
620
|
+
* CLI never reads VERCEL_* or a model key on this path — the server holds them.
|
|
621
|
+
*/
|
|
622
|
+
export async function startHostedViaServer(args, workspaceId, task, tail, io, fetcher = (path, init) => authedFetch(path, init)) {
|
|
623
|
+
const payload = {
|
|
624
|
+
workspaceId,
|
|
625
|
+
task,
|
|
626
|
+
durationMinutes: numFlag(args, '--duration'),
|
|
627
|
+
model: argVal(args, '--model') ?? undefined,
|
|
628
|
+
reasoningEffort: argVal(args, '--reasoning-effort') ?? undefined,
|
|
629
|
+
projectSlug: argVal(args, '--project') ?? undefined,
|
|
630
|
+
title: argVal(args, '--title') ?? undefined,
|
|
631
|
+
};
|
|
632
|
+
let response;
|
|
633
|
+
try {
|
|
634
|
+
response = await fetcher('/api/cli/hosted-sessions', {
|
|
635
|
+
method: 'POST',
|
|
636
|
+
headers: { 'Content-Type': 'application/json' },
|
|
637
|
+
body: JSON.stringify(payload),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
io.printErr?.(`hosted session request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
642
|
+
return 1;
|
|
643
|
+
}
|
|
644
|
+
const body = (await response.json().catch(() => ({})));
|
|
645
|
+
const errorMessage = typeof body.error === 'string' ? body.error : `${response.status}`;
|
|
646
|
+
if (response.status === 503) {
|
|
647
|
+
io.printErr?.(errorMessage);
|
|
648
|
+
io.printErr?.('Re-run with `orizu session start --hosted --operator ...` to provision from your machine ' +
|
|
649
|
+
'(deprecated escape hatch; requires the Vercel token + snapshot on your side).');
|
|
650
|
+
return 1;
|
|
651
|
+
}
|
|
652
|
+
if (!response.ok) {
|
|
653
|
+
io.print(io.json ? JSON.stringify({ ok: false, error: errorMessage }) : `hosted session failed: ${errorMessage}`);
|
|
654
|
+
return 1;
|
|
655
|
+
}
|
|
656
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
|
|
657
|
+
const runId = typeof body.runId === 'string' ? body.runId : '';
|
|
658
|
+
if (io.json) {
|
|
659
|
+
io.print(JSON.stringify({ ok: true, sessionId, runId: runId || null, coordinator: body.coordinator ?? null }));
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
io.print(`hosted session started: session ${sessionId}` +
|
|
663
|
+
(runId ? `, run ${runId}` : '') +
|
|
664
|
+
' (coordinator-managed sandbox)');
|
|
665
|
+
if (!tail && runId)
|
|
666
|
+
io.print(`stream it: orizu run tail --run ${runId}`);
|
|
667
|
+
}
|
|
668
|
+
if (tail && runId) {
|
|
669
|
+
await tailWorkbenchRun({
|
|
670
|
+
fetcher,
|
|
671
|
+
runId,
|
|
672
|
+
onEvent: event => io.print(io.json
|
|
673
|
+
? JSON.stringify(event)
|
|
674
|
+
: `#${String(event.sequence ?? '?')} ${String(event.eventType ?? '')}`),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
608
679
|
/**
|
|
609
680
|
* Dispatch `orizu internal hosted-loop` (in-sandbox) and `orizu session start
|
|
610
681
|
* --hosted` (host-side). Kept out of index.ts so the CLI entrypoint stays a thin
|
|
611
682
|
* dispatcher (ALI-976 ratchet).
|
|
683
|
+
*
|
|
684
|
+
* ALI-1055 cutover: the DEFAULT is the server path (POST /api/cli/hosted-sessions
|
|
685
|
+
* → DO coordinator). `--operator` keeps the old client-provisioned flow as a
|
|
686
|
+
* DEPRECATED escape hatch (loud warning on every use; removal after Phase 6).
|
|
612
687
|
*/
|
|
613
|
-
export async function hostedCommand(args, io) {
|
|
688
|
+
export async function hostedCommand(args, io, deps = {}) {
|
|
614
689
|
const positional = args.filter(arg => !arg.startsWith('--'));
|
|
615
690
|
if (positional[0] === 'internal') {
|
|
616
691
|
if (positional[1] === 'hosted-loop') {
|
|
617
692
|
return hostedLoopCommand(args, io);
|
|
618
693
|
}
|
|
619
|
-
|
|
694
|
+
// ALI-1057: the DO-path in-sandbox boot entrypoint (ORIZU_SANDBOX_ENTRYPOINT).
|
|
695
|
+
if (positional[1] === 'hosted-boot') {
|
|
696
|
+
return hostedBootCommand(io);
|
|
697
|
+
}
|
|
698
|
+
io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot>');
|
|
620
699
|
return 1;
|
|
621
700
|
}
|
|
622
701
|
const task = argVal(args, '--task');
|
|
@@ -630,6 +709,16 @@ export async function hostedCommand(args, io) {
|
|
|
630
709
|
return 1;
|
|
631
710
|
}
|
|
632
711
|
const tail = hasFlag(args, '--tail');
|
|
712
|
+
if (!hasFlag(args, '--operator')) {
|
|
713
|
+
// DEFAULT (ALI-1055): server-provisioned. No provider, no VERCEL_* read, no
|
|
714
|
+
// model key on the customer machine — the coordinator owns the lifetime.
|
|
715
|
+
return (deps.startViaServer ?? startHostedViaServer)(args, workspaceId, task, tail, io);
|
|
716
|
+
}
|
|
717
|
+
// DEPRECATED operator escape hatch — warn LOUDLY on every use.
|
|
718
|
+
io.printErr?.(OPERATOR_DEPRECATION_WARNING);
|
|
719
|
+
if (deps.startViaOperator) {
|
|
720
|
+
return deps.startViaOperator(args, workspaceId, task, tail, io);
|
|
721
|
+
}
|
|
633
722
|
const providerKind = (argVal(args, '--provider') ?? 'vercel').toLowerCase();
|
|
634
723
|
const provider = providerKind === 'local-sim' ? createLocalSimProvider() : createVercelProvider();
|
|
635
724
|
const hostFetcher = (path, init) => authedFetch(path, init);
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import { connectorsCommand } from './connectors-cli.js';
|
|
|
30
30
|
import { ARTIFACT_MAX_BYTES, runnerOptimizerCommand } from './artifact-pull.js';
|
|
31
31
|
import { killSwitchCommand } from './kill-switch-cli.js';
|
|
32
32
|
import { egressAllowlistCommand } from './egress-allowlist-cli.js';
|
|
33
|
+
import { teamConnectorsCommand } from './team-connectors-cli.js';
|
|
33
34
|
import { manifestsCommand } from './manifests-cli.js';
|
|
34
35
|
import { workbenchCommand } from './workbench-cli.js';
|
|
35
36
|
import { hostedCommand } from './hosted-session-cli.js';
|
|
@@ -5168,6 +5169,23 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
5168
5169
|
process.exitCode = await egressAllowlistCommand(cliArgs.slice(1), { json: hasJsonFlag(), print: printLine, printErr: printError });
|
|
5169
5170
|
return;
|
|
5170
5171
|
}
|
|
5172
|
+
if (command === 'team' && subcommand === 'connectors') {
|
|
5173
|
+
process.exitCode = await teamConnectorsCommand(cliArgs.slice(2), {
|
|
5174
|
+
json: hasJsonFlag(),
|
|
5175
|
+
print: printLine,
|
|
5176
|
+
printErr: printError,
|
|
5177
|
+
resolveTeamSlug: async () => {
|
|
5178
|
+
const noInput = hasArg('--no-input') || hasArg('--non-interactive') || !isInteractiveTerminal();
|
|
5179
|
+
try {
|
|
5180
|
+
return (await resolveSetupTeam(getOptionalArgValue('--team'), noInput, false)).slug;
|
|
5181
|
+
}
|
|
5182
|
+
catch {
|
|
5183
|
+
return null;
|
|
5184
|
+
}
|
|
5185
|
+
},
|
|
5186
|
+
});
|
|
5187
|
+
return;
|
|
5188
|
+
}
|
|
5171
5189
|
printUsage();
|
|
5172
5190
|
process.exit(1);
|
|
5173
5191
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral sandbox token-hygiene sweep (ALI-973 / ALI-1050).
|
|
3
|
+
*
|
|
4
|
+
* Extracted out of `daytona-slice.ts` so the hygiene helper carries NO Daytona
|
|
5
|
+
* coupling: it works against any `SandboxProvider`/`SandboxSession` (ADR-003
|
|
6
|
+
* seam) and is imported by the shipped hosted-bootstrap path (`hosted-bootstrap
|
|
7
|
+
* .ts`) as well as the founder-only Daytona slice. Keeping it here means the
|
|
8
|
+
* shipped code never transitively reaches the optional `@daytonaio/sdk`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The list of places a short-lived git token can leak inside a sandbox after a
|
|
12
|
+
* transient clone+push. List-driven so it is trivial to extend. `${repo}` is
|
|
13
|
+
* substituted with the repo path. Ordered from the repo-local stores outward to
|
|
14
|
+
* global config, the credential cache, the reflog, shell history, and the live
|
|
15
|
+
* process listing / argv — the exact surfaces the Daytona-support question names.
|
|
16
|
+
*/
|
|
17
|
+
export function tokenLeakProbes(repoPath) {
|
|
18
|
+
const R = repoPath;
|
|
19
|
+
return [
|
|
20
|
+
{ location: `${R}/.git/config`, command: `cat ${R}/.git/config 2>/dev/null || true` },
|
|
21
|
+
{ location: `${R}/.git/logs (reflog)`, command: `find ${R}/.git/logs -type f -exec cat {} + 2>/dev/null || true` },
|
|
22
|
+
{ location: '~/.git-credentials', command: 'cat "$HOME/.git-credentials" 2>/dev/null || true' },
|
|
23
|
+
{ location: '~/.gitconfig (global)', command: 'cat "$HOME/.gitconfig" 2>/dev/null || true' },
|
|
24
|
+
{ location: 'git credential cache/store', command: 'cat "$HOME/.cache/git/credential/"* "${XDG_CACHE_HOME:-$HOME/.cache}/git/credential/"* 2>/dev/null || true' },
|
|
25
|
+
{ location: 'shell history', command: 'cat "$HOME/.bash_history" "$HOME/.zsh_history" 2>/dev/null || true' },
|
|
26
|
+
{ location: 'process listing / argv', command: 'ps -eo args 2>/dev/null || ps aux 2>/dev/null || true' },
|
|
27
|
+
{ location: 'process env', command: 'env' },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Grep the sandbox filesystem + process table for token values and the
|
|
32
|
+
* `x-access-token` marker across every known leak surface (see `tokenLeakProbes`).
|
|
33
|
+
* Returns every finding; an empty array means the transient operations left no
|
|
34
|
+
* residue. This is the load-bearing check the whole slice exists to make.
|
|
35
|
+
*
|
|
36
|
+
* NOTE (local-sim): the reflog/config/credential-store/history probes run
|
|
37
|
+
* meaningfully in local-sim, but `ps`/argv coverage is only decisive on the live
|
|
38
|
+
* Daytona run (local-sim runs the git clone via a child process on the host, not
|
|
39
|
+
* inside an isolated PID namespace). The WS-F memo marks that probe live-only.
|
|
40
|
+
*/
|
|
41
|
+
export async function sweepForTokenResidue(session, opts) {
|
|
42
|
+
const markers = opts.markers ?? ['x-access-token'];
|
|
43
|
+
const tokens = opts.tokens.filter(token => token.length > 0);
|
|
44
|
+
const probes = opts.probes ?? tokenLeakProbes(opts.repoPath);
|
|
45
|
+
const findings = [];
|
|
46
|
+
for (const probe of probes) {
|
|
47
|
+
const { stdout } = await session.exec(probe.command);
|
|
48
|
+
for (const token of tokens) {
|
|
49
|
+
if (stdout.includes(token)) {
|
|
50
|
+
findings.push({ location: probe.location, marker: 'token-value' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const marker of markers) {
|
|
54
|
+
if (stdout.includes(marker)) {
|
|
55
|
+
findings.push({ location: probe.location, marker });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
package/dist/sandbox-provider.js
CHANGED
|
@@ -51,7 +51,13 @@ import { basename, dirname, join } from 'path';
|
|
|
51
51
|
// cost is a one-time developer/CI tax on this package, not a runtime or
|
|
52
52
|
// shipped-artifact cost. Re-evaluate if @daytonaio/sdk ever becomes a
|
|
53
53
|
// dependency of a package that IS shipped to end users.
|
|
54
|
-
|
|
54
|
+
//
|
|
55
|
+
// The specifier is ASSEMBLED at runtime (join, not a literal) so a bundler that
|
|
56
|
+
// reaches this module through the shared `lib/hosted-runtime/` surface (ALI-1015:
|
|
57
|
+
// `next build` for the app, wrangler for the coordinator Worker) cannot
|
|
58
|
+
// constant-fold `import(specifier)` into a static dependency on the SDK — the
|
|
59
|
+
// same discipline as the ALI-1031 server provider.
|
|
60
|
+
const DAYTONA_SDK_SPECIFIER = ['@daytonaio', 'sdk'].join('/');
|
|
55
61
|
async function loadDaytonaModule() {
|
|
56
62
|
try {
|
|
57
63
|
const specifier = DAYTONA_SDK_SPECIFIER;
|