@zocomputer/agent-sdk 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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
@@ -0,0 +1,76 @@
1
+ import { createGateway } from "ai";
2
+ import { eveSessionFetch } from "./session-fetch";
3
+
4
+ // Names the built-in tool behind a proxied gateway call, so the metering capture can
5
+ // label the resulting usage event (e.g. an image-model generation as `generate_image`)
6
+ // instead of seeing anonymous traffic. Sent by the tool wrappers on their own gateway
7
+ // calls; the api proxy strips it before forwarding upstream and stashes it in
8
+ // UsageEvent.metadata. Only tools that make their OWN gateway call need it — a
9
+ // server-side tool like web_search rides the model turn and is already named in that
10
+ // turn's `gatewayToolCalls`.
11
+ export const ZO_TOOL_HEADER = "x-zo-tool";
12
+
13
+ export const DEFAULT_ZO_AI_BASE_URL = "http://localhost:4000/runtime/ai/v4/ai";
14
+ export const DEFAULT_ZO_AI_KEY = "dev-proxy";
15
+
16
+ // The agent-token header + env var — deliberate duplicates of
17
+ // @zocomputer/runtime-auth's constants (this package is vendored self-contained;
18
+ // same rationale as ZO_TOOL_HEADER above and session-fetch's EVE_SESSION_HEADER).
19
+ // Whoever launches the runtime mints the token and injects ZO_AGENT_TOKEN (the
20
+ // dev launchers; deploys bake it into the project env); attaching it here is
21
+ // what makes every zoGateway call attributed — the api proxy rejects anonymous
22
+ // calls once RUNTIME_AUTH_SECRET is configured.
23
+ const AGENT_TOKEN_HEADER = "x-zo-agent-token";
24
+ const AGENT_TOKEN_ENV = "ZO_AGENT_TOKEN";
25
+
26
+ /**
27
+ * The runtime's agent-token header, from its launcher-injected env — `{}` when
28
+ * none was minted (secretless dev). Exported for tests.
29
+ */
30
+ export function agentAuthHeaders(
31
+ token: string | undefined = process.env[AGENT_TOKEN_ENV],
32
+ ): Record<string, string> {
33
+ const trimmed = token?.trim();
34
+ return trimmed ? { [AGENT_TOKEN_HEADER]: trimmed } : {};
35
+ }
36
+
37
+ type GatewaySettings = NonNullable<Parameters<typeof createGateway>[0]>;
38
+
39
+ export interface ZoGatewayOptions
40
+ extends Omit<GatewaySettings, "apiKey" | "baseURL"> {
41
+ readonly apiKey?: string | null;
42
+ readonly baseURL?: string | null;
43
+ }
44
+
45
+ export function resolveZoGatewayBaseUrl(
46
+ baseURL: string | null | undefined = process.env.ZO_AI_BASE_URL,
47
+ ): string {
48
+ const trimmed = baseURL?.trim();
49
+ return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ZO_AI_BASE_URL;
50
+ }
51
+
52
+ export function resolveZoGatewayApiKey(
53
+ apiKey: string | null | undefined = process.env.ZO_AI_KEY,
54
+ ): string {
55
+ const trimmed = apiKey?.trim();
56
+ return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ZO_AI_KEY;
57
+ }
58
+
59
+ // Explicit return type: the inferred one names @ai-sdk/gateway's GatewayProvider
60
+ // through a store-internal path, which the d.ts build rejects as non-portable.
61
+ export function zoGateway(
62
+ options: ZoGatewayOptions = {},
63
+ ): ReturnType<typeof createGateway> {
64
+ return createGateway({
65
+ ...options,
66
+ // The runtime's own agent token rides every call (attribution); explicit
67
+ // caller headers win on collision.
68
+ headers: { ...agentAuthHeaders(), ...options.headers },
69
+ apiKey: resolveZoGatewayApiKey(options.apiKey),
70
+ baseURL: resolveZoGatewayBaseUrl(options.baseURL),
71
+ // Stamp the ambient eve session id on every call (see session-fetch.ts) — the
72
+ // join key apps/api uses to recover the session's owner when persisting usage.
73
+ // A caller-supplied fetch still gets wrapped, so custom fetches keep the stamp.
74
+ fetch: eveSessionFetch(undefined, options.fetch),
75
+ });
76
+ }
@@ -0,0 +1,20 @@
1
+ export {
2
+ DEFAULT_ZO_AI_BASE_URL,
3
+ DEFAULT_ZO_AI_KEY,
4
+ ZO_TOOL_HEADER,
5
+ resolveZoGatewayApiKey,
6
+ resolveZoGatewayBaseUrl,
7
+ zoGateway,
8
+ } from "./gateway";
9
+ export type { ZoGatewayOptions } from "./gateway";
10
+ export {
11
+ EVE_SESSION_HEADER,
12
+ EVE_TURN_HEADER,
13
+ ambientEveSessionId,
14
+ ambientEveTurnId,
15
+ eveSessionFetch,
16
+ } from "./session-fetch";
17
+
18
+ // `./register` is deliberately NOT re-exported here: it's a side-effect module
19
+ // (import it for what it does, not for a value), and a barrel re-export would
20
+ // run the registration on any value import from this package.
@@ -0,0 +1,26 @@
1
+ import { zoGateway } from "./gateway";
2
+
3
+ // Register the Zo gateway as the AI SDK's default provider
4
+ // (`globalThis.AI_SDK_DEFAULT_PROVIDER`, read as `?? gateway` by
5
+ // `resolveLanguageModel` in `ai`). With the slot pointed at our /runtime/ai
6
+ // proxy, agent.ts can use a bare catalog slug — eve classifies a string model
7
+ // as gateway-routed, so provider-executed built-ins like web_search stay
8
+ // attached and the context window auto-resolves. A wrapped gateway *instance*
9
+ // instead gets web_search silently deleted (eve can't map its "gateway/…" id
10
+ // to a search backend). Import once, first, from agent.ts; never set the slot
11
+ // anywhere else. A missed override fails loud, not leaky: agents hold no real
12
+ // gateway key, so calls 401 at the real gateway instead of bypassing metering.
13
+ // See plans/ray/eve-web-search-under-gateway-models.md (Option B).
14
+
15
+ const SLOT = "AI_SDK_DEFAULT_PROVIDER";
16
+
17
+ if (!(SLOT in globalThis)) {
18
+ Object.defineProperty(globalThis, SLOT, {
19
+ value: zoGateway(),
20
+ // Locked down so a later accidental assignment throws instead of silently
21
+ // rerouting model traffic.
22
+ writable: false,
23
+ configurable: false,
24
+ enumerable: false,
25
+ });
26
+ }
@@ -0,0 +1,124 @@
1
+ // Stamps the ambient eve session id onto each proxied gateway call, so apps/api can
2
+ // join the metered usage back to the session's owner (`eveSessionId` →
3
+ // `Conversation`/`BuilderConversation`; see plans/ray/usage-event-persistence.md).
4
+ //
5
+ // Why a fetch wrapper: `createGateway`'s `headers` is a static record resolved once at
6
+ // construction, but the session id changes per call — the custom `fetch` option is the
7
+ // per-request seam. The fetch runs inside eve's AsyncLocalStorage scope during a model
8
+ // call ("model callbacks observe the unified context" — eve's own contract), so the
9
+ // ambient read is race-free under concurrent sessions, unlike capturing the id into
10
+ // module state from a hook.
11
+ //
12
+ // Why no `eve` import: this package is vendored source-only into the agent working
13
+ // copy, where `ai` + Node built-ins are the only guaranteed deps. eve 0.16 doesn't
14
+ // export `getContext`/`SessionIdKey` publicly anyway — but it deliberately publishes
15
+ // its context storage process-wide (`Symbol.for("eve.context-storage")`, "used by
16
+ // every eve module copy") exactly so out-of-tree code can share it. We read that slot
17
+ // with runtime checks; `session-fetch.test.ts` locks the contract against the
18
+ // installed eve so an upgrade that moves it fails loudly in `test:packages`.
19
+
20
+ /**
21
+ * Mirrors `@zocomputer/runtime-auth`'s `EVE_SESSION_HEADER` — a deliberate duplicate
22
+ * for the same reason `ZO_TOOL_HEADER` is (see `gateway.ts`): this package can't
23
+ * import workspace siblings. apps/api reads it into the gateway attribution and strips
24
+ * it before forwarding upstream.
25
+ */
26
+ export const EVE_SESSION_HEADER = "x-zo-eve-session";
27
+
28
+ /**
29
+ * Mirrors `@zocomputer/runtime-auth`'s `EVE_TURN_HEADER` (same duplication rule).
30
+ * Descriptive metering detail — it lands in `UsageEvent.metadata.turnId` and pins a
31
+ * Zo-paid tool's own gateway call (invisible to eve's step machinery) to its turn.
32
+ */
33
+ export const EVE_TURN_HEADER = "x-zo-eve-turn";
34
+
35
+ /** The process-wide slot eve publishes its context `AsyncLocalStorage` under. */
36
+ const EVE_CONTEXT_STORAGE_KEY = Symbol.for("eve.context-storage");
37
+
38
+ /** `SessionIdKey.name` in eve — a durable serialized key, so it can't drift casually. */
39
+ const SESSION_ID_KEY_NAME = "eve.sessionId";
40
+
41
+ /** `SessionKey.name` in eve — the durable session object carrying `turn.id`. */
42
+ const SESSION_KEY_NAME = "eve.session";
43
+
44
+ /** Narrow structural check: a non-null object carrying a function under `name`. */
45
+ function hasMethod<K extends string>(
46
+ value: unknown,
47
+ name: K,
48
+ ): value is Record<K, (...args: unknown[]) => unknown> {
49
+ return (
50
+ typeof value === "object" &&
51
+ value !== null &&
52
+ typeof (value as Record<string, unknown>)[name] === "function"
53
+ );
54
+ }
55
+
56
+ /**
57
+ * The eve session id of the call currently in scope, or `undefined` when there isn't
58
+ * one. Never throws: a fetch outside any session (e.g. the gateway provider's
59
+ * background model-metadata refresh) or a runtime without eve simply reads no slot.
60
+ */
61
+ export function ambientEveSessionId(): string | undefined {
62
+ const value = ambientContextValue(SESSION_ID_KEY_NAME);
63
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
64
+ }
65
+
66
+ /**
67
+ * The eve turn id of the call currently in scope, or `undefined`. Read from the
68
+ * durable session object (`SessionKey` → `{ sessionId, turn: { id } }`) with the
69
+ * same runtime checks as the session read — never throws, never trusts shape.
70
+ */
71
+ export function ambientEveTurnId(): string | undefined {
72
+ const session = ambientContextValue(SESSION_KEY_NAME);
73
+ if (typeof session !== "object" || session === null) return undefined;
74
+ const turn = (session as Record<string, unknown>)["turn"];
75
+ if (typeof turn !== "object" || turn === null) return undefined;
76
+ const id = (turn as Record<string, unknown>)["id"];
77
+ return typeof id === "string" && id.trim().length > 0 ? id : undefined;
78
+ }
79
+
80
+ /** One guarded read of eve's process-wide context slot by key name. */
81
+ function ambientContextValue(keyName: string): unknown {
82
+ const storage: unknown = Reflect.get(globalThis, EVE_CONTEXT_STORAGE_KEY);
83
+ if (!hasMethod(storage, "getStore")) return undefined;
84
+ const store = storage.getStore();
85
+ // The store is eve's context container; `get` reads a key by its `.name`.
86
+ if (!hasMethod(store, "get")) return undefined;
87
+ return store.get({ name: keyName });
88
+ }
89
+
90
+ /**
91
+ * Wrap a fetch so every request carries the current eve session id, when there is one.
92
+ * No session in scope (or a blank id) → the request passes through untouched.
93
+ *
94
+ * `Object.assign` onto the wrapper keeps the base fetch's extra surface (Bun types a
95
+ * `preconnect` on `fetch`), so the result satisfies `typeof globalThis.fetch`.
96
+ */
97
+ type FetchInput = Parameters<typeof globalThis.fetch>[0];
98
+ type FetchInit = Parameters<typeof globalThis.fetch>[1];
99
+
100
+ export function eveSessionFetch(
101
+ getSessionId: () => string | undefined = ambientEveSessionId,
102
+ baseFetch: typeof globalThis.fetch = globalThis.fetch,
103
+ getTurnId: () => string | undefined = ambientEveTurnId,
104
+ ): typeof globalThis.fetch {
105
+ return Object.assign((input: FetchInput, init?: FetchInit) => {
106
+ const sessionId = getSessionId()?.trim();
107
+ if (!sessionId) return baseFetch(input, init);
108
+ // Merge order matches fetch semantics: `init.headers` (when present) replaces a
109
+ // Request input's headers wholesale, so start from whichever would win.
110
+ const headers = new Headers(
111
+ init?.headers ?? (input instanceof Request ? input.headers : undefined),
112
+ );
113
+ headers.set(EVE_SESSION_HEADER, sessionId);
114
+ // The turn only rides with a session — a turn id with no session id would be
115
+ // unjoinable noise, so the whole stamp is gated on the session being present.
116
+ // Like the session header, the ambient value is AUTHORITATIVE either way: a
117
+ // stale pre-existing turn header is overwritten when a turn is in scope and
118
+ // removed when one isn't, so it can never mislabel a row's metadata.turnId.
119
+ const turnId = getTurnId()?.trim();
120
+ if (turnId) headers.set(EVE_TURN_HEADER, turnId);
121
+ else headers.delete(EVE_TURN_HEADER);
122
+ return baseFetch(input, { ...init, headers });
123
+ }, baseFetch);
124
+ }
@@ -0,0 +1,331 @@
1
+ import { SignJWT, jwtVerify, type JWTPayload } from "jose";
2
+
3
+ // @zocomputer/runtime-auth — the agent-token contract shared by the minter and the verifier.
4
+ //
5
+ // `apps/api` mints the agent token (and verifies it), the launcher that starts an
6
+ // agent process mints one too (dev), and `@zocomputer/agent-sandbox` carries it on the
7
+ // header. Putting the format in one package keeps those in lockstep — the claims,
8
+ // the header names, and the reserved Builder identity all have a single source of
9
+ // truth, rather than `apps/api` owning the format and everyone else re-deriving it.
10
+ //
11
+ // The crypto is pure and injectable (secret + clock passed in) so it unit-tests
12
+ // without env. `apps/api` is the sole verifier; a runtime carries the token but
13
+ // never holds the signing secret. See plans/rc2/runtime-auth-context.md.
14
+
15
+ // ── headers ──────────────────────────────────────────────────────────────────
16
+
17
+ /** The agent token, kept off `Authorization` so it never shadows a WorkOS session. */
18
+ export const AGENT_TOKEN_HEADER = "x-zo-agent-token";
19
+
20
+ /** The eve session the agent's call is for — carried onto the context as `eveSessionId`. */
21
+ export const EVE_SESSION_HEADER = "x-zo-eve-session";
22
+
23
+ /**
24
+ * The eve turn the agent's call belongs to. Descriptive metering detail (it lands
25
+ * in `UsageEvent.metadata.turnId`, never attribution/ownership): a model step's
26
+ * usage row is turn-linkable via its generation id against eve's stored steps,
27
+ * but a Zo-paid tool's own gateway call is invisible to eve's step machinery —
28
+ * this header is what pins a `tool_use` row to its exact turn.
29
+ */
30
+ export const EVE_TURN_HEADER = "x-zo-eve-turn";
31
+
32
+ /** Env var a runtime reads its agent token from (injected by its launcher). */
33
+ export const AGENT_TOKEN_ENV = "ZO_AGENT_TOKEN";
34
+
35
+ // ── actor + context ──────────────────────────────────────────────────────────
36
+
37
+ /** A running eve agent acting as itself. */
38
+ export interface AgentActor {
39
+ readonly kind: "agent";
40
+ /** Which agent (the durable product object). */
41
+ readonly agentProjectId: string;
42
+ /** The org that owns the agent — always present. */
43
+ readonly ownerOrgId: string;
44
+ /** The running deployment, once hosted deploys mint one. */
45
+ readonly deploymentId?: string;
46
+ }
47
+
48
+ /** A human calling the API directly (browser/native), no agent in between. */
49
+ export interface UserActor {
50
+ readonly kind: "user";
51
+ readonly userId: string;
52
+ readonly orgId: string;
53
+ }
54
+
55
+ /** The resolved identity for a request. */
56
+ export interface RuntimeAuthContext {
57
+ readonly actor: AgentActor | UserActor;
58
+ /**
59
+ * The eve session this call is acting for, when an agent actor reports one — the
60
+ * join key from which a route resolves the `Conversation` (and through it the
61
+ * user, org, installation). Carried per request on `EVE_SESSION_HEADER`, not a
62
+ * token claim and not verified (self-asserted, scoped by the actor's org, the same
63
+ * trust the sandbox route gives its eve session key). Absent on a direct user call.
64
+ */
65
+ readonly eveSessionId?: string;
66
+ }
67
+
68
+ // ── reserved identities ──────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * The reserved Zo platform org. Zo's own first-party usage (the Builder, internal
72
+ * jobs) is scoped to it. A real `Org` row, seeded at DB setup, so anything written
73
+ * against it (a `SandboxResource`, later usage rows) satisfies the foreign key. Its
74
+ * id is a fixed, well-known string (not a minted TypeID) so it can be referenced as
75
+ * a constant from both the minter and the seed.
76
+ */
77
+ export const ZO_PLATFORM_ORG = {
78
+ id: "org_zo",
79
+ name: "Zo",
80
+ slug: "zo",
81
+ } as const;
82
+
83
+ /**
84
+ * The Builder's hardcoded agent identity. The Builder is Zo's own first-party agent
85
+ * (it edits other agents) — not a customer-built one — so it has no `AgentProject`
86
+ * row and a fixed identity instead: a well-known project id and the platform org as
87
+ * owner. `agentProjectId` is never persisted by the sandbox path (only `ownerOrgId`
88
+ * is, against the FK), so it needs no seeded row of its own.
89
+ */
90
+ export const BUILDER_AGENT_IDENTITY: AgentTokenClaims = {
91
+ agentProjectId: "agt_builder",
92
+ ownerOrgId: ZO_PLATFORM_ORG.id,
93
+ };
94
+
95
+ /**
96
+ * The dev BUILT agent's hardcoded identity (the `apps/local-agent` working copy
97
+ * on :2000, launched by `packages/fixtures`). Like the Builder it's a first-party
98
+ * dev process with no `AgentProject` row — a fixed identity scoped to the platform
99
+ * org, so its gateway calls are attributed (and metered) instead of anonymous.
100
+ * Which USER's session a call belongs to still comes from the session join, never
101
+ * this token.
102
+ */
103
+ export const LOCAL_AGENT_IDENTITY: AgentTokenClaims = {
104
+ agentProjectId: "agt_local",
105
+ ownerOrgId: ZO_PLATFORM_ORG.id,
106
+ };
107
+
108
+ /**
109
+ * Reserved first-party project ids that deliberately have NO `AgentProject` row
110
+ * (#81: no fake product rows) — consumers must never write them into an
111
+ * AgentProject foreign key. One list so a new reserved identity can't be missed
112
+ * at one of the call sites.
113
+ */
114
+ export const RESERVED_AGENT_PROJECT_IDS: readonly string[] = [
115
+ BUILDER_AGENT_IDENTITY.agentProjectId,
116
+ LOCAL_AGENT_IDENTITY.agentProjectId,
117
+ ];
118
+
119
+ // ── token wire shapes ────────────────────────────────────────────────────────
120
+
121
+ const ISSUER = "zo-api";
122
+ const AGENT_TOKEN_TYP = "zo-agent";
123
+
124
+ /** What the agent token asserts about the actor. */
125
+ export interface AgentTokenClaims {
126
+ readonly agentProjectId: string;
127
+ readonly ownerOrgId: string;
128
+ readonly deploymentId?: string;
129
+ }
130
+
131
+ /** Seconds-since-epoch clock, injected so tests are deterministic. */
132
+ export type Clock = () => number;
133
+
134
+ const defaultClock: Clock = () => Math.floor(Date.now() / 1000);
135
+
136
+ /** Encode the HMAC secret string for jose's Web Crypto key input. */
137
+ function key(secret: string): Uint8Array {
138
+ return new TextEncoder().encode(secret);
139
+ }
140
+
141
+ function asString(v: unknown): string | undefined {
142
+ return typeof v === "string" && v.length > 0 ? v : undefined;
143
+ }
144
+
145
+ // ── mint ─────────────────────────────────────────────────────────────────────
146
+
147
+ export interface MintAgentTokenInput {
148
+ readonly claims: AgentTokenClaims;
149
+ readonly secret: string;
150
+ /** Token lifetime in seconds. */
151
+ readonly ttlSeconds: number;
152
+ readonly clock?: Clock;
153
+ }
154
+
155
+ /** Mint the agent token (the actor credential injected into a runtime's env). */
156
+ export async function mintAgentToken(input: MintAgentTokenInput): Promise<string> {
157
+ const now = (input.clock ?? defaultClock)();
158
+ const payload: JWTPayload = {
159
+ typ: AGENT_TOKEN_TYP,
160
+ agentProjectId: input.claims.agentProjectId,
161
+ ownerOrgId: input.claims.ownerOrgId,
162
+ ...(input.claims.deploymentId ? { deploymentId: input.claims.deploymentId } : {}),
163
+ };
164
+ return new SignJWT(payload)
165
+ .setProtectedHeader({ alg: "HS256" })
166
+ .setIssuer(ISSUER)
167
+ .setIssuedAt(now)
168
+ .setExpirationTime(now + input.ttlSeconds)
169
+ .sign(key(input.secret));
170
+ }
171
+
172
+ // ── verify ───────────────────────────────────────────────────────────────────
173
+
174
+ /** Verify the agent token's signature, issuer, expiry, and `typ`. Returns the claims,
175
+ * or `null` when the token is invalid/expired/wrong-typ. */
176
+ export async function verifyAgentToken(
177
+ token: string,
178
+ secret: string,
179
+ clock: Clock = defaultClock,
180
+ ): Promise<AgentTokenClaims | null> {
181
+ try {
182
+ const { payload } = await jwtVerify(token, key(secret), {
183
+ issuer: ISSUER,
184
+ currentDate: new Date(clock() * 1000),
185
+ });
186
+ if (payload.typ !== AGENT_TOKEN_TYP) return null;
187
+ const agentProjectId = asString(payload.agentProjectId);
188
+ const ownerOrgId = asString(payload.ownerOrgId);
189
+ if (!agentProjectId || !ownerOrgId) return null;
190
+ const deploymentId = asString(payload.deploymentId);
191
+ return { agentProjectId, ownerOrgId, ...(deploymentId ? { deploymentId } : {}) };
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Resolve a verified agent token into a `RuntimeAuthContext`, or `null` when the
199
+ * token is invalid. `eveSessionId` is the session the runtime reports for this call
200
+ * (per request — see the field doc); it's carried onto the context so consumers can
201
+ * join to the `Conversation`. It's not part of the token and is not verified here.
202
+ */
203
+ export async function resolveAgentContext(
204
+ agentToken: string,
205
+ secret: string,
206
+ eveSessionId: string | undefined,
207
+ clock: Clock = defaultClock,
208
+ ): Promise<RuntimeAuthContext | null> {
209
+ const claims = await verifyAgentToken(agentToken, secret, clock);
210
+ if (!claims) return null;
211
+ return {
212
+ actor: {
213
+ kind: "agent",
214
+ agentProjectId: claims.agentProjectId,
215
+ ownerOrgId: claims.ownerOrgId,
216
+ ...(claims.deploymentId ? { deploymentId: claims.deploymentId } : {}),
217
+ },
218
+ ...(eveSessionId ? { eveSessionId } : {}),
219
+ };
220
+ }
221
+
222
+ // ── identity bearer ──────────────────────────────────────────────────────────
223
+ //
224
+ // The second first-party token (credential A): a short-lived signed identity the
225
+ // browser carries on the Builder's eve traffic (`@zocomputer/auth-core`'s
226
+ // IDENTITY_BEARER_HEADER) asserting "this user is building this agent". Needed
227
+ // because eve's client fetch sends no credentials, so the builder eve-proxy can't
228
+ // see the WorkOS session cookie — the browser trades its cookie for this bearer
229
+ // over an authenticated `POST /agents/:id/builder/identity-token`, and the proxy
230
+ // verifies it offline, then injects the resolved identity as the plaintext
231
+ // `x-zo-initiator` header (credential B, below). apps/api both mints and verifies
232
+ // (same HMAC secret as the agent token); web only ferries the opaque string. See
233
+ // plans/sachin/builder-initiator-binding-implementation.md.
234
+
235
+ const IDENTITY_BEARER_TYP = "zo-identity";
236
+
237
+ /** What the identity bearer asserts: which user is building which agent. */
238
+ export interface IdentityClaims {
239
+ readonly userId: string;
240
+ readonly agentId: string;
241
+ }
242
+
243
+ export interface MintIdentityBearerInput {
244
+ readonly claims: IdentityClaims;
245
+ readonly secret: string;
246
+ /** Bearer lifetime in seconds. */
247
+ readonly ttlSeconds: number;
248
+ readonly clock?: Clock;
249
+ }
250
+
251
+ /** Mint the identity bearer (the browser-side identity credential). */
252
+ export async function mintIdentityBearer(
253
+ input: MintIdentityBearerInput,
254
+ ): Promise<string> {
255
+ const now = (input.clock ?? defaultClock)();
256
+ const payload: JWTPayload = {
257
+ typ: IDENTITY_BEARER_TYP,
258
+ userId: input.claims.userId,
259
+ agentId: input.claims.agentId,
260
+ };
261
+ return new SignJWT(payload)
262
+ .setProtectedHeader({ alg: "HS256" })
263
+ .setIssuer(ISSUER)
264
+ .setIssuedAt(now)
265
+ .setExpirationTime(now + input.ttlSeconds)
266
+ .sign(key(input.secret));
267
+ }
268
+
269
+ /** Verify an identity bearer's signature, issuer, expiry, and `typ`. Returns the
270
+ * claims, or `null` when it's invalid/expired/wrong-typ (incl. an agent token
271
+ * presented where an identity bearer belongs — `typ` keeps them unconfusable). */
272
+ export async function verifyIdentityBearer(
273
+ bearer: string,
274
+ secret: string,
275
+ clock: Clock = defaultClock,
276
+ ): Promise<IdentityClaims | null> {
277
+ try {
278
+ const { payload } = await jwtVerify(bearer, key(secret), {
279
+ issuer: ISSUER,
280
+ currentDate: new Date(clock() * 1000),
281
+ });
282
+ if (payload.typ !== IDENTITY_BEARER_TYP) return null;
283
+ const userId = asString(payload.userId);
284
+ const agentId = asString(payload.agentId);
285
+ if (!userId || !agentId) return null;
286
+ return { userId, agentId };
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
292
+ // ── initiator header (proxy → Builder) ─────────────────────────────────────────
293
+ //
294
+ // Credential B: the plaintext identity the builder eve-proxy injects on the
295
+ // forwarded request after verifying the identity bearer (credential A). The
296
+ // Builder's channel auth reads it and stamps `session.auth.initiator`. Not signed
297
+ // — the Builder holds no secret and trusts it because only the edge-authenticated
298
+ // proxy can reach it (Deployment Protection). The proxy always strips any
299
+ // client-supplied value before setting its own. One JSON header so the identity
300
+ // is atomic: `parseInitiator` returns the whole `{ userId, agentId }` or `null`,
301
+ // never a half-present pair.
302
+
303
+ /** The proxy → Builder initiator header. */
304
+ export const INITIATOR_HEADER = "x-zo-initiator";
305
+
306
+ /** The initiator identity carried on `INITIATOR_HEADER`. */
307
+ export interface InitiatorIdentity {
308
+ readonly userId: string;
309
+ readonly agentId: string;
310
+ }
311
+
312
+ /** Serialize the initiator identity for `INITIATOR_HEADER`. */
313
+ export function formatInitiator(identity: InitiatorIdentity): string {
314
+ return JSON.stringify({ userId: identity.userId, agentId: identity.agentId });
315
+ }
316
+
317
+ /** Parse-then-narrow the `INITIATOR_HEADER` value; `null` on absent/malformed. */
318
+ export function parseInitiator(value: string | null | undefined): InitiatorIdentity | null {
319
+ if (!value) return null;
320
+ let parsed: unknown;
321
+ try {
322
+ parsed = JSON.parse(value);
323
+ } catch {
324
+ return null;
325
+ }
326
+ if (typeof parsed !== "object" || parsed === null) return null;
327
+ const { userId, agentId } = parsed as Record<string, unknown>;
328
+ if (typeof userId !== "string" || !userId) return null;
329
+ if (typeof agentId !== "string" || !agentId) return null;
330
+ return { userId, agentId };
331
+ }