@kybernesis/identity 0.1.1 → 0.2.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 +47 -34
- package/dist/arp-auth.js +8 -5
- package/dist/channel.d.ts +12 -7
- package/dist/channel.js +28 -13
- package/dist/connect.d.ts +25 -0
- package/dist/connect.js +91 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/instructions.js +1 -1
- package/dist/peers.js +5 -2
- package/dist/store.d.ts +36 -0
- package/dist/store.js +68 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
# @kybernesis/identity
|
|
2
2
|
|
|
3
|
-
Give an eve agent its `.agent` name.
|
|
4
|
-
permissions and the audit trail; this package is the thin end: it verifies the
|
|
5
|
-
tokens ARP Cloud signs when it delivers a message, serves the document ARP Cloud
|
|
6
|
-
checks when you attach the runtime, and turns your paired peers into tools.
|
|
3
|
+
Give an eve agent its `.agent` name.
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
ARP Cloud holds the identity, the key, the permissions and the audit trail. This
|
|
6
|
+
package is the thin end that lives inside the agent: it lets the owner connect
|
|
7
|
+
the agent from the console with one click, accepts the messages ARP Cloud
|
|
8
|
+
delivers, and turns paired agents into tools.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
No daemon. No socket. No private key in your deployment. No environment
|
|
11
|
+
variables to copy.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
eve add @kybernesis/identity-channel # agent/channels/arp.ts
|
|
14
|
-
eve add @kybernesis/identity-peers # agent/tools/arp-peers.ts
|
|
15
|
-
```
|
|
13
|
+
## For owners
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
1. Register your name at [agent.arp.run](https://agent.arp.run).
|
|
16
|
+
2. On the name's page, **Connect your agent**: paste the agent's address and
|
|
17
|
+
click Connect. That is all — the agent receives its identity from ARP Cloud
|
|
18
|
+
and keeps it itself.
|
|
19
|
+
3. **Pair** with another agent from the same page; paired agents show up as
|
|
20
|
+
`ask_<name>_agent` tools on the agent's next turn.
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
Agents built with Kybernesis tooling ship with this package already wired in.
|
|
23
|
+
For any other eve agent, the developer does the one-time setup below.
|
|
24
|
+
|
|
25
|
+
## For developers (one-time)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
eve add @kybernesis/identity-channel # agent/channels/arp.ts → identityChannel()
|
|
29
|
+
eve add @kybernesis/identity-peers # agent/tools/arp-peers.ts → arpPeers()
|
|
26
30
|
```
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
Then let deliveries in, on the eve channel:
|
|
29
33
|
|
|
30
34
|
```ts
|
|
31
35
|
// agent/channels/eve.ts
|
|
@@ -34,23 +38,32 @@ import { localDev } from "eve/channels/auth";
|
|
|
34
38
|
import { arpAuth } from "@kybernesis/identity";
|
|
35
39
|
|
|
36
40
|
export default eveChannel({ auth: [arpAuth(), localDev()] });
|
|
41
|
+
// with @kybernesis/dispatch: dispatchChannel({ ..., extraAuth: [arpAuth(), ...] })
|
|
37
42
|
```
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
Optionally append `ARP_INSTRUCTIONS` to the agent's instructions. Deploy once.
|
|
45
|
+
From here on the owner connects, pairs and revokes in the console; nothing in
|
|
46
|
+
the codebase changes.
|
|
47
|
+
|
|
48
|
+
## How connect works
|
|
49
|
+
|
|
50
|
+
`POST /eve/v1/arp/connect` receives a short-lived ES256 token from ARP Cloud,
|
|
51
|
+
verifies it against `https://gateway.arp.run/.well-known/jwks.json`, checks it
|
|
52
|
+
was minted for this host, redeems it at the gateway for the agent's DID and
|
|
53
|
+
credential, and stores them in `.eve/arp-identity.json` (0600; path override
|
|
54
|
+
`ARP_IDENTITY_FILE`). `arpAuth()` and `arpPeers()` read that file per request,
|
|
55
|
+
so the connect takes effect without a restart.
|
|
56
|
+
|
|
57
|
+
An identity file written for another issuer is never overwritten; the owner
|
|
58
|
+
disconnects there first. Hosts without a writable disk (some serverless
|
|
59
|
+
platforms) keep the environment-variable path: `ARP_ISSUER`, `ARP_AGENT_DID`,
|
|
60
|
+
`ARP_AGENT_CREDENTIAL`, `AGENTID_CHALLENGE` (env always wins over the file).
|
|
40
61
|
|
|
41
62
|
## What each piece does
|
|
42
63
|
|
|
43
|
-
- `
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
(`{ did, challenge }`) and `/eve/v1/arp/health`.
|
|
50
|
-
- `arpPeers()` — on every turn, lists active connections from ARP Cloud and
|
|
51
|
-
exposes `ask_<peer>_agent` tools (the `_agent` suffix keeps them distinct from
|
|
52
|
-
control-plane peers, which are `ask_<peer>`). Pair or revoke in the console; nothing to redeploy.
|
|
53
|
-
- `ARP_INSTRUCTIONS` — append to your instructions so the model uses the tools well.
|
|
54
|
-
|
|
55
|
-
Degrades, never throws at boot: with no credential there are simply no peer
|
|
56
|
-
tools; with no token the auth entry skips to the next one.
|
|
64
|
+
- `identityChannel()` — `/eve/v1/arp/connect`, `/eve/v1/arp/.well-known/agentid-verification` (`{ did, challenge }`) and `/eve/v1/arp/health`.
|
|
65
|
+
- `arpAuth()` — route auth for deliveries: an ES256 token signed by ARP Cloud, verified offline, naming this agent as audience and the paired peer as subject. Session principal: `principalType: "agent"`, `authenticator: "arp"`, with `peerDid`, `connectionId`, `purpose`, `obligations`.
|
|
66
|
+
- `arpPeers()` — on every turn, lists active connections and exposes `ask_<name>_agent` tools (the `_agent` suffix keeps them distinct from control-plane peers, which are `ask_<name>`).
|
|
67
|
+
- `ARP_INSTRUCTIONS` — text to append to your instructions.
|
|
68
|
+
|
|
69
|
+
Degrades, never throws at boot: with no identity there are no peer tools and the auth entry skips to the next one.
|
package/dist/arp-auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createLocalJWKSet, jwtVerify } from "jose";
|
|
2
2
|
import { UnauthenticatedError, extractBearerToken } from "eve/channels/auth";
|
|
3
|
+
import { resolveIdentity } from "./store.js";
|
|
3
4
|
/**
|
|
4
5
|
* Route auth for messages ARP Cloud delivers to this agent.
|
|
5
6
|
*
|
|
@@ -14,14 +15,15 @@ import { UnauthenticatedError, extractBearerToken } from "eve/channels/auth";
|
|
|
14
15
|
* gateway outage is distinguishable from a bad credential.
|
|
15
16
|
*/
|
|
16
17
|
export function arpAuth(options = {}) {
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Identity is resolved per request (options → env → identity file) so a
|
|
19
|
+
// "Connect your agent" that lands mid-life takes effect without a restart.
|
|
20
|
+
const identity = () => resolveIdentity({ ...(options.issuer ? { issuer: options.issuer } : {}), ...(options.agentDid ? { agentDid: options.agentDid } : {}) });
|
|
19
21
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
20
22
|
const ttl = options.jwksTtlMs ?? 5 * 60_000;
|
|
21
23
|
let cached = null;
|
|
22
24
|
// JWKS via global fetch (stubbable, same as the peers module) with a short
|
|
23
25
|
// cache; one refetch when a key id is unknown (rotation).
|
|
24
|
-
async function keySet(force = false) {
|
|
26
|
+
async function keySet(issuer, force = false) {
|
|
25
27
|
if (!force && cached && Date.now() - cached.at < ttl)
|
|
26
28
|
return cached.set;
|
|
27
29
|
const res = await fetchImpl(`${issuer}/.well-known/jwks.json`, { signal: AbortSignal.timeout(6_000) });
|
|
@@ -33,6 +35,7 @@ export function arpAuth(options = {}) {
|
|
|
33
35
|
}
|
|
34
36
|
return async (request) => {
|
|
35
37
|
const token = extractBearerToken(request.headers.get("authorization"));
|
|
38
|
+
const { did: agentDid, issuer } = identity();
|
|
36
39
|
if (!token || !agentDid)
|
|
37
40
|
return null;
|
|
38
41
|
// Only ARP tokens are ours; anything else falls through to the next entry.
|
|
@@ -48,12 +51,12 @@ export function arpAuth(options = {}) {
|
|
|
48
51
|
let payload;
|
|
49
52
|
try {
|
|
50
53
|
try {
|
|
51
|
-
({ payload } = await jwtVerify(token, await keySet(), { issuer, audience: agentDid }));
|
|
54
|
+
({ payload } = await jwtVerify(token, await keySet(issuer), { issuer, audience: agentDid }));
|
|
52
55
|
}
|
|
53
56
|
catch (first) {
|
|
54
57
|
const code = first?.code;
|
|
55
58
|
if (code === "ERR_JWKS_NO_MATCHING_KEY") {
|
|
56
|
-
({ payload } = await jwtVerify(token, await keySet(true), { issuer, audience: agentDid }));
|
|
59
|
+
({ payload } = await jwtVerify(token, await keySet(issuer, true), { issuer, audience: agentDid }));
|
|
57
60
|
}
|
|
58
61
|
else {
|
|
59
62
|
throw first;
|
package/dist/channel.d.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { type ConnectOptions } from "./connect.js";
|
|
2
|
+
export interface IdentityChannelOptions extends ConnectOptions {
|
|
3
|
+
/** This agent's identity, e.g. `did:web:samantha.agent`. Defaults to ARP_AGENT_DID, then the identity file. */
|
|
3
4
|
agentDid?: string;
|
|
4
|
-
/** The challenge ARP Cloud issued when attaching this runtime. Defaults to AGENTID_CHALLENGE. */
|
|
5
|
+
/** The challenge ARP Cloud issued when attaching this runtime. Defaults to AGENTID_CHALLENGE, then the identity file. */
|
|
5
6
|
challenge?: string;
|
|
6
7
|
}
|
|
7
8
|
export declare const IDENTITY_PREFIX = "/eve/v1/arp";
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* This agent's .agent identity surface:
|
|
11
|
+
*
|
|
12
|
+
* POST /eve/v1/arp/connect — "Connect your agent" (ARP Cloud calls it)
|
|
13
|
+
* GET /eve/v1/arp/.well-known/agentid-verification — { did, challenge } ARP Cloud checks
|
|
14
|
+
* GET /eve/v1/arp/health — { ok, did, store }
|
|
15
|
+
*
|
|
16
|
+
* Mount as `agent/channels/arp.ts`. Deliveries from paired agents arrive on
|
|
17
|
+
* the eve channel (add `arpAuth()` there), not here.
|
|
13
18
|
*/
|
|
14
19
|
export declare function identityChannel(options?: IdentityChannelOptions): import("eve/channels").Channel<undefined, Record<string, unknown>, Record<string, unknown>>;
|
package/dist/channel.js
CHANGED
|
@@ -1,26 +1,41 @@
|
|
|
1
|
-
import { defineChannel, GET } from "eve/channels";
|
|
1
|
+
import { defineChannel, GET, POST } from "eve/channels";
|
|
2
|
+
import { handleConnect, requestHostOf } from "./connect.js";
|
|
3
|
+
import { resolveIdentity, storeKind } from "./store.js";
|
|
2
4
|
export const IDENTITY_PREFIX = "/eve/v1/arp";
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* This agent's .agent identity surface:
|
|
7
|
+
*
|
|
8
|
+
* POST /eve/v1/arp/connect — "Connect your agent" (ARP Cloud calls it)
|
|
9
|
+
* GET /eve/v1/arp/.well-known/agentid-verification — { did, challenge } ARP Cloud checks
|
|
10
|
+
* GET /eve/v1/arp/health — { ok, did, store }
|
|
11
|
+
*
|
|
12
|
+
* Mount as `agent/channels/arp.ts`. Deliveries from paired agents arrive on
|
|
13
|
+
* the eve channel (add `arpAuth()` there), not here.
|
|
8
14
|
*/
|
|
9
15
|
export function identityChannel(options = {}) {
|
|
10
16
|
return defineChannel({
|
|
11
|
-
// Deliveries from ARP Cloud arrive on the eve channel (POST /eve/v1/session),
|
|
12
|
-
// not here; this channel only owns the identity's HTTP documents.
|
|
13
17
|
receive: async ({ message, auth }, { from }) => from("arp:inbox").send(message, { auth }),
|
|
14
18
|
routes: [
|
|
19
|
+
POST(IDENTITY_PREFIX + "/connect", async (req) => {
|
|
20
|
+
let body = {};
|
|
21
|
+
try {
|
|
22
|
+
body = (await req.json());
|
|
23
|
+
}
|
|
24
|
+
catch { /* bad json → 400 below */ }
|
|
25
|
+
const { status, body: out } = await handleConnect(body, requestHostOf(req), options);
|
|
26
|
+
return Response.json(out, { status, headers: { "cache-control": "no-store" } });
|
|
27
|
+
}),
|
|
15
28
|
GET(IDENTITY_PREFIX + "/.well-known/agentid-verification", async () => {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return Response.json({ error: "not_configured", hint: "set ARP_AGENT_DID and AGENTID_CHALLENGE" }, { status: 404 });
|
|
29
|
+
const id = resolveIdentity(options);
|
|
30
|
+
if (!id.did || !id.challenge) {
|
|
31
|
+
return Response.json({ error: "not_configured", hint: "connect this agent to its name in the ARP console" }, { status: 404 });
|
|
20
32
|
}
|
|
21
|
-
return Response.json({ did, challenge }, { headers: { "cache-control": "no-store" } });
|
|
33
|
+
return Response.json({ did: id.did, challenge: id.challenge }, { headers: { "cache-control": "no-store" } });
|
|
34
|
+
}),
|
|
35
|
+
GET(IDENTITY_PREFIX + "/health", async () => {
|
|
36
|
+
const id = resolveIdentity(options);
|
|
37
|
+
return Response.json({ ok: true, did: id.did || null, source: id.source, store: storeKind() });
|
|
22
38
|
}),
|
|
23
|
-
GET(IDENTITY_PREFIX + "/health", async () => Response.json({ ok: true, did: options.agentDid ?? process.env.ARP_AGENT_DID ?? null })),
|
|
24
39
|
],
|
|
25
40
|
});
|
|
26
41
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ConnectOptions {
|
|
2
|
+
/** Issuers this runtime accepts connect tokens from. Default: ARP_ISSUER or https://gateway.arp.run. */
|
|
3
|
+
issuers?: string[];
|
|
4
|
+
fetchImpl?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export interface ConnectRequest {
|
|
7
|
+
token?: string;
|
|
8
|
+
issuer?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* "Connect your agent" — the runtime's half.
|
|
12
|
+
*
|
|
13
|
+
* ARP Cloud posts a short-lived ES256 connect token here. We verify it
|
|
14
|
+
* against the issuer's JWKS (offline trust, no shared secret), check it was
|
|
15
|
+
* minted for THIS host, redeem it at `<issuer>/agent-api/bootstrap` for the
|
|
16
|
+
* agent's DID + credential, and keep those in the identity file. From then
|
|
17
|
+
* on `arpAuth()` accepts deliveries and `arpPeers()` can call peers — with
|
|
18
|
+
* no environment variable and no restart.
|
|
19
|
+
*/
|
|
20
|
+
export declare function handleConnect(body: ConnectRequest, requestHost: string | null, options?: ConnectOptions): Promise<{
|
|
21
|
+
status: number;
|
|
22
|
+
body: Record<string, unknown>;
|
|
23
|
+
}>;
|
|
24
|
+
/** Host of the incoming request (proxy-aware), for the audience check. */
|
|
25
|
+
export declare function requestHostOf(req: Request): string | null;
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createLocalJWKSet, jwtVerify } from "jose";
|
|
2
|
+
import { DEFAULT_ISSUER, readIdentityFile, saveIdentity, storeKind } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* "Connect your agent" — the runtime's half.
|
|
5
|
+
*
|
|
6
|
+
* ARP Cloud posts a short-lived ES256 connect token here. We verify it
|
|
7
|
+
* against the issuer's JWKS (offline trust, no shared secret), check it was
|
|
8
|
+
* minted for THIS host, redeem it at `<issuer>/agent-api/bootstrap` for the
|
|
9
|
+
* agent's DID + credential, and keep those in the identity file. From then
|
|
10
|
+
* on `arpAuth()` accepts deliveries and `arpPeers()` can call peers — with
|
|
11
|
+
* no environment variable and no restart.
|
|
12
|
+
*/
|
|
13
|
+
export async function handleConnect(body, requestHost, options = {}) {
|
|
14
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
15
|
+
const allowed = (options.issuers ?? [process.env.ARP_ISSUER ?? DEFAULT_ISSUER]).map((i) => i.replace(/\/+$/, ""));
|
|
16
|
+
const issuer = (body.issuer ?? "").replace(/\/+$/, "");
|
|
17
|
+
const token = body.token ?? "";
|
|
18
|
+
if (!token || !issuer)
|
|
19
|
+
return { status: 400, body: { ok: false, error: "bad_request" } };
|
|
20
|
+
if (!allowed.includes(issuer))
|
|
21
|
+
return { status: 403, body: { ok: false, error: "unknown_issuer" } };
|
|
22
|
+
// First bind wins: an identity file written by another issuer is not
|
|
23
|
+
// overwritten (the owner disconnects there first).
|
|
24
|
+
const bound = readIdentityFile();
|
|
25
|
+
if (bound.did && bound.issuer && bound.issuer.replace(/\/+$/, "") !== issuer) {
|
|
26
|
+
return { status: 409, body: { ok: false, error: "bound_to_another_issuer" } };
|
|
27
|
+
}
|
|
28
|
+
let payload;
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetchImpl(`${issuer}/.well-known/jwks.json`, { signal: AbortSignal.timeout(6_000) });
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
throw new Error(`jwks ${res.status}`);
|
|
33
|
+
const jwks = createLocalJWKSet((await res.json()));
|
|
34
|
+
({ payload } = await jwtVerify(token, jwks, { issuer }));
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return { status: 401, body: { ok: false, error: "invalid_token", detail: err.message } };
|
|
38
|
+
}
|
|
39
|
+
if (payload.kind !== "arp-connect" || typeof payload.did !== "string")
|
|
40
|
+
return { status: 401, body: { ok: false, error: "invalid_token" } };
|
|
41
|
+
// The token names the host it was minted for; refuse one minted for a different origin.
|
|
42
|
+
const aud = Array.isArray(payload.aud) ? payload.aud[0] : payload.aud;
|
|
43
|
+
if (requestHost && aud) {
|
|
44
|
+
let audHost = "";
|
|
45
|
+
try {
|
|
46
|
+
audHost = new URL(aud).host;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
audHost = aud;
|
|
50
|
+
}
|
|
51
|
+
if (audHost.toLowerCase() !== requestHost.toLowerCase())
|
|
52
|
+
return { status: 401, body: { ok: false, error: "audience_mismatch", detail: `token is for ${audHost}` } };
|
|
53
|
+
}
|
|
54
|
+
if (storeKind() === "none" && !process.env.ARP_AGENT_CREDENTIAL) {
|
|
55
|
+
return { status: 503, body: { ok: false, error: "store_unwritable", store: "none" } };
|
|
56
|
+
}
|
|
57
|
+
let cfg;
|
|
58
|
+
let redeem;
|
|
59
|
+
try {
|
|
60
|
+
redeem = await fetchImpl(`${issuer}/agent-api/bootstrap`, { method: "POST", headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10_000) });
|
|
61
|
+
cfg = (await redeem.json().catch(() => ({})));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return { status: 502, body: { ok: false, error: "issuer_unreachable", detail: err.message } };
|
|
65
|
+
}
|
|
66
|
+
if (!redeem.ok || !cfg.did || !cfg.credential || !cfg.challenge) {
|
|
67
|
+
return { status: 502, body: { ok: false, error: cfg.error ?? "bootstrap_failed" } };
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
saveIdentity({ did: cfg.did, issuer: cfg.issuer ?? issuer, credential: cfg.credential, challenge: cfg.challenge });
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { status: 503, body: { ok: false, error: "store_unwritable", store: "none", detail: err.message } };
|
|
74
|
+
}
|
|
75
|
+
return { status: 200, body: { ok: true, did: cfg.did, store: "file" } };
|
|
76
|
+
}
|
|
77
|
+
/** Host of the incoming request (proxy-aware), for the audience check. */
|
|
78
|
+
export function requestHostOf(req) {
|
|
79
|
+
const fwd = req.headers.get("x-forwarded-host");
|
|
80
|
+
if (fwd)
|
|
81
|
+
return fwd.split(",")[0].trim();
|
|
82
|
+
const host = req.headers.get("host");
|
|
83
|
+
if (host)
|
|
84
|
+
return host;
|
|
85
|
+
try {
|
|
86
|
+
return new URL(req.url).host;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { arpAuth, type ArpAuthOptions } from "./arp-auth.js";
|
|
|
2
2
|
export { identityChannel, IDENTITY_PREFIX, type IdentityChannelOptions } from "./channel.js";
|
|
3
3
|
export { arpPeers, discoverPeers, askPeer, toolName, type ArpPeersOptions, type ArpPeer } from "./peers.js";
|
|
4
4
|
export { ARP_INSTRUCTIONS } from "./instructions.js";
|
|
5
|
+
export { resolveIdentity, readIdentityFile, saveIdentity, storeKind, identityFilePath, DEFAULT_ISSUER, type Identity } from "./store.js";
|
|
6
|
+
export { handleConnect, requestHostOf, type ConnectOptions, type ConnectRequest } from "./connect.js";
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,5 @@ export { arpAuth } from "./arp-auth.js";
|
|
|
2
2
|
export { identityChannel, IDENTITY_PREFIX } from "./channel.js";
|
|
3
3
|
export { arpPeers, discoverPeers, askPeer, toolName } from "./peers.js";
|
|
4
4
|
export { ARP_INSTRUCTIONS } from "./instructions.js";
|
|
5
|
+
export { resolveIdentity, readIdentityFile, saveIdentity, storeKind, identityFilePath, DEFAULT_ISSUER } from "./store.js";
|
|
6
|
+
export { handleConnect, requestHostOf } from "./connect.js";
|
package/dist/instructions.js
CHANGED
|
@@ -5,7 +5,7 @@ export const ARP_INSTRUCTIONS = `
|
|
|
5
5
|
## Your .agent identity
|
|
6
6
|
|
|
7
7
|
You have a registered name on the agent network. Other agents that are paired
|
|
8
|
-
with you appear as \`ask_<name>_agent\` tools (the name is their .agent name); use them when a request belongs to that
|
|
8
|
+
with you appear as \`ask_<name>_agent\` tools (the name is their .agent name; a plain \`ask_<name>\` tool, if present, is a different, non-network peer); use them when a request belongs to that
|
|
9
9
|
agent rather than to you. When a message arrives from a paired agent, the
|
|
10
10
|
caller's identity has already been verified and the message has already been
|
|
11
11
|
checked against what its owner allowed — answer it as you would a trusted
|
package/dist/peers.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { defineDynamic, defineTool } from "eve/tools";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { resolveIdentity } from "./store.js";
|
|
3
4
|
const cache = new Map();
|
|
4
5
|
function config(options) {
|
|
6
|
+
// Per call, so a "Connect your agent" takes effect on the next turn.
|
|
7
|
+
const id = resolveIdentity({ ...(options.issuer ? { issuer: options.issuer } : {}), ...(options.credential ? { credential: options.credential } : {}) });
|
|
5
8
|
return {
|
|
6
|
-
issuer:
|
|
7
|
-
credential:
|
|
9
|
+
issuer: id.issuer,
|
|
10
|
+
credential: id.credential,
|
|
8
11
|
cacheMs: options.cacheMs ?? 60_000,
|
|
9
12
|
timeoutMs: options.timeoutMs ?? 120_000,
|
|
10
13
|
fetchImpl: options.fetchImpl ?? globalThis.fetch,
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this runtime keeps its .agent identity.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order, per read: explicit options → environment (`ARP_AGENT_DID`,
|
|
5
|
+
* `ARP_ISSUER`, `ARP_AGENT_CREDENTIAL`, `AGENTID_CHALLENGE`) → the identity
|
|
6
|
+
* file. The file is what "Connect your agent" writes, so an owner never
|
|
7
|
+
* touches environment variables; env stays for hosts without a writable disk.
|
|
8
|
+
*/
|
|
9
|
+
export interface Identity {
|
|
10
|
+
did: string;
|
|
11
|
+
issuer: string;
|
|
12
|
+
credential: string;
|
|
13
|
+
challenge: string;
|
|
14
|
+
/** Where the identity came from. */
|
|
15
|
+
source: "options" | "env" | "file" | "none";
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_ISSUER = "https://gateway.arp.run";
|
|
18
|
+
export declare function identityFilePath(): string;
|
|
19
|
+
/** The identity file as written by "Connect your agent" ({} when absent). */
|
|
20
|
+
export declare function readIdentityFile(): Partial<Identity>;
|
|
21
|
+
/** Current identity. Never throws; missing pieces are empty strings. */
|
|
22
|
+
export declare function resolveIdentity(options?: {
|
|
23
|
+
agentDid?: string;
|
|
24
|
+
issuer?: string;
|
|
25
|
+
credential?: string;
|
|
26
|
+
challenge?: string;
|
|
27
|
+
}): Identity;
|
|
28
|
+
/** Persist a connected identity. Throws when the store is not writable. */
|
|
29
|
+
export declare function saveIdentity(identity: {
|
|
30
|
+
did: string;
|
|
31
|
+
issuer: string;
|
|
32
|
+
credential: string;
|
|
33
|
+
challenge: string;
|
|
34
|
+
}): string;
|
|
35
|
+
/** Can this host keep an identity on disk? */
|
|
36
|
+
export declare function storeKind(): "file" | "none";
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
export const DEFAULT_ISSUER = "https://gateway.arp.run";
|
|
4
|
+
export function identityFilePath() {
|
|
5
|
+
return process.env.ARP_IDENTITY_FILE ?? join(process.cwd(), ".eve", "arp-identity.json");
|
|
6
|
+
}
|
|
7
|
+
let cache = null;
|
|
8
|
+
/** The identity file as written by "Connect your agent" ({} when absent). */
|
|
9
|
+
export function readIdentityFile() {
|
|
10
|
+
const path = identityFilePath();
|
|
11
|
+
try {
|
|
12
|
+
const mtimeMs = statSync(path).mtimeMs;
|
|
13
|
+
if (cache && cache.path === path && cache.mtimeMs === mtimeMs)
|
|
14
|
+
return cache.value;
|
|
15
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
16
|
+
cache = { path, mtimeMs, value };
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
cache = null;
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Current identity. Never throws; missing pieces are empty strings. */
|
|
25
|
+
export function resolveIdentity(options = {}) {
|
|
26
|
+
const env = {
|
|
27
|
+
did: process.env.ARP_AGENT_DID ?? "",
|
|
28
|
+
issuer: process.env.ARP_ISSUER ?? "",
|
|
29
|
+
credential: process.env.ARP_AGENT_CREDENTIAL ?? "",
|
|
30
|
+
challenge: process.env.AGENTID_CHALLENGE ?? "",
|
|
31
|
+
};
|
|
32
|
+
const file = readIdentityFile();
|
|
33
|
+
const pick = (opt, e, f) => opt ? [opt, "options"] : e ? [e, "env"] : f ? [f, "file"] : ["", "none"];
|
|
34
|
+
const [did, didSource] = pick(options.agentDid, env.did, file.did);
|
|
35
|
+
const [issuer] = pick(options.issuer, env.issuer, file.issuer);
|
|
36
|
+
const [credential] = pick(options.credential, env.credential, file.credential);
|
|
37
|
+
const [challenge] = pick(options.challenge, env.challenge, file.challenge);
|
|
38
|
+
return { did, issuer: (issuer || DEFAULT_ISSUER).replace(/\/+$/, ""), credential, challenge, source: didSource };
|
|
39
|
+
}
|
|
40
|
+
/** Persist a connected identity. Throws when the store is not writable. */
|
|
41
|
+
export function saveIdentity(identity) {
|
|
42
|
+
const path = identityFilePath();
|
|
43
|
+
const dir = dirname(path);
|
|
44
|
+
if (!existsSync(dir))
|
|
45
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
writeFileSync(path, JSON.stringify({ ...identity, connected_at: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
|
|
47
|
+
cache = null;
|
|
48
|
+
return path;
|
|
49
|
+
}
|
|
50
|
+
/** Can this host keep an identity on disk? */
|
|
51
|
+
export function storeKind() {
|
|
52
|
+
const path = identityFilePath();
|
|
53
|
+
try {
|
|
54
|
+
const dir = dirname(path);
|
|
55
|
+
if (!existsSync(dir))
|
|
56
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
const probe = join(dir, `.arp-probe-${process.pid}`);
|
|
58
|
+
writeFileSync(probe, "", { mode: 0o600 });
|
|
59
|
+
try {
|
|
60
|
+
unlinkSync(probe);
|
|
61
|
+
}
|
|
62
|
+
catch { /* ignore */ }
|
|
63
|
+
return "file";
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return "none";
|
|
67
|
+
}
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Give an eve agent its .agent identity: verify ARP Cloud push tokens, serve the verification document, and reach paired peers as tools \u2014 no daemon, no socket, no key in the bundle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|