@loopingai/core 0.6.0 → 0.7.1
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 +36 -3
- package/dist/a2a/caller-token.d.ts +44 -0
- package/dist/a2a/caller-token.js +61 -0
- package/dist/a2a/deliver.d.ts +49 -0
- package/dist/a2a/deliver.js +49 -0
- package/dist/a2a/index.d.ts +3 -0
- package/dist/a2a/index.js +3 -0
- package/dist/a2a/self-origin.d.ts +91 -0
- package/dist/a2a/self-origin.js +114 -0
- package/dist/alarm/index.d.ts +77 -0
- package/dist/alarm/index.js +116 -0
- package/dist/host/agent.d.ts +36 -0
- package/dist/host/agent.js +44 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/platform.d.ts +29 -0
- package/dist/platform.js +31 -0
- package/dist/round/agent.js +18 -2
- package/dist/round/workflow.js +23 -78
- package/dist/subagent/index.d.ts +26 -5
- package/dist/subagent/index.js +35 -5
- package/dist/testing/do.d.ts +4 -4
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +5 -1
- package/package.json +5 -1
- package/scripts/generate-keys.mjs +15 -20
package/README.md
CHANGED
|
@@ -39,11 +39,12 @@ optional is a plugin. Anything opinionated belongs to your app.
|
|
|
39
39
|
npx looping-keys
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Set the private JWK as `A2A_SIGNING_KEY` (`.
|
|
43
|
-
when deployed) and the origins you accept
|
|
42
|
+
Set the private JWK as `A2A_SIGNING_KEY` (`.env` locally; `wrangler deploy
|
|
43
|
+
--secrets-file .env` or `wrangler secret put` when deployed) and the origins you accept
|
|
44
|
+
calls from as `GATEWAY_ORIGINS`:
|
|
44
45
|
|
|
45
46
|
```ini
|
|
46
|
-
# .
|
|
47
|
+
# .env
|
|
47
48
|
A2A_SIGNING_KEY={"crv":"Ed25519","d":"…","x":"…","kty":"OKP","kid":"a2a-2026-08-01"}
|
|
48
49
|
GATEWAY_ORIGINS=["https://gateway.example.com"]
|
|
49
50
|
```
|
|
@@ -268,6 +269,38 @@ makes the served document a fixed point under the repeated decoding a verifier
|
|
|
268
269
|
performs. A gateway pins the card's `kid` + `jku` on first registration
|
|
269
270
|
(Trust-On-First-Use).
|
|
270
271
|
|
|
272
|
+
### Calling out, and knowing your own origin
|
|
273
|
+
|
|
274
|
+
The same key proves this agent to services that are not the gateway — an inference
|
|
275
|
+
proxy, another agent. `signCallerToken` mints the short-lived token for that: `iss` is
|
|
276
|
+
this deployment's origin, `jku` is derived from it, and the audience is normalized to a
|
|
277
|
+
bare origin because the far side compares it byte-for-byte.
|
|
278
|
+
|
|
279
|
+
Its `iss` is **not** something to configure. Inside a Durable Object it is:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
protected override modelRuntime(model: ModelConfig): ModelRuntime {
|
|
283
|
+
return myProvider(this.env, model, () => this.requireSelfOrigin());
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`requireSelfOrigin()` (and `selfOrigin()`, which returns `undefined` instead of
|
|
288
|
+
throwing) answer with the origin core already delivers: the executor computes the
|
|
289
|
+
callback `jku` from `new URL(request.url).origin`, and it rides every turn into the DO
|
|
290
|
+
and on into each subagent facet. A `SELF_ORIGIN` secret only restates that, and has to
|
|
291
|
+
be kept byte-identical with the verifier's allowlist by hand in every environment.
|
|
292
|
+
|
|
293
|
+
The first turn an instance serves **pins** it, and nothing is persisted. Pinning is
|
|
294
|
+
what makes it safe to read: turns run concurrently in one Durable Object and a
|
|
295
|
+
credential thunk fires several frames below the turn that set the value, so a mutable
|
|
296
|
+
field could hand one turn another's origin. An agent has one endpoint anyway — the one
|
|
297
|
+
its card advertises and a verifier allowlists — and a fresh isolate on deploy re-learns
|
|
298
|
+
it.
|
|
299
|
+
|
|
300
|
+
It is known **inside a turn or a chunk**: `onStart`, a constructor and a scheduled
|
|
301
|
+
callback all run before any request has said what this deployment is called, and
|
|
302
|
+
`requireSelfOrigin()` throws there saying so.
|
|
303
|
+
|
|
271
304
|
---
|
|
272
305
|
|
|
273
306
|
## Plugins
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface CallerTokenOptions {
|
|
2
|
+
/** This agent's raw private JWK JSON — the same `A2A_SIGNING_KEY` its card is signed with. */
|
|
3
|
+
signingKey: string;
|
|
4
|
+
/**
|
|
5
|
+
* This agent's own origin. It becomes `iss`, and `jku` is derived from it, and
|
|
6
|
+
* the two must agree: a verifier that accepts a `jku` on a different origin
|
|
7
|
+
* than `iss` lets one allowlisted origin impersonate another.
|
|
8
|
+
*
|
|
9
|
+
* **Not something to configure.** Inside a Durable Object it is
|
|
10
|
+
* `requireSelfOrigin()` — see {@link file://./self-origin.ts SelfOrigin},
|
|
11
|
+
* which learns it from the `jku` every turn already carries. A `SELF_ORIGIN`
|
|
12
|
+
* secret restates what the request path knows and has to be kept
|
|
13
|
+
* byte-identical with the verifier's allowlist by hand.
|
|
14
|
+
*/
|
|
15
|
+
issuer: string;
|
|
16
|
+
/**
|
|
17
|
+
* Who the token is for. Normalized to a bare origin, because a verifier
|
|
18
|
+
* typically derives what it expects from `new URL(request.url).origin` and
|
|
19
|
+
* `jose` compares `aud` byte-for-byte — a trailing slash or a stray path is a
|
|
20
|
+
* 401 on every request with nothing to catch it. Throws on a value that is not
|
|
21
|
+
* an absolute URL, which is the right moment for that to fail.
|
|
22
|
+
*/
|
|
23
|
+
audience: string;
|
|
24
|
+
/** The identity this agent asserts. */
|
|
25
|
+
identity: Record<string, unknown>;
|
|
26
|
+
/** Which tenant of this deployment the token speaks for. */
|
|
27
|
+
tenant: string;
|
|
28
|
+
/** Lifetime in seconds. Defaults to 120. */
|
|
29
|
+
ttlSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Sign a short-lived token identifying **this agent as a caller** to another
|
|
33
|
+
* service that trusts its card key.
|
|
34
|
+
*
|
|
35
|
+
* The production sibling of `makeGatewayToken`, which core previously shipped
|
|
36
|
+
* only from `/testing` — so an agent that had to call out mint-signed had to
|
|
37
|
+
* hand-write this shape, and every deployment that did so wrote its own subtly
|
|
38
|
+
* different version of the `iss`/`jku` agreement above.
|
|
39
|
+
*
|
|
40
|
+
* Distinct from {@link signCallbackJwt}, which carries **no** claims: that one
|
|
41
|
+
* proves "the agent you called is calling you back about this task", where this
|
|
42
|
+
* proves "this is who I am and which tenant I speak for".
|
|
43
|
+
*/
|
|
44
|
+
export declare function signCallerToken(options: CallerTokenOptions): Promise<string>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { importJWK, SignJWT } from "jose";
|
|
2
|
+
import { A2A_JWS_ALG, IDENTITY_CLAIM, TENANT_CLAIM, jwksUrl } from "@loopingai/a2a-protocol";
|
|
3
|
+
import { parsePrivateJwk } from "./card.js";
|
|
4
|
+
/** Default lifetime: long enough for clock skew, short enough to be worthless from a log. */
|
|
5
|
+
const DEFAULT_TTL_SECONDS = 120;
|
|
6
|
+
/**
|
|
7
|
+
* `importJWK` does real work and this is on the per-request path.
|
|
8
|
+
*
|
|
9
|
+
* Keyed by the raw secret so a rotated key invalidates the entry rather than
|
|
10
|
+
* being ignored for the life of the isolate.
|
|
11
|
+
*/
|
|
12
|
+
let cached;
|
|
13
|
+
async function signingKeyFor(raw) {
|
|
14
|
+
if (cached?.raw === raw)
|
|
15
|
+
return cached;
|
|
16
|
+
const jwk = parsePrivateJwk(raw);
|
|
17
|
+
// Not cast to `CryptoKey`: `importJWK` returns a union, and asserting the
|
|
18
|
+
// branch would be a lie the day this key is anything but Ed25519.
|
|
19
|
+
const key = await importJWK(jwk, A2A_JWS_ALG);
|
|
20
|
+
cached = { raw, key, kid: jwk.kid };
|
|
21
|
+
return cached;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Sign a short-lived token identifying **this agent as a caller** to another
|
|
25
|
+
* service that trusts its card key.
|
|
26
|
+
*
|
|
27
|
+
* The production sibling of `makeGatewayToken`, which core previously shipped
|
|
28
|
+
* only from `/testing` — so an agent that had to call out mint-signed had to
|
|
29
|
+
* hand-write this shape, and every deployment that did so wrote its own subtly
|
|
30
|
+
* different version of the `iss`/`jku` agreement above.
|
|
31
|
+
*
|
|
32
|
+
* Distinct from {@link signCallbackJwt}, which carries **no** claims: that one
|
|
33
|
+
* proves "the agent you called is calling you back about this task", where this
|
|
34
|
+
* proves "this is who I am and which tenant I speak for".
|
|
35
|
+
*/
|
|
36
|
+
export async function signCallerToken(options) {
|
|
37
|
+
const { key, kid } = await signingKeyFor(options.signingKey);
|
|
38
|
+
// Normalized for the same reason `audience` is, and it has to happen before
|
|
39
|
+
// both uses: `jku` names only the origin, so an `issuer` carrying a trailing
|
|
40
|
+
// slash or a path would sign an `iss` that disagrees with it — and a verifier
|
|
41
|
+
// comparing `iss` byte-for-byte against a normalized origin allowlist rejects
|
|
42
|
+
// the token even though the two URLs share an origin.
|
|
43
|
+
const issuer = new URL(options.issuer).origin;
|
|
44
|
+
return new SignJWT({
|
|
45
|
+
[IDENTITY_CLAIM]: options.identity,
|
|
46
|
+
[TENANT_CLAIM]: options.tenant
|
|
47
|
+
})
|
|
48
|
+
.setProtectedHeader({
|
|
49
|
+
alg: A2A_JWS_ALG,
|
|
50
|
+
kid,
|
|
51
|
+
// Where the far side fetches the public half. A verifier must check this
|
|
52
|
+
// origin against its own allowlist *before* fetching, which is what stops
|
|
53
|
+
// a forged token nominating an attacker-controlled JWKS.
|
|
54
|
+
jku: jwksUrl(issuer)
|
|
55
|
+
})
|
|
56
|
+
.setIssuer(issuer)
|
|
57
|
+
.setAudience(new URL(options.audience).origin)
|
|
58
|
+
.setIssuedAt()
|
|
59
|
+
.setExpirationTime(`${options.ttlSeconds ?? DEFAULT_TTL_SECONDS}s`)
|
|
60
|
+
.sign(key);
|
|
61
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { WorkflowStep } from "cloudflare:workers";
|
|
2
|
+
import { type TurnPushContext } from "./push.js";
|
|
3
|
+
import type { PlainTask } from "./task.js";
|
|
4
|
+
/** What {@link deliverTerminalTask} needs to finish a turn. */
|
|
5
|
+
export interface DeliverTerminalOptions {
|
|
6
|
+
/** Which Task this is about, and where its callback goes. */
|
|
7
|
+
push: TurnPushContext;
|
|
8
|
+
/** The agent's card-signing key, for the callback JWT. */
|
|
9
|
+
signingKey: string;
|
|
10
|
+
/**
|
|
11
|
+
* The guarded write, and **the cancellation check**. Return whether it
|
|
12
|
+
* applied: `false` must mean the row was already terminal (canceled), which is
|
|
13
|
+
* what suppresses the callback.
|
|
14
|
+
*
|
|
15
|
+
* Resolve any Durable Object stub *inside* this function, never above it — a
|
|
16
|
+
* stub hoisted out of a step body is a live connection that a replay cannot
|
|
17
|
+
* reconnect.
|
|
18
|
+
*/
|
|
19
|
+
saveTask: (task: PlainTask) => Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Build the terminal Task. Called **inside** the `complete` step so that what
|
|
22
|
+
* gets notified is exactly what was persisted: building it outside would
|
|
23
|
+
* re-stamp `new Date()` on every replay and post a Task that differs from the
|
|
24
|
+
* stored one.
|
|
25
|
+
*/
|
|
26
|
+
terminal: () => PlainTask;
|
|
27
|
+
/**
|
|
28
|
+
* Optional best-effort cleanup, run after the write and before the callback.
|
|
29
|
+
* An agent with no managed children omits it and no `sweep` step appears.
|
|
30
|
+
*/
|
|
31
|
+
sweep?: () => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Persist a turn's terminal Task, then notify the gateway.
|
|
35
|
+
*
|
|
36
|
+
* **The guarded write is the cancellation check.** `saveTask` refuses to write a
|
|
37
|
+
* terminal state over a `canceled` row and says so, doing that read and write in
|
|
38
|
+
* one synchronous pass inside the Durable Object. Probing first and saving
|
|
39
|
+
* second would leave a window — between the two calls, and again between this
|
|
40
|
+
* step and `notify` — in which a `tasks/cancel` lands and the gateway still
|
|
41
|
+
* receives a `completed` callback. Keying the notify on "did the write apply"
|
|
42
|
+
* closes it, and that has already been got wrong once.
|
|
43
|
+
*
|
|
44
|
+
* In `/a2a` rather than `/round` because the shape is not a round's: an agent
|
|
45
|
+
* whose turn is a single inference ends it the same way, and importing this from
|
|
46
|
+
* `/round` would put the whole delegation engine in a bundle that must not carry
|
|
47
|
+
* it. Everything it calls already lives here.
|
|
48
|
+
*/
|
|
49
|
+
export declare function deliverTerminalTask(step: WorkflowStep, options: DeliverTerminalOptions): Promise<void>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createPushChannel } from "./push.js";
|
|
2
|
+
/**
|
|
3
|
+
* Persist a turn's terminal Task, then notify the gateway.
|
|
4
|
+
*
|
|
5
|
+
* **The guarded write is the cancellation check.** `saveTask` refuses to write a
|
|
6
|
+
* terminal state over a `canceled` row and says so, doing that read and write in
|
|
7
|
+
* one synchronous pass inside the Durable Object. Probing first and saving
|
|
8
|
+
* second would leave a window — between the two calls, and again between this
|
|
9
|
+
* step and `notify` — in which a `tasks/cancel` lands and the gateway still
|
|
10
|
+
* receives a `completed` callback. Keying the notify on "did the write apply"
|
|
11
|
+
* closes it, and that has already been got wrong once.
|
|
12
|
+
*
|
|
13
|
+
* In `/a2a` rather than `/round` because the shape is not a round's: an agent
|
|
14
|
+
* whose turn is a single inference ends it the same way, and importing this from
|
|
15
|
+
* `/round` would put the whole delegation engine in a bundle that must not carry
|
|
16
|
+
* it. Everything it calls already lives here.
|
|
17
|
+
*/
|
|
18
|
+
export async function deliverTerminalTask(step, options) {
|
|
19
|
+
const task = await step.do("complete", async () => {
|
|
20
|
+
const terminal = options.terminal();
|
|
21
|
+
return (await options.saveTask(terminal)) ? terminal : null;
|
|
22
|
+
});
|
|
23
|
+
if (!task)
|
|
24
|
+
return;
|
|
25
|
+
// Caught, not left to propagate: the terminal Task is already durably saved,
|
|
26
|
+
// so a sweep that still fails once the step's own retries are exhausted must
|
|
27
|
+
// not block the callback — the gateway is owed its result either way.
|
|
28
|
+
if (options.sweep) {
|
|
29
|
+
const sweep = options.sweep;
|
|
30
|
+
try {
|
|
31
|
+
await step.do("sweep", async () => {
|
|
32
|
+
await sweep();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error("[deliver] sweep failed after retries", {
|
|
37
|
+
taskId: options.push.taskId,
|
|
38
|
+
err: String(err)
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// A card-key-signed callback POST. Retried by the step on a non-2xx; the
|
|
43
|
+
// terminal messageId is deterministic and the gateway is idempotent and
|
|
44
|
+
// single-use, so retries are safe. If it ultimately fails, the gateway's own
|
|
45
|
+
// reaction backstop clears the pending marker.
|
|
46
|
+
await step.do("notify", async () => {
|
|
47
|
+
await createPushChannel(options.signingKey, options.push).deliver(task);
|
|
48
|
+
});
|
|
49
|
+
}
|
package/dist/a2a/index.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export { A2A_JWS_ALG, audienceFor, endpointUrl, jwksUrl } from "@loopingai/a2a-p
|
|
|
22
22
|
export { IDENTITY_CLAIM, TENANT_CLAIM, GatewayAuthError, bearerToken, normalizeGatewayOrigins, verifyGatewayToken, type GatewayIdentity, type VerifyOptions } from "./verify.js";
|
|
23
23
|
export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publicCardJwks, type AgentManifest, type BuildCardOptions, type CardSigningConfig, type WireAgentCard } from "./card.js";
|
|
24
24
|
export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
|
|
25
|
+
export { deliverTerminalTask, type DeliverTerminalOptions } from "./deliver.js";
|
|
26
|
+
export { signCallerToken, type CallerTokenOptions } from "./caller-token.js";
|
|
27
|
+
export { SelfOrigin } from "./self-origin.js";
|
|
25
28
|
export { callerContext } from "./caller.js";
|
|
26
29
|
export { createPushChannel, type PushChannel, type TurnPushContext } from "./push.js";
|
|
27
30
|
export { taskStateLabel, type PlainArtifact, type PlainMessage, type PlainPart, type PlainStatus, type PlainTask } from "./task.js";
|
package/dist/a2a/index.js
CHANGED
|
@@ -22,6 +22,9 @@ export { A2A_JWS_ALG, audienceFor, endpointUrl, jwksUrl } from "@loopingai/a2a-p
|
|
|
22
22
|
export { IDENTITY_CLAIM, TENANT_CLAIM, GatewayAuthError, bearerToken, normalizeGatewayOrigins, verifyGatewayToken } from "./verify.js";
|
|
23
23
|
export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publicCardJwks } from "./card.js";
|
|
24
24
|
export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
|
|
25
|
+
export { deliverTerminalTask } from "./deliver.js";
|
|
26
|
+
export { signCallerToken } from "./caller-token.js";
|
|
27
|
+
export { SelfOrigin } from "./self-origin.js";
|
|
25
28
|
export { callerContext } from "./caller.js";
|
|
26
29
|
export { createPushChannel } from "./push.js";
|
|
27
30
|
export { taskStateLabel } from "./task.js";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This deployment's own public origin — learned from the request path, never
|
|
3
|
+
* configured.
|
|
4
|
+
*
|
|
5
|
+
* ## Why an agent needs it at all
|
|
6
|
+
*
|
|
7
|
+
* A Worker that only *answers* never needs to know its own name.
|
|
8
|
+
* {@link file://../worker/index.ts createA2AWorker} derives its audience, its
|
|
9
|
+
* card and its `jku` from `new URL(request.url).origin`, and none of it outlives
|
|
10
|
+
* the request. An agent that **calls out** mint-signed does need it:
|
|
11
|
+
* {@link file://./caller-token.ts signCallerToken} puts it in `iss` and derives
|
|
12
|
+
* the token's `jku` from it. That call happens inside a Durable Object, where
|
|
13
|
+
* there is no `Request` — which is the whole difficulty.
|
|
14
|
+
*
|
|
15
|
+
* The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
|
|
16
|
+
* restates a value the request already carries, and it has to be kept
|
|
17
|
+
* byte-identical by hand with the origin allowlist on the far side, in every
|
|
18
|
+
* environment, forever. Both siblings that tried it took it back out:
|
|
19
|
+
* `looping-anthropic-proxy` deleted `PROXY_AUDIENCE` in favour of `url.origin`,
|
|
20
|
+
* and `looping-gateway` discovers its own origin from the first
|
|
21
|
+
* signature-verified request rather than being told.
|
|
22
|
+
*
|
|
23
|
+
* ## Where the value comes from
|
|
24
|
+
*
|
|
25
|
+
* Core already sends the origin into the Durable Object on every turn, one field
|
|
26
|
+
* short of this use. `A2AExecutor` computes `jku` as `${origin}${jwksPath}` and
|
|
27
|
+
* it rides {@link file://./push.ts TurnPushContext} through the Workflow into
|
|
28
|
+
* `runTaskTurn` and `executeSubtaskChunk`.
|
|
29
|
+
*
|
|
30
|
+
* That is the same origin `signCallerToken` needs, and not by coincidence: a
|
|
31
|
+
* caller token's `jku` **must** be the JWKS the verifier fetches, and `iss` must
|
|
32
|
+
* agree with it — the third check in {@link file://./verify.ts verify.ts}. An
|
|
33
|
+
* origin derived from anywhere else is exactly what that check exists to catch,
|
|
34
|
+
* so deriving it from the `jku` core already serves makes the agreement
|
|
35
|
+
* structural instead of clerical.
|
|
36
|
+
*
|
|
37
|
+
* ## Pinned on the first turn, and in memory
|
|
38
|
+
*
|
|
39
|
+
* The first origin an isolate is told wins, and later ones are ignored. That is
|
|
40
|
+
* not laziness about staleness — it is what makes the value safe to *read*.
|
|
41
|
+
*
|
|
42
|
+
* A Durable Object's input gate stays open across a non-storage await, and this
|
|
43
|
+
* package runs concurrent RPCs into one object by design (`round/workflow.ts`
|
|
44
|
+
* runs a round's branches under `Promise.all`). Mutable instance state can
|
|
45
|
+
* therefore change while a turn is awaiting a model call, and the credential
|
|
46
|
+
* thunks that read this are lazy — they run several frames below the turn, when
|
|
47
|
+
* the client is built. Pinned, the field is immutable after its first write, so
|
|
48
|
+
* every concurrent reader in the isolate gets the same string and no turn can
|
|
49
|
+
* sign as another turn's origin.
|
|
50
|
+
*
|
|
51
|
+
* The cost of pinning is what an agent does not have: several identities. An
|
|
52
|
+
* agent has one endpoint — the one its card advertises, the one a gateway calls
|
|
53
|
+
* and a verifier allowlists — so there is nothing to follow. Note the asymmetry
|
|
54
|
+
* with `looping-anthropic-proxy`, which derives its audience per request and
|
|
55
|
+
* refuses to cache: a *verifier* must accept every hostname it answers on, while
|
|
56
|
+
* a *signer* needs one stable identity.
|
|
57
|
+
*
|
|
58
|
+
* Nothing is persisted, which is what keeps a pin from outliving its truth. An
|
|
59
|
+
* isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
|
|
60
|
+
* deployment re-learns its origin from the next turn it serves.
|
|
61
|
+
*/
|
|
62
|
+
export declare class SelfOrigin {
|
|
63
|
+
private observed?;
|
|
64
|
+
/**
|
|
65
|
+
* Offer the origin of an absolute URL seen on the request path — a `jku`, an
|
|
66
|
+
* endpoint, or a bare origin. Only `.origin` is kept, so a path or a trailing
|
|
67
|
+
* slash cannot reach a token claim.
|
|
68
|
+
*
|
|
69
|
+
* **The first usable value wins**; every later call is a no-op, including one
|
|
70
|
+
* naming a different origin. Called at each RPC entry and again when the push
|
|
71
|
+
* channel is built, so most calls are already no-ops — but the reason for the
|
|
72
|
+
* pin is the read side, not the write side. See the note above the class.
|
|
73
|
+
*
|
|
74
|
+
* Silently ignores anything unusable (absent, relative, or a scheme that has
|
|
75
|
+
* no meaningful origin), and an unusable value never pins: the parse comes
|
|
76
|
+
* first and the field is assigned only on success. This runs at the top of a
|
|
77
|
+
* turn, where a diagnostic value must never be the thing that fails it; the
|
|
78
|
+
* throw belongs at {@link require}, where something actually wanted the value.
|
|
79
|
+
*/
|
|
80
|
+
note(url: string | undefined): void;
|
|
81
|
+
/** The pinned origin, or `undefined` when nothing has carried one yet. */
|
|
82
|
+
peek(): string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The pinned origin, for a caller that cannot proceed without it.
|
|
85
|
+
*
|
|
86
|
+
* Throws naming the timing, because that is what the mistake always is: the
|
|
87
|
+
* value arrives with a turn, so `onStart`, a constructor and a scheduled
|
|
88
|
+
* callback all run before any request has said what this deployment is called.
|
|
89
|
+
*/
|
|
90
|
+
require(): string;
|
|
91
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This deployment's own public origin — learned from the request path, never
|
|
3
|
+
* configured.
|
|
4
|
+
*
|
|
5
|
+
* ## Why an agent needs it at all
|
|
6
|
+
*
|
|
7
|
+
* A Worker that only *answers* never needs to know its own name.
|
|
8
|
+
* {@link file://../worker/index.ts createA2AWorker} derives its audience, its
|
|
9
|
+
* card and its `jku` from `new URL(request.url).origin`, and none of it outlives
|
|
10
|
+
* the request. An agent that **calls out** mint-signed does need it:
|
|
11
|
+
* {@link file://./caller-token.ts signCallerToken} puts it in `iss` and derives
|
|
12
|
+
* the token's `jku` from it. That call happens inside a Durable Object, where
|
|
13
|
+
* there is no `Request` — which is the whole difficulty.
|
|
14
|
+
*
|
|
15
|
+
* The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
|
|
16
|
+
* restates a value the request already carries, and it has to be kept
|
|
17
|
+
* byte-identical by hand with the origin allowlist on the far side, in every
|
|
18
|
+
* environment, forever. Both siblings that tried it took it back out:
|
|
19
|
+
* `looping-anthropic-proxy` deleted `PROXY_AUDIENCE` in favour of `url.origin`,
|
|
20
|
+
* and `looping-gateway` discovers its own origin from the first
|
|
21
|
+
* signature-verified request rather than being told.
|
|
22
|
+
*
|
|
23
|
+
* ## Where the value comes from
|
|
24
|
+
*
|
|
25
|
+
* Core already sends the origin into the Durable Object on every turn, one field
|
|
26
|
+
* short of this use. `A2AExecutor` computes `jku` as `${origin}${jwksPath}` and
|
|
27
|
+
* it rides {@link file://./push.ts TurnPushContext} through the Workflow into
|
|
28
|
+
* `runTaskTurn` and `executeSubtaskChunk`.
|
|
29
|
+
*
|
|
30
|
+
* That is the same origin `signCallerToken` needs, and not by coincidence: a
|
|
31
|
+
* caller token's `jku` **must** be the JWKS the verifier fetches, and `iss` must
|
|
32
|
+
* agree with it — the third check in {@link file://./verify.ts verify.ts}. An
|
|
33
|
+
* origin derived from anywhere else is exactly what that check exists to catch,
|
|
34
|
+
* so deriving it from the `jku` core already serves makes the agreement
|
|
35
|
+
* structural instead of clerical.
|
|
36
|
+
*
|
|
37
|
+
* ## Pinned on the first turn, and in memory
|
|
38
|
+
*
|
|
39
|
+
* The first origin an isolate is told wins, and later ones are ignored. That is
|
|
40
|
+
* not laziness about staleness — it is what makes the value safe to *read*.
|
|
41
|
+
*
|
|
42
|
+
* A Durable Object's input gate stays open across a non-storage await, and this
|
|
43
|
+
* package runs concurrent RPCs into one object by design (`round/workflow.ts`
|
|
44
|
+
* runs a round's branches under `Promise.all`). Mutable instance state can
|
|
45
|
+
* therefore change while a turn is awaiting a model call, and the credential
|
|
46
|
+
* thunks that read this are lazy — they run several frames below the turn, when
|
|
47
|
+
* the client is built. Pinned, the field is immutable after its first write, so
|
|
48
|
+
* every concurrent reader in the isolate gets the same string and no turn can
|
|
49
|
+
* sign as another turn's origin.
|
|
50
|
+
*
|
|
51
|
+
* The cost of pinning is what an agent does not have: several identities. An
|
|
52
|
+
* agent has one endpoint — the one its card advertises, the one a gateway calls
|
|
53
|
+
* and a verifier allowlists — so there is nothing to follow. Note the asymmetry
|
|
54
|
+
* with `looping-anthropic-proxy`, which derives its audience per request and
|
|
55
|
+
* refuses to cache: a *verifier* must accept every hostname it answers on, while
|
|
56
|
+
* a *signer* needs one stable identity.
|
|
57
|
+
*
|
|
58
|
+
* Nothing is persisted, which is what keeps a pin from outliving its truth. An
|
|
59
|
+
* isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
|
|
60
|
+
* deployment re-learns its origin from the next turn it serves.
|
|
61
|
+
*/
|
|
62
|
+
export class SelfOrigin {
|
|
63
|
+
observed;
|
|
64
|
+
/**
|
|
65
|
+
* Offer the origin of an absolute URL seen on the request path — a `jku`, an
|
|
66
|
+
* endpoint, or a bare origin. Only `.origin` is kept, so a path or a trailing
|
|
67
|
+
* slash cannot reach a token claim.
|
|
68
|
+
*
|
|
69
|
+
* **The first usable value wins**; every later call is a no-op, including one
|
|
70
|
+
* naming a different origin. Called at each RPC entry and again when the push
|
|
71
|
+
* channel is built, so most calls are already no-ops — but the reason for the
|
|
72
|
+
* pin is the read side, not the write side. See the note above the class.
|
|
73
|
+
*
|
|
74
|
+
* Silently ignores anything unusable (absent, relative, or a scheme that has
|
|
75
|
+
* no meaningful origin), and an unusable value never pins: the parse comes
|
|
76
|
+
* first and the field is assigned only on success. This runs at the top of a
|
|
77
|
+
* turn, where a diagnostic value must never be the thing that fails it; the
|
|
78
|
+
* throw belongs at {@link require}, where something actually wanted the value.
|
|
79
|
+
*/
|
|
80
|
+
note(url) {
|
|
81
|
+
if (this.observed || !url)
|
|
82
|
+
return;
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = new URL(url);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
91
|
+
return;
|
|
92
|
+
this.observed = parsed.origin;
|
|
93
|
+
}
|
|
94
|
+
/** The pinned origin, or `undefined` when nothing has carried one yet. */
|
|
95
|
+
peek() {
|
|
96
|
+
return this.observed;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The pinned origin, for a caller that cannot proceed without it.
|
|
100
|
+
*
|
|
101
|
+
* Throws naming the timing, because that is what the mistake always is: the
|
|
102
|
+
* value arrives with a turn, so `onStart`, a constructor and a scheduled
|
|
103
|
+
* callback all run before any request has said what this deployment is called.
|
|
104
|
+
*/
|
|
105
|
+
require() {
|
|
106
|
+
if (!this.observed) {
|
|
107
|
+
throw new Error("this deployment's own origin is not known on this instance yet: it is " +
|
|
108
|
+
"learned from the `jku` that arrives with every turn, so it is " +
|
|
109
|
+
"available inside a turn or a subtask chunk — not from onStart, a " +
|
|
110
|
+
"constructor or a scheduled callback");
|
|
111
|
+
}
|
|
112
|
+
return this.observed;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@loopingai/core/alarm` — many deadlines over a Durable Object's one alarm.
|
|
3
|
+
*
|
|
4
|
+
* A Durable Object has exactly **one** alarm, and an object that needs to wake
|
|
5
|
+
* for more than one reason cannot simply call `setAlarm` from each of them: the
|
|
6
|
+
* last writer silently wins, and whatever the loser was waiting on never
|
|
7
|
+
* happens. {@link WakeMap} is the fix — one storage row holding every pending
|
|
8
|
+
* intent, and the only thing in a DO that calls `setAlarm`.
|
|
9
|
+
*
|
|
10
|
+
* **Its own subpath, deliberately.** This is useful to a plain `DurableObject`,
|
|
11
|
+
* not only to a {@link LoopingAgent}, so it must be importable without pulling
|
|
12
|
+
* the agent machinery into a bundle.
|
|
13
|
+
*
|
|
14
|
+
* **Why not `Agent.schedule()`.** The `agents` SDK has the same mechanism, but
|
|
15
|
+
* sells it only as a method on `Agent`: adopting it means the object becomes an
|
|
16
|
+
* `Agent`, whose constructor creates `cf_agents_state`, `cf_agents_mcp_servers`
|
|
17
|
+
* and `cf_agents_queues` in that object's SQLite, builds an `MCPClientManager`,
|
|
18
|
+
* and prototype-patches every public method for tracing. For an object whose
|
|
19
|
+
* SQLite is something else already — a container's filesystem, say — that is a
|
|
20
|
+
* large import for a small one. `agents/schedule` is not an alternative: it is a
|
|
21
|
+
* prompt and a zod schema for parsing natural-language dates, not alarm
|
|
22
|
+
* machinery.
|
|
23
|
+
*
|
|
24
|
+
* This owns *when* an object wakes. What it owes on waking is the object's own:
|
|
25
|
+
* `alarm()` reads {@link WakeMap.due} and dispatches.
|
|
26
|
+
*/
|
|
27
|
+
/** One scheduled wake-up. */
|
|
28
|
+
export interface WakeIntent {
|
|
29
|
+
/** Why we are waking. Namespace it, e.g. `sync-retry:container-shell`. */
|
|
30
|
+
key: string;
|
|
31
|
+
/** Epoch ms at which this intent becomes due. */
|
|
32
|
+
notBefore: number;
|
|
33
|
+
/** Retry counter, for the intents that carry one. */
|
|
34
|
+
attempt?: number;
|
|
35
|
+
}
|
|
36
|
+
/** The single storage row holding every intent. Small, and written atomically. */
|
|
37
|
+
export declare const WAKE_KEY = "wake";
|
|
38
|
+
/** How far out {@link WakeMap.repair} re-arms when the handler itself failed. */
|
|
39
|
+
export declare const WAKE_REPAIR_MS = 60000;
|
|
40
|
+
export declare class WakeMap {
|
|
41
|
+
#private;
|
|
42
|
+
constructor(storage: DurableObjectStorage);
|
|
43
|
+
/**
|
|
44
|
+
* Every pending intent, as a **null-prototype** dictionary rebuilt from own
|
|
45
|
+
* entries only.
|
|
46
|
+
*
|
|
47
|
+
* {@link WakeIntent.key} is a caller-supplied string, so an ordinary object
|
|
48
|
+
* literal would let three of them misbehave: `get("toString")` would return an
|
|
49
|
+
* inherited function rather than `undefined`, `clear("constructor")` would
|
|
50
|
+
* treat a key it never held as present, and `set` on `"__proto__"` would hit
|
|
51
|
+
* `Object.prototype`'s setter and change the prototype instead of storing the
|
|
52
|
+
* intent. With no prototype there is nothing to inherit and nothing to poison,
|
|
53
|
+
* and every string round-trips as an ordinary key.
|
|
54
|
+
*/
|
|
55
|
+
all(): Promise<Record<string, WakeIntent>>;
|
|
56
|
+
get(key: string): Promise<WakeIntent | undefined>;
|
|
57
|
+
set(intent: WakeIntent): Promise<void>;
|
|
58
|
+
clear(key: string): Promise<void>;
|
|
59
|
+
/** Every intent whose time has come, earliest first. */
|
|
60
|
+
due(now: number): Promise<WakeIntent[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Point the alarm at the earliest deadline.
|
|
63
|
+
*
|
|
64
|
+
* Only ever moved **earlier**, never later: an alarm that fires too soon finds
|
|
65
|
+
* nothing due, re-arms, and costs one wake-up, whereas an alarm pushed later
|
|
66
|
+
* by a coincidental write silently delays whatever was already waiting. When
|
|
67
|
+
* no intents remain the alarm is deleted outright, so an idle object does not
|
|
68
|
+
* wake on a schedule it has no use for.
|
|
69
|
+
*/
|
|
70
|
+
rearm(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Re-arm shortly, for when the handler failed before it could work out what
|
|
73
|
+
* it owed. Distinct from {@link rearm} because that one trusts the map, and
|
|
74
|
+
* the map is what we just failed to read.
|
|
75
|
+
*/
|
|
76
|
+
repair(now: number): Promise<void>;
|
|
77
|
+
}
|