eve-hub 0.6.67 → 0.6.69

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.6.69
4
+
5
+ ### Patch Changes
6
+
7
+ - Hosted hub: a feed update re-reads only the threads it touched and the model calls added since the last one (the cache gaps), and checks itself against a whole read now and then; before, every update read the whole workspace.
8
+
9
+ ## 0.6.68
10
+
11
+ ### Patch Changes
12
+
13
+ - A deployed bot calls the hub with its own project's OIDC token on every call (the tools, the mention gate, the post-commit delivery), with no HUB_TOKEN. It takes a call as the hub's when the call's `x-hub-oidc-token` is a token of this team's from the hub project (HUB_PROJECT_ID); a call without one (an older hub) still counts by BOT_TOKEN. Tokens are checked with jose. The hub accepts the bots' tokens beside the HMAC one and sends its own beside BOT_TOKEN, and gives each bots project HUB_PROJECT_ID.
14
+
3
15
  ## 0.6.67
4
16
 
5
17
  ### Patch Changes
@@ -1,10 +1,10 @@
1
1
  import { eveChannel } from "eve/channels/eve";
2
2
  import { localDev } from "eve/channels/auth";
3
- import { isBotToken } from "../bot-token.mjs";
3
+ import { getVercelOidcToken } from "@vercel/oidc";
4
+ import { isHubRequest } from "../hub-oidc.mjs";
4
5
  // The registry relays for the workspace's human and names them in
5
6
  // `x-roster-user`. The name is trusted as a user principal only from the hub:
6
- // a local-dev request, or one carrying the workspace's BOT_TOKEN (a deployed
7
- // bot, see bot-token.mjs). Resolving it as a user lets user-scoped
7
+ // a local-dev request, or one from the hub (a deployed bot, see hub-oidc.mjs). Resolving it as a user lets user-scoped
8
8
  // connections (open_pull_request's OAuth) key tokens to the configured human;
9
9
  // `localDev()` alone is not a user, so eve fails them with principal_required.
10
10
  const dev = localDev();
@@ -12,7 +12,7 @@ async function rosterUser(req) {
12
12
  const user = req.headers.get("x-roster-user");
13
13
  if (!user)
14
14
  return null;
15
- if (!(await dev(req)) && !isBotToken(req.headers.get("authorization")))
15
+ if (!(await dev(req)) && !(await isHubRequest(req, getVercelOidcToken)))
16
16
  return null;
17
17
  return { authenticator: "roster", principalId: `roster:${user}`, principalType: "user", attributes: {} };
18
18
  }
@@ -16,7 +16,7 @@ const credential = credentialCache(process.env.VERCEL ? deploymentToken : hubCre
16
16
  export default defineHook({
17
17
  events: {
18
18
  async "message.received"(event, ctx) {
19
- if (ctx.session.parent || !process.env.HUB_TOKEN)
19
+ if (ctx.session.parent || !(process.env.VERCEL || process.env.HUB_TOKEN))
20
20
  return; // subagents are never gated; nor is a bot with no hosted hub
21
21
  const { cancel } = await screenInbound({
22
22
  text: event.data.message,
package/hub-oidc.d.mts ADDED
@@ -0,0 +1,4 @@
1
+ export type TeamClaims = { iss: string; owner_id: string; project_id?: string; environment?: string; user_id?: string; exp: number; [claim: string]: unknown };
2
+ export function looksLikeJwt(token: string | null | undefined): boolean;
3
+ export function verifyTeamToken(token: string, own: string, options?: { fetchImpl?: typeof fetch; now?: number }): Promise<TeamClaims | undefined>;
4
+ export function isHubRequest(request: Request, own: () => Promise<string>, options?: { env?: Record<string, string | undefined>; fetchImpl?: typeof fetch; now?: number }): Promise<boolean>;
package/hub-oidc.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // Who a Vercel OIDC token says its caller is, for the hub and its bots to check each other with:
2
+ // a bot calls the hub with its bots project's token, the hub calls a bot with its own (`x-hub-oidc-token`).
3
+ // A token counts when the team's keys signed it (RS256, JWKS at `<iss>/.well-known/jwks.json`), it is within
4
+ // its `nbf`/`exp`, and it comes from the checker's own team: both `iss` and `owner_id` equal those of the checker's
5
+ // own token, so nothing is configured. The caller then checks which project (`project_id`) it may be.
6
+ import { createRemoteJWKSet, customFetch, decodeJwt, errors, jwtVerify } from "jose";
7
+ import { isBotToken } from "./bot-token.mjs";
8
+
9
+ // One key set per issuer (and per fetch): jose shares a pending fetch between concurrent checks, keeps the keys an hour, and
10
+ // refetches on an unknown `kid` no more than every 30 s, so a stream of made-up tokens is not a stream of fetches.
11
+ const keySets = new WeakMap();
12
+ // Not a bad token but the keys that could not be read (a timeout, a non-200 answer, an unreadable key set): an outage, so it throws.
13
+ const KEY_FETCH_FAILED = new Set([errors.JWKSTimeout.code, errors.JWKSInvalid.code, errors.JOSEError.code]);
14
+ function keysOf(iss, fetchImpl) {
15
+ if (!keySets.has(fetchImpl)) keySets.set(fetchImpl, new Map());
16
+ const sets = keySets.get(fetchImpl);
17
+ if (!sets.has(iss)) sets.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks.json`), { [customFetch]: fetchImpl, cooldownDuration: 30_000, cacheMaxAge: 60 * 60_000 }));
18
+ return sets.get(iss);
19
+ }
20
+
21
+ export const looksLikeJwt = (token) => /^[\w-]+\.[\w-]+\.[\w-]*$/.test(String(token ?? ""));
22
+
23
+ /**
24
+ * The verified claims of `token`, or undefined for any token that is not the checker's team's.
25
+ * `own` is the checker's own token (its `iss` and `owner_id` say which team to trust).
26
+ */
27
+ export async function verifyTeamToken(token, own, { fetchImpl = fetch, now = Date.now() } = {}) {
28
+ const { iss, owner_id: owner } = decodeJwt(own);
29
+ if (!iss || !owner) return undefined;
30
+ try {
31
+ const { payload } = await jwtVerify(token, keysOf(iss, fetchImpl), { issuer: iss, algorithms: ["RS256"], requiredClaims: ["exp"], currentDate: new Date(now) });
32
+ return payload.owner_id === owner ? payload : undefined;
33
+ } catch (error) {
34
+ if (error instanceof errors.JOSEError && !KEY_FETCH_FAILED.has(error.code)) return undefined;
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Whether `request` comes from the hub. A call carrying `x-hub-oidc-token` is decided by that token alone: it is of this team's,
41
+ * from the hub project (HUB_PROJECT_ID) and no development token (the hub's own function minted it). A call without one
42
+ * (a hub that does not send it yet) is the workspace's BOT_TOKEN. `own` is this deployment's own token.
43
+ */
44
+ export async function isHubRequest(request, own, { env = process.env, ...options } = {}) {
45
+ const token = request.headers.get("x-hub-oidc-token");
46
+ if (!token) return isBotToken(request.headers.get("authorization"), env.BOT_TOKEN);
47
+ if (!env.HUB_PROJECT_ID) return false;
48
+ let claims;
49
+ try { claims = await verifyTeamToken(token, await own(), options); }
50
+ catch (error) { console.error(`[eve-hub] could not check the hub's token: ${error?.message}`); return false; }
51
+ return claims !== undefined && claims.project_id === env.HUB_PROJECT_ID && (claims.environment === "production" || claims.environment === "preview");
52
+ }
package/mention-gate.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  // HUB_MENTION_GATE_REASONING, HUB_MENTION_GATE_TIMEOUT_MS and
22
22
  // HUB_MENTION_GATE_URL (tests).
23
23
  import { codeRanges } from "./mention-tokens.mjs";
24
- import { registryHeaders } from "./registry-fetch.mjs";
24
+ import { hubHeaders } from "./registry-fetch.mjs";
25
25
  import { createHash } from "node:crypto";
26
26
 
27
27
  export { codeRanges };
@@ -400,7 +400,7 @@ export function credentialCache(load, { ttlMs = 10 * 60e3, failMs = 60e3, now =
400
400
  const HUB_CALL_MS = 2000;
401
401
 
402
402
  async function hubCall(fetchImpl, url, body) {
403
- const res = await fetchImpl(url, { method: "POST", headers: registryHeaders({ "content-type": "application/json" }), body: JSON.stringify(body), signal: AbortSignal.timeout(HUB_CALL_MS) });
403
+ const res = await fetchImpl(url, { method: "POST", headers: await hubHeaders({ "content-type": "application/json" }), body: JSON.stringify(body), signal: AbortSignal.timeout(HUB_CALL_MS) });
404
404
  if (!res.ok) throw new Error(`hub ${res.status}`);
405
405
  return res.json();
406
406
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.6.67",
3
+ "version": "0.6.69",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "./execution-context": "./execution-context.mjs",
27
27
  "./connections/*": "./connections/*.js",
28
28
  "./bot-token": "./bot-token.mjs",
29
+ "./hub-oidc": "./hub-oidc.mjs",
29
30
  "./prepare": "./prepare.mjs",
30
31
  "./vercel": "./vercel.mjs",
31
32
  "./world": "./world.mjs"
@@ -37,6 +38,7 @@
37
38
  "@vercel/oidc": "4.0.0"
38
39
  },
39
40
  "dependencies": {
40
- "ai": "^7.0.137"
41
+ "ai": "^7.0.137",
42
+ "jose": "^6.2.3"
41
43
  }
42
44
  }
package/prepare.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  // has no workspace cwd);
16
16
  // - /hub/manifest: the roster and the commit sha the deployment was built
17
17
  // from, for the hub to sync from: a custom channel on main, routed in
18
- // vercel.mjs. Bearer BOT_TOKEN, as on each bot's eve channel (bot-token.mjs).
18
+ // vercel.mjs. From the hub, as on each bot's eve channel (hub-oidc.mjs).
19
19
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
20
20
  import { basename, dirname, join } from "node:path";
21
21
  import { DEPLOYED_KIT_FILES, KIT_FILES, MAIN_KIT_FILES } from "./stubs.mjs";
@@ -51,7 +51,8 @@ export default code({ worker: { model, reasoning } });
51
51
 
52
52
  const KIT_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
53
53
  const MANIFEST_CHANNEL = `import { defineChannel, GET } from "eve/channels";
54
- import { isBotToken } from "eve-hub/bot-token";
54
+ import { getVercelOidcToken } from "@vercel/oidc";
55
+ import { isHubRequest } from "eve-hub/hub-oidc";
55
56
  import { parseWorkspace } from "eve-hub/workspace";
56
57
  import workspace from "../../../../hub.manifest.json" with { type: "json" };
57
58
 
@@ -66,8 +67,8 @@ const manifest = { sha, kit: ${JSON.stringify(KIT_VERSION)}, agents, humans, sub
66
67
 
67
68
  export default defineChannel({
68
69
  routes: [
69
- GET("/hub/manifest", (request) => {
70
- if (!isBotToken(request.headers.get("authorization"))) return new Response("unauthorized", { status: 401 });
70
+ GET("/hub/manifest", async (request) => {
71
+ if (!(await isHubRequest(request, getVercelOidcToken))) return new Response("unauthorized", { status: 401 });
71
72
  if (!sha) return new Response("no commit sha: this deployment wasn't built from git", { status: 500 });
72
73
  return Response.json(manifest);
73
74
  }),
@@ -31,10 +31,10 @@ export const sandboxRegistry = () => (process.env.HUB_SANDBOX_SERVICES === "none
31
31
 
32
32
  // Also used by the sandbox's node:http transport, which distinguishes
33
33
  // requests that never connected from ones whose result may have been lost.
34
- export function registryHeaders(init) {
34
+ export function registryHeaders(init, env = process.env) {
35
35
  const headers = new Headers(init);
36
- if (process.env.HUB_TOKEN && !headers.has("authorization")) headers.set("authorization", `Bearer ${process.env.HUB_TOKEN}`);
37
- if (process.env.HUB_PROTECTION_BYPASS) headers.set("x-vercel-protection-bypass", process.env.HUB_PROTECTION_BYPASS);
36
+ if (env.HUB_TOKEN && !headers.has("authorization")) headers.set("authorization", `Bearer ${env.HUB_TOKEN}`);
37
+ if (env.HUB_PROTECTION_BYPASS) headers.set("x-vercel-protection-bypass", env.HUB_PROTECTION_BYPASS);
38
38
  return Object.fromEntries(headers);
39
39
  }
40
40
 
@@ -57,8 +57,19 @@ export function connectionErrorCode(error) {
57
57
 
58
58
  const DROPPED = new Set(["ECONNRESET", "EPIPE", "UND_ERR_SOCKET"]);
59
59
 
60
+ // Every call a bot makes to the hub: a deployed bot is the caller its own project's token says (the hub checks it: api/auth.ts);
61
+ // elsewhere HUB_TOKEN is the credential, if any. The one place a call's headers are made.
62
+ export async function hubHeaders(init, env = process.env) {
63
+ const merged = new Headers(init);
64
+ if (env.VERCEL && !merged.has("authorization")) {
65
+ const { getVercelOidcToken } = await import("@vercel/oidc");
66
+ merged.set("authorization", `Bearer ${await getVercelOidcToken()}`);
67
+ }
68
+ return registryHeaders(merged, env);
69
+ }
70
+
60
71
  export async function registryFetch(path, init = {}, { base = registryBase(), deadlineMs = num(process.env.ROSTER_REGISTRY_RETRY_MS, 30_000), attemptTimeoutMs, fetchImpl = fetch } = {}) {
61
- init = { ...init, headers: registryHeaders(init.headers) };
72
+ init = { ...init, headers: await hubHeaders(init.headers) };
62
73
  const input = `${base}${path}`;
63
74
  const method = String(init.method ?? "GET").toUpperCase();
64
75
  const deadline = Date.now() + deadlineMs;