orizu 0.4.1 → 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.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +271 -16
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +855 -237
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +39 -2
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +272 -16
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +987 -296
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +70 -2
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +3 -1
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Per-team ADDITIVE egress allowlist CLI (ALI-1006 / G5):
3
+ * orizu team egress-allowlist <team> # show extra domains
4
+ * orizu team egress-allowlist <team> --add <domain>
5
+ * orizu team egress-allowlist <team> --remove <domain>
6
+ * orizu team egress-allowlist <team> --set a.com,b.com
7
+ * orizu team egress-allowlist <team> --clear
8
+ *
9
+ * The BASE allowlist (Orizu API + model provider + git host) is code-owned and
10
+ * NOT shown/managed here — this surface only widens a team's egress with domains
11
+ * a customer workflow needs. Human ADMIN only: the server rejects agent bearers
12
+ * and non-admins (the definer RPC gates on is_team_admin). Pure command logic
13
+ * with an injected fetcher.
14
+ */
15
+
16
+ import { authedFetch } from './http.js'
17
+
18
+ export type CliFetcher = (path: string, init?: RequestInit) => Promise<Response>
19
+
20
+ export interface EgressAllowlistCommandIo {
21
+ json: boolean
22
+ print: (line: string) => void
23
+ printErr?: (line: string) => void
24
+ fetcher?: CliFetcher
25
+ }
26
+
27
+ function fetcherFrom(io: EgressAllowlistCommandIo): CliFetcher {
28
+ return io.fetcher ?? authedFetch
29
+ }
30
+
31
+ function argValue(args: readonly string[], flag: string): string | null {
32
+ const index = args.indexOf(flag)
33
+ if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
34
+ return null
35
+ }
36
+ return args[index + 1]
37
+ }
38
+
39
+ function firstPositional(args: readonly string[]): string | null {
40
+ for (let i = 0; i < args.length; i += 1) {
41
+ const arg = args[i]
42
+ if (arg.startsWith('--')) {
43
+ if (argValue(args, arg) !== null) i += 1
44
+ continue
45
+ }
46
+ return arg
47
+ }
48
+ return null
49
+ }
50
+
51
+ function printErr(io: EgressAllowlistCommandIo, message: string): void {
52
+ ;(io.printErr ?? io.print)(message)
53
+ }
54
+
55
+ async function responseMessage(response: Response): Promise<string> {
56
+ try {
57
+ const data = (await response.clone().json()) as Record<string, unknown>
58
+ const error = typeof data.error === 'string' ? data.error : null
59
+ return error || response.statusText || String(response.status)
60
+ } catch {
61
+ return response.statusText || String(response.status)
62
+ }
63
+ }
64
+
65
+ function splitDomains(value: string): string[] {
66
+ return value
67
+ .split(',')
68
+ .map(d => d.trim())
69
+ .filter(d => d.length > 0)
70
+ }
71
+
72
+ async function fetchCurrent(fetcher: CliFetcher, path: string): Promise<string[]> {
73
+ const response = await fetcher(path, { method: 'GET' })
74
+ if (!response.ok) {
75
+ throw new Error(`show failed (${response.status}): ${await responseMessage(response)}`)
76
+ }
77
+ const data = (await response.json()) as { extraDomains?: unknown }
78
+ return Array.isArray(data.extraDomains) ? data.extraDomains.filter((d): d is string => typeof d === 'string') : []
79
+ }
80
+
81
+ function emit(io: EgressAllowlistCommandIo, teamSlug: string, domains: string[]): void {
82
+ if (io.json) {
83
+ io.print(JSON.stringify({ teamSlug, extraDomains: domains }))
84
+ return
85
+ }
86
+ io.print(
87
+ domains.length === 0
88
+ ? `${teamSlug}: no additive egress domains (base allowlist only)`
89
+ : `${teamSlug} additive egress domains:\n ${domains.join('\n ')}`
90
+ )
91
+ }
92
+
93
+ export async function egressAllowlistCommand(args: string[], io: EgressAllowlistCommandIo): Promise<number> {
94
+ const teamSlug = firstPositional(args)
95
+ if (!teamSlug) {
96
+ printErr(io, 'Usage: orizu team egress-allowlist <team> [--add <domain>|--remove <domain>|--set a.com,b.com|--clear] [--json]')
97
+ return 1
98
+ }
99
+ const path = `/api/cli/teams/${encodeURIComponent(teamSlug)}/egress-allowlist`
100
+ const fetcher = fetcherFrom(io)
101
+
102
+ const setValue = argValue(args, '--set')
103
+ const addValue = argValue(args, '--add')
104
+ const removeValue = argValue(args, '--remove')
105
+ const clear = args.includes('--clear')
106
+ const isMutation = setValue !== null || addValue !== null || removeValue !== null || clear
107
+
108
+ try {
109
+ if (!isMutation) {
110
+ emit(io, teamSlug, await fetchCurrent(fetcher, path))
111
+ return 0
112
+ }
113
+
114
+ // Compute the next set. --set/--clear replace; --add/--remove mutate current.
115
+ let next: string[]
116
+ if (setValue !== null) {
117
+ next = splitDomains(setValue)
118
+ } else if (clear) {
119
+ next = []
120
+ } else {
121
+ const current = await fetchCurrent(fetcher, path)
122
+ const set = new Set(current)
123
+ if (addValue) for (const d of splitDomains(addValue)) set.add(d)
124
+ if (removeValue) for (const d of splitDomains(removeValue)) set.delete(d)
125
+ next = [...set]
126
+ }
127
+
128
+ const response = await fetcher(path, {
129
+ method: 'PUT',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ domains: next }),
132
+ })
133
+ if (!response.ok) {
134
+ printErr(io, `egress-allowlist update failed (${response.status}): ${await responseMessage(response)}`)
135
+ return 1
136
+ }
137
+ const data = (await response.json()) as { extraDomains?: unknown }
138
+ const stored = Array.isArray(data.extraDomains)
139
+ ? data.extraDomains.filter((d): d is string => typeof d === 'string')
140
+ : next
141
+ emit(io, teamSlug, stored)
142
+ return 0
143
+ } catch (error) {
144
+ printErr(io, error instanceof Error ? error.message : String(error))
145
+ return 1
146
+ }
147
+ }
@@ -0,0 +1,188 @@
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
+
30
+ import type { SandboxEgressPolicy, SandboxEgressRule } from './sandbox-provider.js'
31
+
32
+ /** Default model-provider endpoint (Anthropic) the broker injects a key for. */
33
+ export const ANTHROPIC_API_HOST = 'api.anthropic.com'
34
+ /** Anthropic authenticates with `x-api-key` (not a Bearer Authorization). */
35
+ export const ANTHROPIC_API_KEY_HEADER = 'x-api-key'
36
+
37
+ /** GitHub git-over-HTTPS SNIs. `git clone`/`git push` over the smart HTTP
38
+ * protocol connect to `github.com`; `codeload.github.com` serves repository
39
+ * archive/tarball fetches (release assets, `pip`/`go` github-archive installs).
40
+ * Both are allowed so the agent's git work AND any archive fetch its workflow
41
+ * triggers succeed — everything else on github's infra stays denied. */
42
+ export const GIT_HOST_DOMAINS = ['github.com', 'codeload.github.com'] as const
43
+
44
+ /** Default host the startup canary probes to PROVE the policy is live (a known
45
+ * non-allowlisted host: reaching it means egress is NOT enforced). */
46
+ export const DEFAULT_EGRESS_CANARY_HOST = 'example.com'
47
+
48
+ /** Default Orizu control-plane base when none is configured. */
49
+ const DEFAULT_ORIZU_BASE_URL = 'https://orizu.ai'
50
+
51
+ /**
52
+ * Resolve the Orizu control-plane host from a base URL and CONSERVATIVELY
53
+ * wildcard its registrable base domain so the policy survives the API moving to
54
+ * a subdomain (e.g. `api.orizu.ai`) without re-opening egress. Returns the apex
55
+ * PLUS a leading-wildcard for its subdomains: `orizu.ai` + `*.orizu.ai`. A
56
+ * loopback/localhost base (rehearsal) yields just the bare host (no wildcard —
57
+ * there is nothing to broaden and the firewall does not enforce it anyway).
58
+ */
59
+ export function orizuEgressDomains(baseUrl: string | undefined): string[] {
60
+ const raw = (baseUrl ?? process.env.ORIZU_BASE_URL ?? DEFAULT_ORIZU_BASE_URL).trim()
61
+ let host: string
62
+ try {
63
+ host = new URL(raw).hostname.toLowerCase()
64
+ } catch {
65
+ // Not a URL — treat the value as a bare host (strip any stray path/port).
66
+ host = raw.replace(/^[a-z]+:\/\//i, '').split('/')[0].split(':')[0].toLowerCase()
67
+ }
68
+ if (!host) return [DEFAULT_ORIZU_BASE_URL.replace('https://', '')]
69
+ // Loopback / single-label hosts: no base-domain to wildcard.
70
+ if (host === 'localhost' || host === '127.0.0.1' || !host.includes('.')) {
71
+ return [host]
72
+ }
73
+ const labels = host.split('.')
74
+ // Registrable base = last two labels (orizu.ai from api.orizu.ai). Good enough
75
+ // for our single first-party domain; a multi-part TLD would over-broaden, but
76
+ // the base host is always our own domain so this is a controlled input.
77
+ const baseDomain = labels.slice(-2).join('.')
78
+ const domains = new Set<string>([baseDomain, `*.${baseDomain}`])
79
+ // Also allow the exact configured host if it is a deeper subdomain than the
80
+ // wildcard's single-level match would guarantee (belt-and-braces).
81
+ domains.add(host)
82
+ return [...domains]
83
+ }
84
+
85
+ /**
86
+ * Normalize a per-team extra domain to a bare hostname (or leading-wildcard).
87
+ * Strips protocol/path/port, lowercases, and validates the SNI shape. Returns
88
+ * null for anything that is not a plausible hostname so a bad config entry is
89
+ * dropped rather than silently widening the policy in an unexpected way.
90
+ */
91
+ export function normalizeEgressDomain(input: string): string | null {
92
+ let value = input.trim().toLowerCase()
93
+ if (!value) return null
94
+ value = value.replace(/^[a-z]+:\/\//i, '') // strip scheme
95
+ value = value.split('/')[0] // strip path
96
+ value = value.split(':')[0] // strip port
97
+ if (!value) return null
98
+ // A leading `*.` wildcard is allowed; validate the remainder as a hostname.
99
+ const hostPart = value.startsWith('*.') ? value.slice(2) : value
100
+ // Labels: alphanumeric + hyphen, dot-separated, at least one dot (reject bare
101
+ // single labels and anything with illegal characters, spaces, or `*` mid-host).
102
+ 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)) {
103
+ return null
104
+ }
105
+ return value
106
+ }
107
+
108
+ export interface EgressModelKeyBroker {
109
+ /** The raw model API key. It lives ONLY in the policy the host sends to the
110
+ * firewall control plane; it never enters the sandbox env/disk/run events. */
111
+ apiKey: string
112
+ /** Domain to inject the key for (default api.anthropic.com). */
113
+ host?: string
114
+ /** Header the endpoint authenticates with (default x-api-key). */
115
+ headerName?: string
116
+ }
117
+
118
+ export interface BuildEgressPolicyOptions {
119
+ /** Orizu control-plane base URL (default env ORIZU_BASE_URL / orizu.ai). */
120
+ orizuBaseUrl?: string
121
+ /** Additional model-provider hosts to allow WITHOUT a key transform. The broker
122
+ * host (below) is added automatically; list only extra providers here. */
123
+ modelHosts?: readonly string[]
124
+ /** Compose the G3 model-key broker transform onto its host's allow rule, so
125
+ * brokering and the allowlist are one policy. */
126
+ modelKeyBroker?: EgressModelKeyBroker
127
+ /** Per-team ADDITIVE domains (a customer's own API a workflow needs). Base
128
+ * hosts can never be removed; these only widen. Invalid entries are dropped. */
129
+ extraDomains?: readonly string[]
130
+ }
131
+
132
+ /**
133
+ * Compute the ordered list of BASE (code-owned, non-removable) allow domains for
134
+ * an Orizu base URL: the Orizu control-plane host(s), the model provider host,
135
+ * and the git host(s). Exported so tests and the policy doc assert the exact set.
136
+ */
137
+ export function baseEgressDomains(opts: { orizuBaseUrl?: string; modelHost?: string; modelHosts?: readonly string[] } = {}): string[] {
138
+ const domains = new Set<string>()
139
+ for (const d of orizuEgressDomains(opts.orizuBaseUrl)) domains.add(d)
140
+ domains.add(opts.modelHost ?? ANTHROPIC_API_HOST)
141
+ for (const d of opts.modelHosts ?? []) {
142
+ const n = normalizeEgressDomain(d)
143
+ if (n) domains.add(n)
144
+ }
145
+ for (const d of GIT_HOST_DOMAINS) domains.add(d)
146
+ return [...domains]
147
+ }
148
+
149
+ /**
150
+ * Build a DEFAULT-DENY Vercel network policy whose allowlist is exactly the base
151
+ * hosts (Orizu API + model provider + git) plus any valid per-team extra domains,
152
+ * with the model-key broker transform COMPOSED onto the model host's rule.
153
+ *
154
+ * Correctness invariants (unit-tested):
155
+ * - record form → default-deny (no `"*"` key is EVER emitted);
156
+ * - every base host is present;
157
+ * - the broker transform sits on the model host's rule (one coherent policy);
158
+ * - base hosts survive regardless of `extraDomains` (cannot be removed).
159
+ */
160
+ export function buildEgressPolicy(opts: BuildEgressPolicyOptions = {}): SandboxEgressPolicy {
161
+ const brokerHost = opts.modelKeyBroker?.host ?? ANTHROPIC_API_HOST
162
+ const base = baseEgressDomains({
163
+ orizuBaseUrl: opts.orizuBaseUrl,
164
+ modelHost: brokerHost,
165
+ modelHosts: opts.modelHosts,
166
+ })
167
+
168
+ const allow: Record<string, SandboxEgressRule[]> = {}
169
+ for (const domain of base) allow[domain] = []
170
+
171
+ // Additive per-team domains: normalized + de-duped; NEVER able to unset a base
172
+ // host (base was inserted first; a repeat just re-sets the same empty rule).
173
+ for (const raw of opts.extraDomains ?? []) {
174
+ const domain = normalizeEgressDomain(raw)
175
+ if (domain) allow[domain] = allow[domain] ?? []
176
+ }
177
+
178
+ // Compose the broker transform onto the model host's rule (G3 + G5 as one
179
+ // policy). The raw key stays host-side in this object; it is never written into
180
+ // the sandbox — OpenCode is given a non-secret dummy key and the proxy overrides
181
+ // the real header on matching egress.
182
+ if (opts.modelKeyBroker) {
183
+ const headerName = opts.modelKeyBroker.headerName ?? ANTHROPIC_API_KEY_HEADER
184
+ allow[brokerHost] = [{ transform: [{ headers: { [headerName]: opts.modelKeyBroker.apiKey } }] }]
185
+ }
186
+
187
+ return { allow }
188
+ }
@@ -0,0 +1,155 @@
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
+
21
+ import { existsSync } from 'fs'
22
+ import { dirname, join } from 'path'
23
+
24
+ import { authedFetch } from './http.js'
25
+ import { readJsonManifest } from './workspace.js'
26
+
27
+ export type GitCredentialFetcher = (path: string, init?: RequestInit) => Promise<Response>
28
+
29
+ export interface GitCredentialIo {
30
+ stdin: string
31
+ cwd: string
32
+ print: (line: string) => void
33
+ printErr: (line: string) => void
34
+ fetcher?: GitCredentialFetcher
35
+ /** Explicit workspace id (tests / clone-time). Overrides env + cwd lookup. */
36
+ workspaceId?: string
37
+ }
38
+
39
+ const GITHUB_HOST = 'github.com'
40
+
41
+ export function parseCredentialInput(stdin: string): Record<string, string> {
42
+ const map: Record<string, string> = {}
43
+ for (const rawLine of stdin.split('\n')) {
44
+ const line = rawLine.replace(/\r$/, '')
45
+ if (!line) continue
46
+ const eq = line.indexOf('=')
47
+ if (eq <= 0) continue
48
+ map[line.slice(0, eq)] = line.slice(eq + 1)
49
+ }
50
+ return map
51
+ }
52
+
53
+ /**
54
+ * Walk up from `cwd` to the workspace root (the dir holding orizu.team.json) and
55
+ * return its bound workspace id, or null if this is not an attached workspace.
56
+ */
57
+ export function resolveAttachedWorkspaceId(cwd: string): string | null {
58
+ let dir = cwd
59
+ for (;;) {
60
+ const manifestPath = join(dir, 'orizu.team.json')
61
+ if (existsSync(manifestPath)) {
62
+ const manifest = readJsonManifest(manifestPath)
63
+ const canonical = manifest?.canonical
64
+ const serviceId =
65
+ canonical && typeof canonical === 'object' && !Array.isArray(canonical)
66
+ ? (canonical as Record<string, unknown>).serviceId
67
+ : null
68
+ if (typeof serviceId === 'string' && serviceId) return serviceId
69
+ const setup = manifest?.setup
70
+ const attached =
71
+ setup && typeof setup === 'object' && !Array.isArray(setup)
72
+ ? (setup as Record<string, unknown>).attachedWorkspaceId
73
+ : null
74
+ return typeof attached === 'string' && attached ? attached : null
75
+ }
76
+ const parent = dirname(dir)
77
+ if (parent === dir) return null
78
+ dir = parent
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolve the workspace id, preferring an explicit override, then the
84
+ * `ORIZU_WORKSPACE_ID` env var (set during `git clone` so the helper works
85
+ * before the working tree — and thus orizu.team.json — exists), then the cwd
86
+ * manifest walk for steady-state pull/push.
87
+ */
88
+ export function resolveWorkspaceId(io: GitCredentialIo): string | null {
89
+ if (io.workspaceId) return io.workspaceId
90
+ const fromEnv = process.env.ORIZU_WORKSPACE_ID?.trim()
91
+ if (fromEnv) return fromEnv
92
+ return resolveAttachedWorkspaceId(io.cwd)
93
+ }
94
+
95
+ async function mintToken(
96
+ fetcher: GitCredentialFetcher,
97
+ workspaceId: string,
98
+ purpose: 'read' | 'write'
99
+ ): Promise<{ ok: true; token: string } | { ok: false; status: number; error: string }> {
100
+ const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify({ purpose }),
104
+ })
105
+ if (response.ok) {
106
+ const data = (await response.json()) as { token?: string }
107
+ if (data.token) return { ok: true, token: data.token }
108
+ return { ok: false, status: response.status, error: 'Broker returned no token' }
109
+ }
110
+ let error = `status ${response.status}`
111
+ try {
112
+ const body = (await response.json()) as { error?: string }
113
+ if (body.error) error = body.error
114
+ } catch {
115
+ // Non-JSON body; keep the status-based message.
116
+ }
117
+ return { ok: false, status: response.status, error }
118
+ }
119
+
120
+ export async function runGitCredential(op: string, io: GitCredentialIo): Promise<number> {
121
+ // store/erase are no-ops: we never persist a credential to disk.
122
+ if (op !== 'get') return 0
123
+
124
+ const input = parseCredentialInput(io.stdin)
125
+
126
+ // Only broker for github.com; anything else is git's own business.
127
+ if (input.host && input.host !== GITHUB_HOST) return 0
128
+
129
+ const workspaceId = resolveWorkspaceId(io)
130
+ if (!workspaceId) {
131
+ // Unattached directory: stay silent so git falls through cleanly.
132
+ return 0
133
+ }
134
+
135
+ const fetcher = io.fetcher ?? authedFetch
136
+
137
+ // Mint write first (curators/pushers), fall back to read on 403 (members).
138
+ let minted = await mintToken(fetcher, workspaceId, 'write')
139
+ if (!minted.ok && minted.status === 403) {
140
+ minted = await mintToken(fetcher, workspaceId, 'read')
141
+ }
142
+
143
+ if (!minted.ok) {
144
+ io.printErr(
145
+ `orizu git-credential: could not broker a token for this workspace (${minted.error}). ` +
146
+ 'Ensure you are logged in (`orizu login`) and a team admin/curator has provisioned the repo.'
147
+ )
148
+ return 0
149
+ }
150
+
151
+ io.print('username=x-access-token')
152
+ io.print(`password=${minted.token}`)
153
+ io.print('')
154
+ return 0
155
+ }