@keemakr/agent-sdk 0.7.0 → 0.9.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 +16 -0
- package/dist/client.js +64 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/refresh.d.ts +31 -0
- package/dist/refresh.js +112 -0
- package/dist/tool-directory.d.ts +9 -1
- package/package.json +3 -3
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/refresh.js
ADDED
|
@@ -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/dist/tool-directory.d.ts
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
|
-
export declare const keemakrToolDirectory: import("eve/tools").DynamicSentinel
|
|
1
|
+
export declare const keemakrToolDirectory: import("eve/tools").DynamicSentinel<{
|
|
2
|
+
[k: string]: import("eve/tools").ToolDefinition<{
|
|
3
|
+
args?: Record<string, unknown> | undefined;
|
|
4
|
+
}, {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
tool: string;
|
|
7
|
+
result: unknown;
|
|
8
|
+
}>;
|
|
9
|
+
} | null>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keemakr/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"prepublishOnly": "npm run build"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"eve": "0.
|
|
42
|
+
"eve": "0.22.1",
|
|
43
43
|
"jose": "^6.2.3"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^20.19.43",
|
|
47
|
-
"eve": "0.
|
|
47
|
+
"eve": "0.22.1",
|
|
48
48
|
"jose": "^6.2.3",
|
|
49
49
|
"typescript": "^5"
|
|
50
50
|
},
|