orizu 0.4.0 → 0.5.0
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/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +226 -0
- package/dist/help.js +276 -21
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +945 -258
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +96 -0
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +366 -0
- package/src/help.ts +277 -21
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +1102 -317
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +164 -0
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +8 -4
- package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G5 — sandbox egress policy builder (ALI-1006, per the hosted-agent threat
|
|
3
|
+
* model T1). Owns the ALLOWLIST CONTENT the `SandboxProvider` seam plumbs to the
|
|
4
|
+
* sandbox firewall at create (`packages/cli/src/vercel-sandbox-provider.ts` →
|
|
5
|
+
* Vercel `networkPolicy`). The Vercel adapter is a pass-through; this file is
|
|
6
|
+
* where "default-deny with a per-team allowlist (Orizu API, model providers, git
|
|
7
|
+
* host); blocked attempts logged" is actually decided.
|
|
8
|
+
*
|
|
9
|
+
* The policy is DEFAULT-DENY: the record form `{ allow: { host: rules } }` denies
|
|
10
|
+
* everything NOT explicitly listed, so the correctness invariant is simply that
|
|
11
|
+
* we NEVER emit a `"*"` allow entry (which would re-open the sandbox). The
|
|
12
|
+
* model-key credential broker (G3 / `buildModelKeyBrokerPolicy`) is COMPOSED onto
|
|
13
|
+
* the model host's rule here so brokering + allowlisting are ONE coherent policy,
|
|
14
|
+
* not two policies that could disagree.
|
|
15
|
+
*
|
|
16
|
+
* SNI matching (verified against @vercel/sandbox@1.10.2 `NetworkPolicy`): an
|
|
17
|
+
* allow entry matches the exact SNI host; a LEADING-wildcard entry (`*.orizu.ai`)
|
|
18
|
+
* matches any single-or-multi-label subdomain but NOT the apex — so a host that
|
|
19
|
+
* must serve BOTH apex and subdomains is listed twice (`orizu.ai` +
|
|
20
|
+
* `*.orizu.ai`). Domains are hostnames only — never a protocol, path, or port.
|
|
21
|
+
*
|
|
22
|
+
* The BASE allowlist (Orizu API + model provider + git host) is CODE-OWNED and
|
|
23
|
+
* non-negotiable: `buildEgressPolicy` always includes it and per-team config can
|
|
24
|
+
* only ADD to it, never remove a base host. See
|
|
25
|
+
* docs/requirements/hosted-customer-workbench/sandbox-egress-policy.md for the
|
|
26
|
+
* rationale per host and the honest statement of what Vercel enforces (firewall,
|
|
27
|
+
* SNI-level) vs. what we generate (blocked-attempt evidence via the canary).
|
|
28
|
+
*/
|
|
29
|
+
/** Default model-provider endpoint (Anthropic) the broker injects a key for. */
|
|
30
|
+
export const ANTHROPIC_API_HOST = 'api.anthropic.com';
|
|
31
|
+
/** Anthropic authenticates with `x-api-key` (not a Bearer Authorization). */
|
|
32
|
+
export const ANTHROPIC_API_KEY_HEADER = 'x-api-key';
|
|
33
|
+
/** GitHub git-over-HTTPS SNIs. `git clone`/`git push` over the smart HTTP
|
|
34
|
+
* protocol connect to `github.com`; `codeload.github.com` serves repository
|
|
35
|
+
* archive/tarball fetches (release assets, `pip`/`go` github-archive installs).
|
|
36
|
+
* Both are allowed so the agent's git work AND any archive fetch its workflow
|
|
37
|
+
* triggers succeed — everything else on github's infra stays denied. */
|
|
38
|
+
export const GIT_HOST_DOMAINS = ['github.com', 'codeload.github.com'];
|
|
39
|
+
/** Default host the startup canary probes to PROVE the policy is live (a known
|
|
40
|
+
* non-allowlisted host: reaching it means egress is NOT enforced). */
|
|
41
|
+
export const DEFAULT_EGRESS_CANARY_HOST = 'example.com';
|
|
42
|
+
/** Default Orizu control-plane base when none is configured. */
|
|
43
|
+
const DEFAULT_ORIZU_BASE_URL = 'https://orizu.ai';
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the Orizu control-plane host from a base URL and CONSERVATIVELY
|
|
46
|
+
* wildcard its registrable base domain so the policy survives the API moving to
|
|
47
|
+
* a subdomain (e.g. `api.orizu.ai`) without re-opening egress. Returns the apex
|
|
48
|
+
* PLUS a leading-wildcard for its subdomains: `orizu.ai` + `*.orizu.ai`. A
|
|
49
|
+
* loopback/localhost base (rehearsal) yields just the bare host (no wildcard —
|
|
50
|
+
* there is nothing to broaden and the firewall does not enforce it anyway).
|
|
51
|
+
*/
|
|
52
|
+
export function orizuEgressDomains(baseUrl) {
|
|
53
|
+
const raw = (baseUrl ?? process.env.ORIZU_BASE_URL ?? DEFAULT_ORIZU_BASE_URL).trim();
|
|
54
|
+
let host;
|
|
55
|
+
try {
|
|
56
|
+
host = new URL(raw).hostname.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Not a URL — treat the value as a bare host (strip any stray path/port).
|
|
60
|
+
host = raw.replace(/^[a-z]+:\/\//i, '').split('/')[0].split(':')[0].toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
if (!host)
|
|
63
|
+
return [DEFAULT_ORIZU_BASE_URL.replace('https://', '')];
|
|
64
|
+
// Loopback / single-label hosts: no base-domain to wildcard.
|
|
65
|
+
if (host === 'localhost' || host === '127.0.0.1' || !host.includes('.')) {
|
|
66
|
+
return [host];
|
|
67
|
+
}
|
|
68
|
+
const labels = host.split('.');
|
|
69
|
+
// Registrable base = last two labels (orizu.ai from api.orizu.ai). Good enough
|
|
70
|
+
// for our single first-party domain; a multi-part TLD would over-broaden, but
|
|
71
|
+
// the base host is always our own domain so this is a controlled input.
|
|
72
|
+
const baseDomain = labels.slice(-2).join('.');
|
|
73
|
+
const domains = new Set([baseDomain, `*.${baseDomain}`]);
|
|
74
|
+
// Also allow the exact configured host if it is a deeper subdomain than the
|
|
75
|
+
// wildcard's single-level match would guarantee (belt-and-braces).
|
|
76
|
+
domains.add(host);
|
|
77
|
+
return [...domains];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Normalize a per-team extra domain to a bare hostname (or leading-wildcard).
|
|
81
|
+
* Strips protocol/path/port, lowercases, and validates the SNI shape. Returns
|
|
82
|
+
* null for anything that is not a plausible hostname so a bad config entry is
|
|
83
|
+
* dropped rather than silently widening the policy in an unexpected way.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeEgressDomain(input) {
|
|
86
|
+
let value = input.trim().toLowerCase();
|
|
87
|
+
if (!value)
|
|
88
|
+
return null;
|
|
89
|
+
value = value.replace(/^[a-z]+:\/\//i, ''); // strip scheme
|
|
90
|
+
value = value.split('/')[0]; // strip path
|
|
91
|
+
value = value.split(':')[0]; // strip port
|
|
92
|
+
if (!value)
|
|
93
|
+
return null;
|
|
94
|
+
// A leading `*.` wildcard is allowed; validate the remainder as a hostname.
|
|
95
|
+
const hostPart = value.startsWith('*.') ? value.slice(2) : value;
|
|
96
|
+
// Labels: alphanumeric + hyphen, dot-separated, at least one dot (reject bare
|
|
97
|
+
// single labels and anything with illegal characters, spaces, or `*` mid-host).
|
|
98
|
+
if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(hostPart)) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Compute the ordered list of BASE (code-owned, non-removable) allow domains for
|
|
105
|
+
* an Orizu base URL: the Orizu control-plane host(s), the model provider host,
|
|
106
|
+
* and the git host(s). Exported so tests and the policy doc assert the exact set.
|
|
107
|
+
*/
|
|
108
|
+
export function baseEgressDomains(opts = {}) {
|
|
109
|
+
const domains = new Set();
|
|
110
|
+
for (const d of orizuEgressDomains(opts.orizuBaseUrl))
|
|
111
|
+
domains.add(d);
|
|
112
|
+
domains.add(opts.modelHost ?? ANTHROPIC_API_HOST);
|
|
113
|
+
for (const d of opts.modelHosts ?? []) {
|
|
114
|
+
const n = normalizeEgressDomain(d);
|
|
115
|
+
if (n)
|
|
116
|
+
domains.add(n);
|
|
117
|
+
}
|
|
118
|
+
for (const d of GIT_HOST_DOMAINS)
|
|
119
|
+
domains.add(d);
|
|
120
|
+
return [...domains];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Build a DEFAULT-DENY Vercel network policy whose allowlist is exactly the base
|
|
124
|
+
* hosts (Orizu API + model provider + git) plus any valid per-team extra domains,
|
|
125
|
+
* with the model-key broker transform COMPOSED onto the model host's rule.
|
|
126
|
+
*
|
|
127
|
+
* Correctness invariants (unit-tested):
|
|
128
|
+
* - record form → default-deny (no `"*"` key is EVER emitted);
|
|
129
|
+
* - every base host is present;
|
|
130
|
+
* - the broker transform sits on the model host's rule (one coherent policy);
|
|
131
|
+
* - base hosts survive regardless of `extraDomains` (cannot be removed).
|
|
132
|
+
*/
|
|
133
|
+
export function buildEgressPolicy(opts = {}) {
|
|
134
|
+
const brokerHost = opts.modelKeyBroker?.host ?? ANTHROPIC_API_HOST;
|
|
135
|
+
const base = baseEgressDomains({
|
|
136
|
+
orizuBaseUrl: opts.orizuBaseUrl,
|
|
137
|
+
modelHost: brokerHost,
|
|
138
|
+
modelHosts: opts.modelHosts,
|
|
139
|
+
});
|
|
140
|
+
const allow = {};
|
|
141
|
+
for (const domain of base)
|
|
142
|
+
allow[domain] = [];
|
|
143
|
+
// Additive per-team domains: normalized + de-duped; NEVER able to unset a base
|
|
144
|
+
// host (base was inserted first; a repeat just re-sets the same empty rule).
|
|
145
|
+
for (const raw of opts.extraDomains ?? []) {
|
|
146
|
+
const domain = normalizeEgressDomain(raw);
|
|
147
|
+
if (domain)
|
|
148
|
+
allow[domain] = allow[domain] ?? [];
|
|
149
|
+
}
|
|
150
|
+
// Compose the broker transform onto the model host's rule (G3 + G5 as one
|
|
151
|
+
// policy). The raw key stays host-side in this object; it is never written into
|
|
152
|
+
// the sandbox — OpenCode is given a non-secret dummy key and the proxy overrides
|
|
153
|
+
// the real header on matching egress.
|
|
154
|
+
if (opts.modelKeyBroker) {
|
|
155
|
+
const headerName = opts.modelKeyBroker.headerName ?? ANTHROPIC_API_KEY_HEADER;
|
|
156
|
+
allow[brokerHost] = [{ transform: [{ headers: { [headerName]: opts.modelKeyBroker.apiKey } }] }];
|
|
157
|
+
}
|
|
158
|
+
return { allow };
|
|
159
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git credential helper (ALI-971 / WS-D).
|
|
3
|
+
*
|
|
4
|
+
* Implements git's credential-helper protocol on stdin/stdout so teammates can
|
|
5
|
+
* `git pull`/`git push` the hosted workbench repo with NO GitHub identity: git
|
|
6
|
+
* asks this helper for a credential, we broker a short-lived, downscoped
|
|
7
|
+
* installation token from Orizu and hand it back as `x-access-token:<token>`.
|
|
8
|
+
* The token is never written to disk (60-min TTL is the backstop).
|
|
9
|
+
*
|
|
10
|
+
* Protocol: git runs `orizu git-credential <op>` where op is get|store|erase
|
|
11
|
+
* and feeds `key=value` lines on stdin terminated by a blank line. Only `get`
|
|
12
|
+
* does work; `store`/`erase` are no-ops. For `get` we respond only when the
|
|
13
|
+
* host is github.com AND the cwd resolves to an attached Orizu workspace —
|
|
14
|
+
* otherwise we stay silent so git falls through to its normal handling.
|
|
15
|
+
*
|
|
16
|
+
* Purpose selection: git does not tell us read-vs-write intent, so we mint
|
|
17
|
+
* `write` first (the common push case for curators) and fall back to `read` on
|
|
18
|
+
* a 403 (plain members). Pure logic + injected fetcher/io: index.ts stays thin.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from 'fs';
|
|
21
|
+
import { dirname, join } from 'path';
|
|
22
|
+
import { authedFetch } from './http.js';
|
|
23
|
+
import { readJsonManifest } from './workspace.js';
|
|
24
|
+
const GITHUB_HOST = 'github.com';
|
|
25
|
+
export function parseCredentialInput(stdin) {
|
|
26
|
+
const map = {};
|
|
27
|
+
for (const rawLine of stdin.split('\n')) {
|
|
28
|
+
const line = rawLine.replace(/\r$/, '');
|
|
29
|
+
if (!line)
|
|
30
|
+
continue;
|
|
31
|
+
const eq = line.indexOf('=');
|
|
32
|
+
if (eq <= 0)
|
|
33
|
+
continue;
|
|
34
|
+
map[line.slice(0, eq)] = line.slice(eq + 1);
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Walk up from `cwd` to the workspace root (the dir holding orizu.team.json) and
|
|
40
|
+
* return its bound workspace id, or null if this is not an attached workspace.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAttachedWorkspaceId(cwd) {
|
|
43
|
+
let dir = cwd;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const manifestPath = join(dir, 'orizu.team.json');
|
|
46
|
+
if (existsSync(manifestPath)) {
|
|
47
|
+
const manifest = readJsonManifest(manifestPath);
|
|
48
|
+
const canonical = manifest?.canonical;
|
|
49
|
+
const serviceId = canonical && typeof canonical === 'object' && !Array.isArray(canonical)
|
|
50
|
+
? canonical.serviceId
|
|
51
|
+
: null;
|
|
52
|
+
if (typeof serviceId === 'string' && serviceId)
|
|
53
|
+
return serviceId;
|
|
54
|
+
const setup = manifest?.setup;
|
|
55
|
+
const attached = setup && typeof setup === 'object' && !Array.isArray(setup)
|
|
56
|
+
? setup.attachedWorkspaceId
|
|
57
|
+
: null;
|
|
58
|
+
return typeof attached === 'string' && attached ? attached : null;
|
|
59
|
+
}
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir)
|
|
62
|
+
return null;
|
|
63
|
+
dir = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the workspace id, preferring an explicit override, then the
|
|
68
|
+
* `ORIZU_WORKSPACE_ID` env var (set during `git clone` so the helper works
|
|
69
|
+
* before the working tree — and thus orizu.team.json — exists), then the cwd
|
|
70
|
+
* manifest walk for steady-state pull/push.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveWorkspaceId(io) {
|
|
73
|
+
if (io.workspaceId)
|
|
74
|
+
return io.workspaceId;
|
|
75
|
+
const fromEnv = process.env.ORIZU_WORKSPACE_ID?.trim();
|
|
76
|
+
if (fromEnv)
|
|
77
|
+
return fromEnv;
|
|
78
|
+
return resolveAttachedWorkspaceId(io.cwd);
|
|
79
|
+
}
|
|
80
|
+
async function mintToken(fetcher, workspaceId, purpose) {
|
|
81
|
+
const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ purpose }),
|
|
85
|
+
});
|
|
86
|
+
if (response.ok) {
|
|
87
|
+
const data = (await response.json());
|
|
88
|
+
if (data.token)
|
|
89
|
+
return { ok: true, token: data.token };
|
|
90
|
+
return { ok: false, status: response.status, error: 'Broker returned no token' };
|
|
91
|
+
}
|
|
92
|
+
let error = `status ${response.status}`;
|
|
93
|
+
try {
|
|
94
|
+
const body = (await response.json());
|
|
95
|
+
if (body.error)
|
|
96
|
+
error = body.error;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Non-JSON body; keep the status-based message.
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, status: response.status, error };
|
|
102
|
+
}
|
|
103
|
+
export async function runGitCredential(op, io) {
|
|
104
|
+
// store/erase are no-ops: we never persist a credential to disk.
|
|
105
|
+
if (op !== 'get')
|
|
106
|
+
return 0;
|
|
107
|
+
const input = parseCredentialInput(io.stdin);
|
|
108
|
+
// Only broker for github.com; anything else is git's own business.
|
|
109
|
+
if (input.host && input.host !== GITHUB_HOST)
|
|
110
|
+
return 0;
|
|
111
|
+
const workspaceId = resolveWorkspaceId(io);
|
|
112
|
+
if (!workspaceId) {
|
|
113
|
+
// Unattached directory: stay silent so git falls through cleanly.
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
const fetcher = io.fetcher ?? authedFetch;
|
|
117
|
+
// Mint write first (curators/pushers), fall back to read on 403 (members).
|
|
118
|
+
let minted = await mintToken(fetcher, workspaceId, 'write');
|
|
119
|
+
if (!minted.ok && minted.status === 403) {
|
|
120
|
+
minted = await mintToken(fetcher, workspaceId, 'read');
|
|
121
|
+
}
|
|
122
|
+
if (!minted.ok) {
|
|
123
|
+
io.printErr(`orizu git-credential: could not broker a token for this workspace (${minted.error}). ` +
|
|
124
|
+
'Ensure you are logged in (`orizu login`) and a team admin/curator has provisioned the repo.');
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
io.print('username=x-access-token');
|
|
128
|
+
io.print(`password=${minted.token}`);
|
|
129
|
+
io.print('');
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub link + hosted-repo attach for `orizu setup` (ALI-971 / WS-C + WS-D).
|
|
3
|
+
*
|
|
4
|
+
* Pure logic + injected HTTP/git/io so index.ts stays thin and the flows are
|
|
5
|
+
* unit-testable without a real browser, server, or git. Two responsibilities:
|
|
6
|
+
*
|
|
7
|
+
* 1. `runGithubLink` — the REQUIRED team-setup link step: POST the link route,
|
|
8
|
+
* open/print the App-install URL, then poll until the org is connected
|
|
9
|
+
* (with a timeout + resume guidance).
|
|
10
|
+
* 2. `runHostedAttach` — clone the provisioned workbench repo and wire the
|
|
11
|
+
* repo-local git credential helper so future pull/push flow through the
|
|
12
|
+
* Orizu broker. Never stores a long-lived credential: the clone uses a
|
|
13
|
+
* one-shot brokered token embedded only in the transient clone URL, which
|
|
14
|
+
* is immediately rewritten to a clean origin.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import { mkdirSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { authedFetch } from './http.js';
|
|
20
|
+
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
21
|
+
const DEFAULT_POLL_TIMEOUT_MS = 180000;
|
|
22
|
+
function defaultSleep(ms) {
|
|
23
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
25
|
+
async function readError(response) {
|
|
26
|
+
try {
|
|
27
|
+
const body = (await response.json());
|
|
28
|
+
if (body.error)
|
|
29
|
+
return body.error;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// fall through to status text
|
|
33
|
+
}
|
|
34
|
+
return `status ${response.status}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Begin (or resume) the required GitHub link for a team and block until the
|
|
38
|
+
* customer org is connected. Throws with resume guidance on timeout.
|
|
39
|
+
*/
|
|
40
|
+
export async function runGithubLink(teamSlug, io) {
|
|
41
|
+
const fetcher = io.fetcher ?? authedFetch;
|
|
42
|
+
const start = await fetcher('/api/cli/github/link', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ teamSlug }),
|
|
46
|
+
});
|
|
47
|
+
if (start.status === 409) {
|
|
48
|
+
io.print(' GitHub org already connected for this team.');
|
|
49
|
+
return { status: 'active', orgLogin: null, alreadyLinked: true };
|
|
50
|
+
}
|
|
51
|
+
if (!start.ok) {
|
|
52
|
+
throw new Error(`Failed to start GitHub link: ${await readError(start)}`);
|
|
53
|
+
}
|
|
54
|
+
const { installUrl, state } = (await start.json());
|
|
55
|
+
io.print(' Connect your team GitHub org by installing the Orizu GitHub App:');
|
|
56
|
+
io.print(` ${installUrl}`);
|
|
57
|
+
if (io.openUrl) {
|
|
58
|
+
io.openUrl(installUrl);
|
|
59
|
+
io.print(' Opened the install page in your browser. Waiting for you to finish...');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
io.print(' Open the URL above, install the App, then return here. Waiting...');
|
|
63
|
+
}
|
|
64
|
+
const now = io.now ?? (() => Date.now());
|
|
65
|
+
const sleep = io.sleep ?? defaultSleep;
|
|
66
|
+
const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
67
|
+
const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
|
|
68
|
+
const deadline = now() + timeout;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`);
|
|
71
|
+
if (poll.ok) {
|
|
72
|
+
const data = (await poll.json());
|
|
73
|
+
if (data.status === 'active') {
|
|
74
|
+
io.print(` GitHub org connected${data.orgLogin ? ` (${data.orgLogin})` : ''}.`);
|
|
75
|
+
return { status: 'active', orgLogin: data.orgLogin ?? null, alreadyLinked: false };
|
|
76
|
+
}
|
|
77
|
+
if (data.status === 'pending_confirmation') {
|
|
78
|
+
return await confirmLink(teamSlug, data.orgLogin ?? null, io, fetcher);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (now() >= deadline) {
|
|
82
|
+
throw new Error('Timed out waiting for the GitHub App install to complete. ' +
|
|
83
|
+
`Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`);
|
|
84
|
+
}
|
|
85
|
+
await sleep(interval);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The install callback bound an org but left the row `pending_confirmation`.
|
|
90
|
+
* Confirm the org with the human (second factor), then complete via the
|
|
91
|
+
* authenticated confirm endpoint — this is the only path to `active`.
|
|
92
|
+
*/
|
|
93
|
+
async function confirmLink(teamSlug, orgLogin, io, fetcher) {
|
|
94
|
+
io.print(` GitHub reported installation on org: ${orgLogin ?? '(unknown)'}`);
|
|
95
|
+
if (!io.confirm) {
|
|
96
|
+
throw new Error(`Org ${orgLogin ?? '(unknown)'} is bound but needs confirmation. ` +
|
|
97
|
+
`Run \`orizu github link --team ${teamSlug}\` interactively to confirm.`);
|
|
98
|
+
}
|
|
99
|
+
const approved = await io.confirm(orgLogin);
|
|
100
|
+
if (!approved) {
|
|
101
|
+
throw new Error(`Did not confirm org ${orgLogin ?? '(unknown)'}. ` +
|
|
102
|
+
`Re-run \`orizu github link --team ${teamSlug}\` to link the correct org.`);
|
|
103
|
+
}
|
|
104
|
+
const resp = await fetcher('/api/cli/github/link/confirm', {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ teamSlug, orgLogin }),
|
|
108
|
+
});
|
|
109
|
+
if (!resp.ok) {
|
|
110
|
+
throw new Error(`Failed to confirm GitHub link: ${await readError(resp)}`);
|
|
111
|
+
}
|
|
112
|
+
const confirmed = (await resp.json());
|
|
113
|
+
io.print(` GitHub org connected (${confirmed.orgLogin ?? orgLogin ?? 'linked'}).`);
|
|
114
|
+
return { status: 'active', orgLogin: confirmed.orgLogin ?? orgLogin, alreadyLinked: false };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Decide how `orizu setup` should materialize the workspace. `--local` (or the
|
|
118
|
+
* absence of a linked org) keeps Phase 1 scaffolding; a linked org with a
|
|
119
|
+
* provisioned repo attaches by clone; a linked org without a repo provisions
|
|
120
|
+
* first, then attaches.
|
|
121
|
+
*/
|
|
122
|
+
export function decideSetupMode(input) {
|
|
123
|
+
if (input.local || !input.activeInstallation)
|
|
124
|
+
return 'local';
|
|
125
|
+
return input.repoFullName ? 'attach' : 'provision-then-attach';
|
|
126
|
+
}
|
|
127
|
+
function gitOrThrow(git, args, opts) {
|
|
128
|
+
const result = git(args, opts);
|
|
129
|
+
if (result.status !== 0) {
|
|
130
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr || `exit ${result.status}`}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const CREDENTIAL_HELPER = '!orizu git-credential';
|
|
134
|
+
/**
|
|
135
|
+
* Provision-if-needed, then clone with the Orizu credential helper already
|
|
136
|
+
* configured so the brokered token NEVER touches argv, the clone URL, or
|
|
137
|
+
* .git/config. The helper resolves the workspace during clone via the
|
|
138
|
+
* `ORIZU_WORKSPACE_ID` env var (the working tree — and orizu.team.json — does
|
|
139
|
+
* not exist yet), then mints on demand. Repo-local config persists the helper
|
|
140
|
+
* for subsequent pull/push, which resolve the workspace from cwd.
|
|
141
|
+
*/
|
|
142
|
+
export async function runHostedAttach(options, io) {
|
|
143
|
+
const fetcher = io.fetcher ?? authedFetch;
|
|
144
|
+
let repoFullName = options.repoFullName;
|
|
145
|
+
let provisioned = false;
|
|
146
|
+
if (!repoFullName) {
|
|
147
|
+
io.print(' Provisioning the hosted workbench repo...');
|
|
148
|
+
const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(options.workspaceId)}/provision-repo`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
body: JSON.stringify({}),
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error(`Failed to provision workbench repo: ${await readError(response)}`);
|
|
155
|
+
}
|
|
156
|
+
const data = (await response.json());
|
|
157
|
+
repoFullName = data.repo;
|
|
158
|
+
provisioned = true;
|
|
159
|
+
}
|
|
160
|
+
const cleanUrl = `https://github.com/${repoFullName}.git`;
|
|
161
|
+
io.print(` Cloning ${repoFullName}...`);
|
|
162
|
+
// Clone with the helper inline + the workspace id in the env so the token is
|
|
163
|
+
// brokered on demand and never materializes in the URL or on disk.
|
|
164
|
+
gitOrThrow(io.git, [
|
|
165
|
+
'-c',
|
|
166
|
+
`credential.helper=${CREDENTIAL_HELPER}`,
|
|
167
|
+
'-c',
|
|
168
|
+
'credential.useHttpPath=true',
|
|
169
|
+
'clone',
|
|
170
|
+
cleanUrl,
|
|
171
|
+
options.targetDir,
|
|
172
|
+
], { env: { ORIZU_WORKSPACE_ID: options.workspaceId } });
|
|
173
|
+
// Persist the helper repo-locally for future pull/push (cwd-resolved).
|
|
174
|
+
gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
|
|
175
|
+
gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
|
|
176
|
+
const ensureOrizuDir = io.ensureOrizuDir ?? ((repoDir) => mkdirSync(join(repoDir, '.orizu'), { recursive: true }));
|
|
177
|
+
ensureOrizuDir(options.targetDir);
|
|
178
|
+
io.print(' Attached. Configured the Orizu credential helper for git.');
|
|
179
|
+
return { repoFullName, provisioned, targetDir: options.targetDir };
|
|
180
|
+
}
|
|
181
|
+
// -- Interactive setup dispatch (ALI-996 / WS-C crumb) ----------------------
|
|
182
|
+
function defaultGitRunner(args, opts) {
|
|
183
|
+
// Strip repo-context env (set by git when running inside hooks) so the
|
|
184
|
+
// child git is scoped strictly to cwd/args — see daytona-slice-rehearsal.ts.
|
|
185
|
+
const env = { ...process.env };
|
|
186
|
+
delete env.GIT_DIR;
|
|
187
|
+
delete env.GIT_WORK_TREE;
|
|
188
|
+
delete env.GIT_INDEX_FILE;
|
|
189
|
+
const result = spawnSync('git', args, { cwd: opts?.cwd, encoding: 'utf8', env });
|
|
190
|
+
return { status: result.status ?? 1, stderr: result.stderr };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Bridge the required github-link step to the hosted attach. Create-or-attaches
|
|
194
|
+
* the server workspace (whose response now surfaces repoProvider/repoFullName —
|
|
195
|
+
* ALI-972), then routes through the already-tested `decideSetupMode`:
|
|
196
|
+
* - `local` → returns `attached: false`; the caller scaffolds.
|
|
197
|
+
* - `attach` / `provision-then-attach` → clones (provisioning first if needed)
|
|
198
|
+
* via `runHostedAttach` and returns `attached: true`.
|
|
199
|
+
*/
|
|
200
|
+
export async function runInteractiveHostedSetup(input, io) {
|
|
201
|
+
const fetcher = io.fetcher ?? authedFetch;
|
|
202
|
+
const response = await fetcher('/api/cli/workspaces', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ teamSlug: input.teamSlug, name: input.teamSlug, slug: input.teamSlug }),
|
|
206
|
+
});
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(`Failed to attach workspace: ${await readError(response)}`);
|
|
209
|
+
}
|
|
210
|
+
const data = (await response.json());
|
|
211
|
+
const workspace = data.workspace;
|
|
212
|
+
if (!workspace?.id) {
|
|
213
|
+
throw new Error('Workspace attach response did not include an id');
|
|
214
|
+
}
|
|
215
|
+
const repoFullName = workspace.repoFullName ?? null;
|
|
216
|
+
const mode = decideSetupMode({
|
|
217
|
+
local: input.local,
|
|
218
|
+
activeInstallation: input.activeInstallation,
|
|
219
|
+
repoFullName,
|
|
220
|
+
});
|
|
221
|
+
if (mode === 'local') {
|
|
222
|
+
return { mode, attached: false, repoFullName: null };
|
|
223
|
+
}
|
|
224
|
+
const result = await runHostedAttach({ workspaceId: workspace.id, repoFullName, targetDir: input.targetDir }, { print: io.print, fetcher, git: io.git ?? defaultGitRunner, ensureOrizuDir: io.ensureOrizuDir });
|
|
225
|
+
return { mode, attached: true, repoFullName: result.repoFullName };
|
|
226
|
+
}
|