@keemakr/agent-sdk 0.7.0 → 0.8.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 CHANGED
@@ -159,6 +159,22 @@ await kee.tools.run('current-time'); // run one in keemakr-core
159
159
 
160
160
  A call whose grant lacks the required scope returns a `KeeError` with `status: 403`; an expired/invalid grant returns `status: 401`.
161
161
 
162
+ ## Grant refresh (automatic since 0.8.0)
163
+
164
+ Every `useKee` capability call keeps its grant alive by itself: when the active token has under two minutes left, the SDK exchanges it at core's `POST /api/capability/grant/refresh` (single-flight per delegation — concurrent tool calls share one refresh), and a `401 grant_expired` gets one refresh + one retry before surfacing. You write nothing; long runs simply stop dying at the TTL.
165
+
166
+ The floor is core's, not the SDK's: an **expired** grant can never be refreshed, scopes are re-derived from the install at each exchange, and the whole chain dies at the renewal horizon (`CAPABILITY_GRANT_MAX_LIFETIME_SECONDS` on core, default 6h) with a relayable `grant_horizon_exceeded` error.
167
+
168
+ Headless callers holding a raw grant can drive the exchange directly:
169
+
170
+ ```ts
171
+ import { refreshGrant } from '@keemakr/agent-sdk';
172
+
173
+ const { token, exp } = await refreshGrant(currentToken); // throws KeeError when core refuses
174
+ ```
175
+
176
+ `refreshGrant` resolves core from `KEE_CORE_URL` / `KEE_CORE_JWKS_URL` (pass `{ coreUrl }` to override) and never verifies or signs anything locally — core is the only judge.
177
+
162
178
  ## Autonomous / scheduled runs
163
179
 
164
180
  A cron/scheduled turn has no operator session, so it gets no session grant. keemakr-core can mint a **machine grant** for it (gated on the tenant's per-install `unattended_consent`). If your remote runs **outside** an eve channel, verify that grant directly:
package/dist/client.js CHANGED
@@ -4,6 +4,12 @@
4
4
  // /api/capability/* endpoints, forwarding the grant. Core re-verifies the grant
5
5
  // and enforces scope on every call; the SDK never sees a raw credential on the
6
6
  // proxy path.
7
+ //
8
+ // Since 0.8.0 every capability call keeps its grant ALIVE transparently: the
9
+ // token is refreshed shortly before expiry (see refresh.ts), and a 401
10
+ // `grant_expired` gets one refresh + one retry before surfacing. Long runs stop
11
+ // dying mid-flight; the ceiling is core's renewal horizon (default 6h).
12
+ import { ensureFreshToken, tokenForRetry, coreBaseUrl } from './refresh.js';
7
13
  function keeError(message, status, body) {
8
14
  const e = new Error(message);
9
15
  e.name = 'KeeError';
@@ -36,32 +42,69 @@ function readGrant(ctx) {
36
42
  }
37
43
  return { token, tenantId, scopes, traceId };
38
44
  }
39
- // Resolve core's base URL. Prefer KEE_CORE_URL; otherwise derive it from the
40
- // JWKS URL by stripping the well-known path.
41
- function coreBaseUrl() {
42
- const explicit = process.env.KEE_CORE_URL;
43
- if (explicit)
44
- return explicit.replace(/\/$/, '');
45
- const jwks = process.env.KEE_CORE_JWKS_URL;
46
- if (jwks)
47
- return jwks.replace(/\/\.well-known\/jwks\.json\/?$/, '');
48
- throw keeError('KEE_CORE_URL (or KEE_CORE_JWKS_URL) must be set to reach the Capability API');
45
+ // A 401 from core surfaces INSIDE the remote agent's run as a failed tool, and
46
+ // the only audience there is the remote's model — so the message must carry
47
+ // remediation the model can relay verbatim instead of improvising around it.
48
+ // Branches on core's typed `code` when present; the /expired/ text test remains
49
+ // the fallback for older cores' undifferentiated 'invalid or expired grant'.
50
+ function with401Guidance(base, code) {
51
+ if (code === 'grant_horizon_exceeded') {
52
+ return (`${base} — the delegation ran past the platform's renewal horizon ` +
53
+ `(CAPABILITY_GRANT_MAX_LIFETIME_SECONDS, default 6h). This is not an ` +
54
+ `agent-fixable error: split the work into shorter runs, or the platform ` +
55
+ `operator can raise the horizon deliberately.`);
56
+ }
57
+ if (code === 'grant_expired' || (!code && /expired/i.test(base))) {
58
+ return (`${base} — the capability grant expired mid-run and could not be refreshed ` +
59
+ `(the SDK refreshes automatically before expiry). This is not an agent-fixable ` +
60
+ `error: tell the operator to retry the delegation once; if it recurs, the ` +
61
+ `keemakr-core deployment may predate the grant-refresh endpoint — raise ` +
62
+ `CAPABILITY_GRANT_TTL_SECONDS there, or use a machine grant for long ` +
63
+ `unattended work.`);
64
+ }
65
+ return (`${base} — the capability grant was rejected. This is not an agent-fixable ` +
66
+ `error: the operator should re-issue the delegation.`);
67
+ }
68
+ /** An expired-grant denial: the typed `code` from current cores, with the
69
+ * legacy error-text match as the fallback against pre-refresh cores. */
70
+ function isExpiredDenial(status, body) {
71
+ if (status !== 401)
72
+ return false;
73
+ if (body.code)
74
+ return body.code === 'grant_expired';
75
+ return /expired/i.test(body.error ?? '');
49
76
  }
50
77
  async function capabilityFetch(grant, path, body, method = 'POST') {
51
78
  const url = `${coreBaseUrl()}/api/capability/${path}`;
52
79
  const hasBody = method !== 'GET' && method !== 'DELETE';
53
- const res = await fetch(url, {
54
- method,
55
- headers: {
56
- ...(hasBody ? { 'content-type': 'application/json' } : {}),
57
- authorization: `Bearer ${grant.token}`,
58
- ...(grant.traceId ? { 'x-keemakr-trace-id': grant.traceId } : {}),
59
- },
60
- ...(hasBody ? { body: JSON.stringify(body ?? {}) } : {}),
61
- });
62
- const json = (await res.json().catch(() => ({})));
80
+ const send = async (token) => {
81
+ const res = await fetch(url, {
82
+ method,
83
+ headers: {
84
+ ...(hasBody ? { 'content-type': 'application/json' } : {}),
85
+ authorization: `Bearer ${token}`,
86
+ ...(grant.traceId ? { 'x-keemakr-trace-id': grant.traceId } : {}),
87
+ },
88
+ ...(hasBody ? { body: JSON.stringify(body ?? {}) } : {}),
89
+ });
90
+ const json = (await res.json().catch(() => ({})));
91
+ return { res, json };
92
+ };
93
+ // Proactive: refresh the grant first when it's inside the expiry threshold
94
+ // (failure degrades to the current token — core is the judge).
95
+ const token = await ensureFreshToken(grant.token);
96
+ let { res, json } = await send(token);
97
+ // Reactive: one refresh + one retry when core says the grant expired in
98
+ // flight (or another call refreshed while this one was out on a stale
99
+ // token). At most one retry — never a loop.
100
+ if (isExpiredDenial(res.status, json)) {
101
+ const retryToken = await tokenForRetry(grant.token, token);
102
+ if (retryToken)
103
+ ({ res, json } = await send(retryToken));
104
+ }
63
105
  if (!res.ok) {
64
- throw keeError(json.error ?? `capability request failed (${res.status})`, res.status, json);
106
+ const base = json.error ?? `capability request failed (${res.status})`;
107
+ throw keeError(res.status === 401 ? with401Guidance(base, json.code) : base, res.status, json);
65
108
  }
66
109
  return json;
67
110
  }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export { grantAuth } from './grant-auth.js';
2
2
  export { verifyGrant, type VerifiedGrant } from './verify-grant.js';
3
3
  export { useKee, MemoryConflictError, type Kee, type KeeConnection, type KeeContext, type KeeError, type KeeMemory, type KeeKb, type KBHit, type KeeTools, type MemoryEntry, type MemorySearchHit, } from './client.js';
4
4
  export { keemakrToolDirectory } from './tool-directory.js';
5
+ export { refreshGrant, REFRESH_THRESHOLD_SECONDS } from './refresh.js';
package/dist/index.js CHANGED
@@ -12,3 +12,4 @@ export { grantAuth } from './grant-auth.js';
12
12
  export { verifyGrant } from './verify-grant.js';
13
13
  export { useKee, MemoryConflictError, } from './client.js';
14
14
  export { keemakrToolDirectory } from './tool-directory.js';
15
+ export { refreshGrant, REFRESH_THRESHOLD_SECONDS } from './refresh.js';
@@ -0,0 +1,31 @@
1
+ /** Refresh when the active token has less than this long left to live. */
2
+ export declare const REFRESH_THRESHOLD_SECONDS = 120;
3
+ /** Resolve core's base URL: KEE_CORE_URL, else derived from KEE_CORE_JWKS_URL. */
4
+ export declare function coreBaseUrl(): string;
5
+ /** The freshest token known for a delegation (the exchanged one, else the original). */
6
+ export declare function activeGrantToken(originalToken: string): string;
7
+ /**
8
+ * Exchange a still-valid grant for a fresh one. The one public low-level hook,
9
+ * for headless/advanced callers — `useKee` calls it for you. Throws a KeeError-
10
+ * shaped error when core refuses (expired grant, horizon exceeded, …); the
11
+ * error's `body.code` carries core's machine-readable reason.
12
+ */
13
+ export declare function refreshGrant(token: string, opts?: {
14
+ coreUrl?: string;
15
+ }): Promise<{
16
+ token: string;
17
+ exp: number;
18
+ }>;
19
+ /**
20
+ * The proactive path, called before every capability request: returns the token
21
+ * the request should carry, refreshing first when the active one is inside the
22
+ * expiry threshold. A failed refresh degrades to the current token.
23
+ */
24
+ export declare function ensureFreshToken(originalToken: string): Promise<string>;
25
+ /**
26
+ * The reactive path, called once after a 401 `grant_expired`: if another call
27
+ * already refreshed (the request went out on a stale token), hand back the
28
+ * newer one; otherwise attempt one shared refresh. Returns the token to retry
29
+ * with, or null — the caller retries AT MOST once and never loops.
30
+ */
31
+ export declare function tokenForRetry(originalToken: string, usedToken: string): Promise<string | null>;
@@ -0,0 +1,112 @@
1
+ // Grant refresh — keeps a delegation's capability grant alive for the whole
2
+ // run by exchanging a STILL-VALID grant for a fresh one at core's
3
+ // POST /api/capability/grant/refresh (sliding renewal; the chain is bounded
4
+ // server-side by root_iat, default 6h).
5
+ //
6
+ // The cache is MODULE state keyed by the ORIGINAL grant token from the session
7
+ // auth attributes — deliberately not closure state: eve step replay
8
+ // reconstructs closures, and a closure-held fresh token would silently vanish
9
+ // on replay. Module state survives within a process; across processes the
10
+ // worst case is one redundant refresh, which core guarantees is harmless
11
+ // (refreshing the same grant twice just yields two valid tokens).
12
+ //
13
+ // The SDK never verifies signatures and never sees the signing key —
14
+ // decodeJwt() here reads `exp` locally only; core re-verifies everything.
15
+ import { decodeJwt } from 'jose';
16
+ /** Refresh when the active token has less than this long left to live. */
17
+ export const REFRESH_THRESHOLD_SECONDS = 120;
18
+ // original grant token → freshest exchanged token. Module-level on purpose (R2).
19
+ const refreshed = new Map();
20
+ // original grant token → in-flight refresh, so N concurrent tool calls share one POST.
21
+ const inflight = new Map();
22
+ /** Resolve core's base URL: KEE_CORE_URL, else derived from KEE_CORE_JWKS_URL. */
23
+ export function coreBaseUrl() {
24
+ const explicit = process.env.KEE_CORE_URL;
25
+ if (explicit)
26
+ return explicit.replace(/\/$/, '');
27
+ const jwks = process.env.KEE_CORE_JWKS_URL;
28
+ if (jwks)
29
+ return jwks.replace(/\/\.well-known\/jwks\.json\/?$/, '');
30
+ const e = new Error('KEE_CORE_URL (or KEE_CORE_JWKS_URL) must be set to reach the Capability API');
31
+ e.name = 'KeeError';
32
+ throw e;
33
+ }
34
+ function expOf(token) {
35
+ try {
36
+ const { exp } = decodeJwt(token);
37
+ return typeof exp === 'number' ? exp : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** The freshest token known for a delegation (the exchanged one, else the original). */
44
+ export function activeGrantToken(originalToken) {
45
+ return refreshed.get(originalToken)?.token ?? originalToken;
46
+ }
47
+ /**
48
+ * Exchange a still-valid grant for a fresh one. The one public low-level hook,
49
+ * for headless/advanced callers — `useKee` calls it for you. Throws a KeeError-
50
+ * shaped error when core refuses (expired grant, horizon exceeded, …); the
51
+ * error's `body.code` carries core's machine-readable reason.
52
+ */
53
+ export async function refreshGrant(token, opts) {
54
+ const base = opts?.coreUrl?.replace(/\/$/, '') ?? coreBaseUrl();
55
+ const res = await fetch(`${base}/api/capability/grant/refresh`, {
56
+ method: 'POST',
57
+ headers: { authorization: `Bearer ${token}` },
58
+ });
59
+ const json = (await res.json().catch(() => ({})));
60
+ if (!res.ok || typeof json.token !== 'string' || typeof json.exp !== 'number') {
61
+ const e = new Error(`grant refresh failed (${res.status})${json.error ? `: ${json.error}` : ''}`);
62
+ e.name = 'KeeError';
63
+ e.status = res.status;
64
+ e.body = json;
65
+ throw e;
66
+ }
67
+ return { token: json.token, exp: json.exp };
68
+ }
69
+ /** Single-flight refresh of a delegation's ACTIVE token; null on any failure. */
70
+ function refreshShared(originalToken) {
71
+ const running = inflight.get(originalToken);
72
+ if (running)
73
+ return running;
74
+ const attempt = refreshGrant(activeGrantToken(originalToken))
75
+ .then((fresh) => {
76
+ refreshed.set(originalToken, fresh);
77
+ return fresh;
78
+ })
79
+ .catch(() => null) // non-fatal: proceed on the current token, core decides
80
+ .finally(() => {
81
+ inflight.delete(originalToken);
82
+ });
83
+ inflight.set(originalToken, attempt);
84
+ return attempt;
85
+ }
86
+ /**
87
+ * The proactive path, called before every capability request: returns the token
88
+ * the request should carry, refreshing first when the active one is inside the
89
+ * expiry threshold. A failed refresh degrades to the current token.
90
+ */
91
+ export async function ensureFreshToken(originalToken) {
92
+ const active = activeGrantToken(originalToken);
93
+ const exp = expOf(active);
94
+ const now = Math.floor(Date.now() / 1000);
95
+ if (exp !== null && exp - now >= REFRESH_THRESHOLD_SECONDS)
96
+ return active;
97
+ const fresh = await refreshShared(originalToken);
98
+ return fresh?.token ?? active;
99
+ }
100
+ /**
101
+ * The reactive path, called once after a 401 `grant_expired`: if another call
102
+ * already refreshed (the request went out on a stale token), hand back the
103
+ * newer one; otherwise attempt one shared refresh. Returns the token to retry
104
+ * with, or null — the caller retries AT MOST once and never loops.
105
+ */
106
+ export async function tokenForRetry(originalToken, usedToken) {
107
+ const active = activeGrantToken(originalToken);
108
+ if (active !== usedToken)
109
+ return active;
110
+ const fresh = await refreshShared(originalToken);
111
+ return fresh && fresh.token !== usedToken ? fresh.token : null;
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keemakr/agent-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The floor for keemakr marketplace agents: verify the capability grant and reach tenant connections, memory, and shared platform tools through keemakr-core — without holding raw secrets or resolving the tenant yourself.",
5
5
  "license": "MIT",
6
6
  "type": "module",