@ziggs-ai/api-client 0.1.29 → 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 +5 -19
- package/dist/ConnectionManager.d.ts +24 -5
- package/dist/ConnectionManager.js +58 -14
- package/dist/http/ArtifactsClient.d.ts +21 -1
- package/dist/http/ArtifactsClient.js +38 -6
- package/dist/http/ConnectionsClient.d.ts +89 -0
- package/dist/http/ConnectionsClient.js +154 -0
- package/dist/http/ContextGrantsClient.d.ts +27 -0
- package/dist/http/ContextGrantsClient.js +25 -0
- package/dist/http/InboxClient.d.ts +35 -1
- package/dist/http/InboxClient.js +25 -2
- package/dist/http/PaymentsClient.d.ts +94 -0
- package/dist/http/PaymentsClient.js +231 -0
- package/dist/http/TaskClient.d.ts +8 -0
- package/dist/http/grantRails.d.ts +20 -0
- package/dist/http/grantRails.js +50 -0
- package/dist/http/index.d.ts +7 -1
- package/dist/http/index.js +3 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/package.json +1 -1
- package/dist/websocket/ControlSocket.d.ts +0 -13
- package/dist/websocket/ControlSocket.js +0 -37
- package/dist/websocket/WebSocketClient.d.ts +0 -86
- package/dist/websocket/WebSocketClient.js +0 -268
- package/dist/websocket/index.d.ts +0 -1
- package/dist/websocket/index.js +0 -1
package/README.md
CHANGED
|
@@ -10,25 +10,11 @@ npm install @ziggs-ai/api-client
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import { WebSocketClient } from '@ziggs-ai/api-client';
|
|
19
|
-
|
|
20
|
-
const client = new WebSocketClient({
|
|
21
|
-
operatorKey: process.env.ZIGGS_OPERATOR_KEY,
|
|
22
|
-
agentId: 'my-agent-id',
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
client.setMessageHandler(async (text, metadata) => {
|
|
26
|
-
console.log('Received:', text);
|
|
27
|
-
console.log('From:', metadata.senderId);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
client.connect();
|
|
31
|
-
```
|
|
13
|
+
Delivery is a stateless HTTP pull — there is no WebSocket client. An agent
|
|
14
|
+
receives work by long-polling its inbox: `GET /inbox?wait=25` (held open until
|
|
15
|
+
something passes the caller's cursor) → act → `POST /inbox/ack`. Because the
|
|
16
|
+
cursor lives server-side, any HTTP client following this loop is both correct
|
|
17
|
+
and live. See `docs/unified-inbox-wake-queue.md` for the reference loop.
|
|
32
18
|
|
|
33
19
|
### HTTP Clients
|
|
34
20
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type ControlSocketOptions } from './websocket/ControlSocket.js';
|
|
2
1
|
type OpenFn = () => Promise<unknown>;
|
|
3
2
|
type CloseFn = (handle: unknown) => Promise<void>;
|
|
4
3
|
export interface ConnectionManagerMeta {
|
|
@@ -10,7 +9,18 @@ export interface ConnectionManagerMeta {
|
|
|
10
9
|
export interface ConnectionManagerOptions {
|
|
11
10
|
maxActive?: number;
|
|
12
11
|
idleTimeoutMs?: number;
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Fleet control (delivery law, phase 4): the operator key whose owned agents
|
|
14
|
+
* this manager lazily starts. `start()` long-polls `GET /inbox/operator` with
|
|
15
|
+
* this key; when an agent's slice shows actionable content, it starts that
|
|
16
|
+
* agent's host (which then consumes via its own per-agent inbox loop). No
|
|
17
|
+
* socket, no wake push — lazy process management driven by the poll. `wsUrl`
|
|
18
|
+
* is accepted for back-compat but unused (the poll is HTTP).
|
|
19
|
+
*/
|
|
20
|
+
control?: {
|
|
21
|
+
wsUrl?: string;
|
|
22
|
+
operatorKey?: string;
|
|
23
|
+
};
|
|
14
24
|
}
|
|
15
25
|
export interface QueryFilter {
|
|
16
26
|
domain?: string;
|
|
@@ -20,16 +30,25 @@ export interface QueryFilter {
|
|
|
20
30
|
export declare class ConnectionManager {
|
|
21
31
|
private maxActive;
|
|
22
32
|
private idleTimeoutMs;
|
|
23
|
-
private
|
|
24
|
-
private
|
|
33
|
+
private _operatorKey;
|
|
34
|
+
private _polling;
|
|
35
|
+
private _pollDone;
|
|
25
36
|
private _entries;
|
|
26
37
|
private _active;
|
|
27
38
|
private _meta;
|
|
28
39
|
private _waking;
|
|
29
40
|
constructor({ maxActive, idleTimeoutMs, control }?: ConnectionManagerOptions);
|
|
30
41
|
register(id: string, openFn: OpenFn, closeFn: CloseFn, meta?: ConnectionManagerMeta): void;
|
|
31
|
-
start(): void;
|
|
42
|
+
start(operatorKey?: string): void;
|
|
32
43
|
stop(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Fleet lazy-start loop: long-poll the operator inbox and start a host for any
|
|
46
|
+
* owned agent whose slice is actionable and not already running. The started
|
|
47
|
+
* host consumes + acks via its own per-agent inbox loop, so this loop never
|
|
48
|
+
* acks — it only decides which hosts to run. Errors retry with backoff+jitter;
|
|
49
|
+
* the loop never exits until `stop()` (nothing to resynchronize).
|
|
50
|
+
*/
|
|
51
|
+
private _runOperatorPoll;
|
|
33
52
|
wake(id: string): Promise<unknown>;
|
|
34
53
|
sleep(id: string): Promise<void>;
|
|
35
54
|
sleepAll(): Promise<void>;
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { InboxClient } from './http/InboxClient.js';
|
|
2
2
|
import { runtimeLog } from './shared/runtimeLog.js';
|
|
3
|
+
const OPERATOR_POLL_WAIT_SECONDS = 25;
|
|
4
|
+
const POLL_BACKOFF_BASE_MS = 1_000;
|
|
5
|
+
const POLL_BACKOFF_MAX_MS = 30_000;
|
|
6
|
+
function sleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
3
9
|
export class ConnectionManager {
|
|
4
10
|
maxActive;
|
|
5
11
|
idleTimeoutMs;
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
_operatorKey;
|
|
13
|
+
_polling;
|
|
14
|
+
_pollDone;
|
|
8
15
|
_entries;
|
|
9
16
|
_active;
|
|
10
17
|
_meta;
|
|
@@ -12,8 +19,9 @@ export class ConnectionManager {
|
|
|
12
19
|
constructor({ maxActive = 50, idleTimeoutMs = 60_000, control } = {}) {
|
|
13
20
|
this.maxActive = maxActive;
|
|
14
21
|
this.idleTimeoutMs = idleTimeoutMs;
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
22
|
+
this._operatorKey = control?.operatorKey ?? null;
|
|
23
|
+
this._polling = false;
|
|
24
|
+
this._pollDone = null;
|
|
17
25
|
this._entries = new Map();
|
|
18
26
|
this._active = new Map();
|
|
19
27
|
this._meta = new Map();
|
|
@@ -30,20 +38,56 @@ export class ConnectionManager {
|
|
|
30
38
|
if (meta)
|
|
31
39
|
this._meta.set(id, meta);
|
|
32
40
|
}
|
|
33
|
-
start() {
|
|
34
|
-
if (
|
|
41
|
+
start(operatorKey) {
|
|
42
|
+
if (operatorKey)
|
|
43
|
+
this._operatorKey = operatorKey;
|
|
44
|
+
if (this._polling || !this._operatorKey)
|
|
35
45
|
return;
|
|
36
|
-
this.
|
|
37
|
-
|
|
38
|
-
agentIds: () => this.list(),
|
|
39
|
-
onWake: (id) => this.wake(id).catch(err => runtimeLog.warn('ConnectionManager', `wake("${id}") failed: ${err.message}`)),
|
|
40
|
-
});
|
|
46
|
+
this._polling = true;
|
|
47
|
+
this._pollDone = this._runOperatorPoll(this._operatorKey);
|
|
41
48
|
}
|
|
42
49
|
async stop() {
|
|
43
|
-
this.
|
|
44
|
-
this.
|
|
50
|
+
this._polling = false;
|
|
51
|
+
if (this._pollDone)
|
|
52
|
+
await this._pollDone.catch(() => { });
|
|
53
|
+
this._pollDone = null;
|
|
45
54
|
await this.sleepAll();
|
|
46
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Fleet lazy-start loop: long-poll the operator inbox and start a host for any
|
|
58
|
+
* owned agent whose slice is actionable and not already running. The started
|
|
59
|
+
* host consumes + acks via its own per-agent inbox loop, so this loop never
|
|
60
|
+
* acks — it only decides which hosts to run. Errors retry with backoff+jitter;
|
|
61
|
+
* the loop never exits until `stop()` (nothing to resynchronize).
|
|
62
|
+
*/
|
|
63
|
+
async _runOperatorPoll(operatorKey) {
|
|
64
|
+
// No agentId → operator-level multiplex read (transport auth only).
|
|
65
|
+
const inbox = new InboxClient(operatorKey);
|
|
66
|
+
let consecutiveErrors = 0;
|
|
67
|
+
while (this._polling) {
|
|
68
|
+
try {
|
|
69
|
+
const env = await inbox.getOperatorInbox({ waitSeconds: OPERATOR_POLL_WAIT_SECONDS });
|
|
70
|
+
consecutiveErrors = 0;
|
|
71
|
+
for (const entry of env.agents) {
|
|
72
|
+
const id = entry.agentId;
|
|
73
|
+
// Only slices with actionable content are listed; start the host if
|
|
74
|
+
// it isn't already running and we know how to open it.
|
|
75
|
+
if (!id || this._active.has(id) || !this._entries.has(id))
|
|
76
|
+
continue;
|
|
77
|
+
this.wake(id).catch((err) => runtimeLog.warn('ConnectionManager', `lazy-start("${id}") failed: ${err.message}`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (!this._polling)
|
|
82
|
+
break;
|
|
83
|
+
consecutiveErrors++;
|
|
84
|
+
const backoff = Math.min(POLL_BACKOFF_MAX_MS, POLL_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
|
|
85
|
+
const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14);
|
|
86
|
+
runtimeLog.warn('ConnectionManager', `operator poll failed (#${consecutiveErrors}): ${err.message} — retrying in ~${Math.round(jitter)}ms`);
|
|
87
|
+
await sleep(jitter);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
47
91
|
async wake(id) {
|
|
48
92
|
const existing = this._active.get(id);
|
|
49
93
|
if (existing) {
|
|
@@ -23,6 +23,13 @@ export interface WriteArtifactInput {
|
|
|
23
23
|
/** Optional task binding — creates a TaskArtifactLink alongside the primary scope link. */
|
|
24
24
|
taskId?: string;
|
|
25
25
|
service?: Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* Optional idempotency key. A redelivered write with the same key (same
|
|
28
|
+
* sender) no-ops server-side and returns the original artifact. Derive it
|
|
29
|
+
* deterministically from durable inputs so a stateless crash-replay
|
|
30
|
+
* reproduces it.
|
|
31
|
+
*/
|
|
32
|
+
idempotencyKey?: string;
|
|
26
33
|
}
|
|
27
34
|
/**
|
|
28
35
|
* Replaces `ContextReader`'s artifact reads + `ContextWriter`'s breadcrumb
|
|
@@ -47,7 +54,20 @@ export declare class ArtifactsClient {
|
|
|
47
54
|
* endpoint is updated to accept `visibility`, this falls back to logging
|
|
48
55
|
* locally (operators can still wire their own sink).
|
|
49
56
|
*/
|
|
50
|
-
recordThought(chatId: string, text: string
|
|
57
|
+
recordThought(chatId: string, text: string, opts?: {
|
|
58
|
+
idempotencyKey?: string;
|
|
59
|
+
}): Promise<void>;
|
|
51
60
|
write(input: WriteArtifactInput): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* ZIG-899 — the deliberate-record variant: a deliverable the model chose to
|
|
63
|
+
* record must fail loudly and hand back the artifactId, unlike `write`'s
|
|
64
|
+
* soft-fail breadcrumb contract. This is the one wire call for both agent
|
|
65
|
+
* surfaces (the SDK record_artifact tool and ziggs-mcp's
|
|
66
|
+
* ziggs_record_artifact).
|
|
67
|
+
*/
|
|
68
|
+
writeStrict(input: WriteArtifactInput): Promise<{
|
|
69
|
+
artifactId?: string;
|
|
70
|
+
}>;
|
|
71
|
+
private _assertScopeXor;
|
|
52
72
|
private _headers;
|
|
53
73
|
}
|
|
@@ -49,20 +49,40 @@ export class ArtifactsClient {
|
|
|
49
49
|
* endpoint is updated to accept `visibility`, this falls back to logging
|
|
50
50
|
* locally (operators can still wire their own sink).
|
|
51
51
|
*/
|
|
52
|
-
async recordThought(chatId, text) {
|
|
52
|
+
async recordThought(chatId, text, opts = {}) {
|
|
53
53
|
return this.write({
|
|
54
54
|
chatId,
|
|
55
55
|
text,
|
|
56
56
|
content_type: 'thought',
|
|
57
57
|
visibility: 'agent-private',
|
|
58
|
+
idempotencyKey: opts.idempotencyKey,
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
async write(input) {
|
|
61
62
|
if (!input.text || !input.text.trim())
|
|
62
63
|
return;
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
this._assertScopeXor(input);
|
|
65
|
+
try {
|
|
66
|
+
await this.writeStrict(input);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
// Soft-fail on the wire only: breadcrumb writes are not load-bearing.
|
|
70
|
+
// Caller mistakes (scope xor, empty text) still throw above.
|
|
71
|
+
runtimeLog.warn('ArtifactsClient', `⚠️ ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* ZIG-899 — the deliberate-record variant: a deliverable the model chose to
|
|
76
|
+
* record must fail loudly and hand back the artifactId, unlike `write`'s
|
|
77
|
+
* soft-fail breadcrumb contract. This is the one wire call for both agent
|
|
78
|
+
* surfaces (the SDK record_artifact tool and ziggs-mcp's
|
|
79
|
+
* ziggs_record_artifact).
|
|
80
|
+
*/
|
|
81
|
+
async writeStrict(input) {
|
|
82
|
+
if (!input.text || !input.text.trim()) {
|
|
83
|
+
throw new Error('ArtifactsClient.writeStrict: text is required');
|
|
65
84
|
}
|
|
85
|
+
this._assertScopeXor(input);
|
|
66
86
|
const url = `${getBackendUrl()}/artifacts`;
|
|
67
87
|
const res = await fetch(url, {
|
|
68
88
|
method: 'POST',
|
|
@@ -75,12 +95,24 @@ export class ArtifactsClient {
|
|
|
75
95
|
agreementId: input.agreementId,
|
|
76
96
|
taskId: input.taskId,
|
|
77
97
|
service: input.service,
|
|
98
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
78
99
|
}),
|
|
79
100
|
});
|
|
101
|
+
const body = await res.text().catch(() => '');
|
|
80
102
|
if (!res.ok) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
throw new Error(`POST /artifacts ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
107
|
+
return { artifactId: parsed.artifactId };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
_assertScopeXor(input) {
|
|
114
|
+
if ((input.chatId && input.agreementId) || (!input.chatId && !input.agreementId)) {
|
|
115
|
+
throw new Error('ArtifactsClient.write: pass exactly one of chatId or agreementId');
|
|
84
116
|
}
|
|
85
117
|
}
|
|
86
118
|
_headers() {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
export declare function assertNoLeakedConnectionSecret(serialized: string): void;
|
|
3
|
+
/** Thrown by ConnectionsClient with the HTTP status and raw body attached. */
|
|
4
|
+
export interface ConnectionsError extends Error {
|
|
5
|
+
status?: number;
|
|
6
|
+
body?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ConnectionProxyParams {
|
|
9
|
+
connectionId: string;
|
|
10
|
+
grantId: string;
|
|
11
|
+
action: string;
|
|
12
|
+
payload?: unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface McpConnectionRequestParams {
|
|
15
|
+
serverUrl: string;
|
|
16
|
+
tools: string[];
|
|
17
|
+
reason?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Working chat the consent card is opened into. Required by the backend for
|
|
20
|
+
* the agent-tool path; the per-user gateway trigger (ZIG-771) omits it.
|
|
21
|
+
*/
|
|
22
|
+
chatId?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Target the consent request at this end-user (X-On-Behalf-Of-User) instead
|
|
25
|
+
* of the operator — the per-user MCP gateway's connect prompt (ZIG-771).
|
|
26
|
+
*/
|
|
27
|
+
onBehalfOfUserId?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* ZIG-894 — the one connections client for every surface. Consolidates the
|
|
31
|
+
* former agent-sdk private ZiggsConnectClient and ziggs-mcp's inline fetches
|
|
32
|
+
* (proxyConnection, createMcpConnectionRequest). Proxied provider calls never
|
|
33
|
+
* return the raw OAuth token; every proxy response is additionally scanned by
|
|
34
|
+
* the client-side leak guard before it reaches the caller. Grant *listing*
|
|
35
|
+
* across connections is the unified GET /grants; the per-connection grant
|
|
36
|
+
* mutations (issue / attenuate / revoke) live here.
|
|
37
|
+
*/
|
|
38
|
+
export declare class ConnectionsClient {
|
|
39
|
+
private readonly operatorKey;
|
|
40
|
+
private readonly agentId?;
|
|
41
|
+
private readonly baseUrl;
|
|
42
|
+
constructor(operatorKey: string, agentId?: string, baseUrl?: string);
|
|
43
|
+
/**
|
|
44
|
+
* Present a ConnectionGrant to the broker and perform a provider action.
|
|
45
|
+
* Returns the provider result — never the raw OAuth token. Requires an
|
|
46
|
+
* impersonated agent (the grant holder).
|
|
47
|
+
*/
|
|
48
|
+
proxy({ connectionId, grantId, action, payload }: ConnectionProxyParams): Promise<unknown>;
|
|
49
|
+
/**
|
|
50
|
+
* List ConnectionGrants on a connection. When impersonating an agent the
|
|
51
|
+
* server already scopes rows to that holder; the client-side filter is kept
|
|
52
|
+
* as defense-in-depth (same behavior as the former ZiggsConnectClient).
|
|
53
|
+
*/
|
|
54
|
+
listGrants({ connectionId }: {
|
|
55
|
+
connectionId: string;
|
|
56
|
+
}): Promise<unknown[]>;
|
|
57
|
+
/** Issue a connection grant to an agent holder (connection owner side). */
|
|
58
|
+
issueGrant({ connectionId, holderId, caveats, }: {
|
|
59
|
+
connectionId: string;
|
|
60
|
+
holderId: string;
|
|
61
|
+
caveats?: unknown;
|
|
62
|
+
}): Promise<unknown>;
|
|
63
|
+
/**
|
|
64
|
+
* Attenuate (re-delegate) a connection grant with tighter caveats. Narrowing
|
|
65
|
+
* a grant whose connection you own mints immediately; a grant whose owner is
|
|
66
|
+
* a different party returns `{ status: 'pending_approval', agreementId }`
|
|
67
|
+
* (ZIG-701 consent flow) and no grant is minted yet.
|
|
68
|
+
*/
|
|
69
|
+
attenuateGrant({ connectionId, grantId, holderId, caveats, }: {
|
|
70
|
+
connectionId: string;
|
|
71
|
+
grantId: string;
|
|
72
|
+
holderId: string;
|
|
73
|
+
caveats?: unknown;
|
|
74
|
+
}): Promise<unknown>;
|
|
75
|
+
/** Revoke a single connection grant. */
|
|
76
|
+
revokeGrant({ connectionId, grantId, }: {
|
|
77
|
+
connectionId: string;
|
|
78
|
+
grantId: string;
|
|
79
|
+
}): Promise<unknown>;
|
|
80
|
+
/**
|
|
81
|
+
* ZIG-686 — agent-initiated MCP connection request: ask the principal to
|
|
82
|
+
* connect a remote MCP server and grant this agent the listed tools. Opens a
|
|
83
|
+
* connection-consent agreement in the working chat (ZIG-798); on approval the
|
|
84
|
+
* server is connected (if needed) and the agent is granted the tools.
|
|
85
|
+
*/
|
|
86
|
+
requestMcpConnection({ serverUrl, tools, reason, chatId, onBehalfOfUserId, }: McpConnectionRequestParams): Promise<Record<string, unknown>>;
|
|
87
|
+
private _requestRaw;
|
|
88
|
+
private _request;
|
|
89
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { getBackendUrl } from '../utils/urlUtils.js';
|
|
3
|
+
import { buildOperatorHeaders } from './operatorHeaders.js';
|
|
4
|
+
// ZIG-569 — defense-in-depth mirror of the backend leak-guard
|
|
5
|
+
// (assertProxyResponseDoesNotLeakTokens). The backend strips the *specific*
|
|
6
|
+
// vault token from the response; clients never see that token, so this layer
|
|
7
|
+
// instead pattern-scans the proxied body for high-signal provider credential
|
|
8
|
+
// shapes and refuses to hand a likely-leaked secret to the model.
|
|
9
|
+
const LEAKED_SECRET_PATTERNS = [
|
|
10
|
+
/\bgh[posru]_[A-Za-z0-9]{16,}\b/, // GitHub PAT / OAuth / user / server / refresh
|
|
11
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/, // fine-grained GitHub PAT
|
|
12
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, // Slack bot/user/app/refresh tokens
|
|
13
|
+
/\bxapp-[A-Za-z0-9-]{10,}\b/, // Slack app-level token
|
|
14
|
+
/"(?:access_token|refresh_token)"\s*:\s*"[^"]{8,}"/, // raw OAuth token JSON keys
|
|
15
|
+
];
|
|
16
|
+
export function assertNoLeakedConnectionSecret(serialized) {
|
|
17
|
+
for (const re of LEAKED_SECRET_PATTERNS) {
|
|
18
|
+
if (re.test(serialized)) {
|
|
19
|
+
throw new Error('connection proxy response withheld: it appears to contain a credential (token-leak guard)');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* ZIG-894 — the one connections client for every surface. Consolidates the
|
|
25
|
+
* former agent-sdk private ZiggsConnectClient and ziggs-mcp's inline fetches
|
|
26
|
+
* (proxyConnection, createMcpConnectionRequest). Proxied provider calls never
|
|
27
|
+
* return the raw OAuth token; every proxy response is additionally scanned by
|
|
28
|
+
* the client-side leak guard before it reaches the caller. Grant *listing*
|
|
29
|
+
* across connections is the unified GET /grants; the per-connection grant
|
|
30
|
+
* mutations (issue / attenuate / revoke) live here.
|
|
31
|
+
*/
|
|
32
|
+
export class ConnectionsClient {
|
|
33
|
+
operatorKey;
|
|
34
|
+
agentId;
|
|
35
|
+
baseUrl;
|
|
36
|
+
constructor(operatorKey, agentId, baseUrl) {
|
|
37
|
+
if (!operatorKey)
|
|
38
|
+
throw new Error('ConnectionsClient: operatorKey is required');
|
|
39
|
+
this.operatorKey = operatorKey;
|
|
40
|
+
this.agentId = agentId;
|
|
41
|
+
this.baseUrl = baseUrl || getBackendUrl();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Present a ConnectionGrant to the broker and perform a provider action.
|
|
45
|
+
* Returns the provider result — never the raw OAuth token. Requires an
|
|
46
|
+
* impersonated agent (the grant holder).
|
|
47
|
+
*/
|
|
48
|
+
async proxy({ connectionId, grantId, action, payload }) {
|
|
49
|
+
if (!connectionId)
|
|
50
|
+
throw new Error('proxy: connectionId is required');
|
|
51
|
+
if (!grantId)
|
|
52
|
+
throw new Error('proxy: grantId is required');
|
|
53
|
+
if (!action)
|
|
54
|
+
throw new Error('proxy: action is required');
|
|
55
|
+
if (!this.agentId) {
|
|
56
|
+
throw new Error('proxy: agentId is required — connection broker calls must impersonate the grant holder agent');
|
|
57
|
+
}
|
|
58
|
+
const text = await this._requestRaw('POST', `/connections/${encodeURIComponent(connectionId)}/proxy`, { grantId, action, payload: payload ?? {} });
|
|
59
|
+
assertNoLeakedConnectionSecret(text);
|
|
60
|
+
let parsed = text;
|
|
61
|
+
try {
|
|
62
|
+
parsed = text ? JSON.parse(text) : null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
parsed = text;
|
|
66
|
+
}
|
|
67
|
+
const result = parsed?.['result'];
|
|
68
|
+
return result ?? parsed;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* List ConnectionGrants on a connection. When impersonating an agent the
|
|
72
|
+
* server already scopes rows to that holder; the client-side filter is kept
|
|
73
|
+
* as defense-in-depth (same behavior as the former ZiggsConnectClient).
|
|
74
|
+
*/
|
|
75
|
+
async listGrants({ connectionId }) {
|
|
76
|
+
if (!connectionId)
|
|
77
|
+
throw new Error('listGrants: connectionId is required');
|
|
78
|
+
if (!this.agentId) {
|
|
79
|
+
throw new Error('listGrants: agentId is required — grants are scoped to the impersonated agent');
|
|
80
|
+
}
|
|
81
|
+
const res = (await this._request('GET', `/connections/${encodeURIComponent(connectionId)}/grants`, undefined));
|
|
82
|
+
const grants = res['grants'] || [];
|
|
83
|
+
return grants.filter((g) => g['holderId'] === this.agentId);
|
|
84
|
+
}
|
|
85
|
+
/** Issue a connection grant to an agent holder (connection owner side). */
|
|
86
|
+
async issueGrant({ connectionId, holderId, caveats, }) {
|
|
87
|
+
if (!connectionId)
|
|
88
|
+
throw new Error('issueGrant: connectionId is required');
|
|
89
|
+
if (!holderId)
|
|
90
|
+
throw new Error('issueGrant: holderId is required');
|
|
91
|
+
return this._request('POST', `/connections/${encodeURIComponent(connectionId)}/grants`, {
|
|
92
|
+
holderId,
|
|
93
|
+
caveats,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Attenuate (re-delegate) a connection grant with tighter caveats. Narrowing
|
|
98
|
+
* a grant whose connection you own mints immediately; a grant whose owner is
|
|
99
|
+
* a different party returns `{ status: 'pending_approval', agreementId }`
|
|
100
|
+
* (ZIG-701 consent flow) and no grant is minted yet.
|
|
101
|
+
*/
|
|
102
|
+
async attenuateGrant({ connectionId, grantId, holderId, caveats, }) {
|
|
103
|
+
if (!connectionId)
|
|
104
|
+
throw new Error('attenuateGrant: connectionId is required');
|
|
105
|
+
if (!grantId)
|
|
106
|
+
throw new Error('attenuateGrant: grantId is required');
|
|
107
|
+
if (!holderId)
|
|
108
|
+
throw new Error('attenuateGrant: holderId is required');
|
|
109
|
+
return this._request('POST', `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}/attenuate`, { holderId, caveats });
|
|
110
|
+
}
|
|
111
|
+
/** Revoke a single connection grant. */
|
|
112
|
+
async revokeGrant({ connectionId, grantId, }) {
|
|
113
|
+
if (!connectionId)
|
|
114
|
+
throw new Error('revokeGrant: connectionId is required');
|
|
115
|
+
if (!grantId)
|
|
116
|
+
throw new Error('revokeGrant: grantId is required');
|
|
117
|
+
return this._request('DELETE', `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}`, undefined);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* ZIG-686 — agent-initiated MCP connection request: ask the principal to
|
|
121
|
+
* connect a remote MCP server and grant this agent the listed tools. Opens a
|
|
122
|
+
* connection-consent agreement in the working chat (ZIG-798); on approval the
|
|
123
|
+
* server is connected (if needed) and the agent is granted the tools.
|
|
124
|
+
*/
|
|
125
|
+
async requestMcpConnection({ serverUrl, tools, reason, chatId, onBehalfOfUserId, }) {
|
|
126
|
+
if (!serverUrl)
|
|
127
|
+
throw new Error('requestMcpConnection: serverUrl is required');
|
|
128
|
+
return (await this._request('POST', '/connections/mcp/requests', { serverUrl, tools, reason, chatId }, onBehalfOfUserId ? { 'X-On-Behalf-Of-User': onBehalfOfUserId } : undefined));
|
|
129
|
+
}
|
|
130
|
+
async _requestRaw(method, path, body, extraHeaders) {
|
|
131
|
+
const init = {
|
|
132
|
+
method,
|
|
133
|
+
headers: buildOperatorHeaders(this.operatorKey, this.agentId, {
|
|
134
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
135
|
+
...extraHeaders,
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
if (body !== undefined)
|
|
139
|
+
init.body = JSON.stringify(body);
|
|
140
|
+
const response = await fetch(`${this.baseUrl}${path}`, init);
|
|
141
|
+
const text = await response.text().catch(() => '');
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const err = new Error(`${method} ${path} ${response.status} ${text.slice(0, 200)}`);
|
|
144
|
+
err.status = response.status;
|
|
145
|
+
err.body = text;
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
async _request(method, path, body, extraHeaders) {
|
|
151
|
+
const text = await this._requestRaw(method, path, body, extraHeaders);
|
|
152
|
+
return text ? JSON.parse(text) : null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -39,6 +39,26 @@ export type DelegateContextGrantResult = {
|
|
|
39
39
|
agreementId: string;
|
|
40
40
|
ownerId?: string;
|
|
41
41
|
};
|
|
42
|
+
/** A single readable entry inside a grant's scope — id + label only (ZIG-870). */
|
|
43
|
+
export interface ReachEntry {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* ZIG-870 reach expansion — the chat/agreement ids a grant you hold actually
|
|
49
|
+
* covers, so an org/agreement-scoped grant becomes a concrete list you can
|
|
50
|
+
* `context_read` through (via=chat:<id> / agreement:<id>). Ids + labels only,
|
|
51
|
+
* never content. Org scope is capped; `truncatedChats`/`truncatedAgreements`
|
|
52
|
+
* report how many were left off, never silently dropped.
|
|
53
|
+
*/
|
|
54
|
+
export interface GrantReachResult {
|
|
55
|
+
grantId: string;
|
|
56
|
+
scope: ContextGrantScope;
|
|
57
|
+
chats: ReachEntry[];
|
|
58
|
+
agreements: ReachEntry[];
|
|
59
|
+
truncatedChats: number;
|
|
60
|
+
truncatedAgreements: number;
|
|
61
|
+
}
|
|
42
62
|
/**
|
|
43
63
|
* ZIG-411 context grant management — list / issue / delegate / revoke.
|
|
44
64
|
*/
|
|
@@ -53,6 +73,13 @@ export declare class ContextGrantsClient {
|
|
|
53
73
|
constructor(operatorKey: string, agentId?: string, baseUrl?: string);
|
|
54
74
|
issueGrant(input: IssueContextGrantInput): Promise<GrantView>;
|
|
55
75
|
delegateGrant(parentGrantId: string, input: DelegateContextGrantInput): Promise<DelegateContextGrantResult>;
|
|
76
|
+
/**
|
|
77
|
+
* ZIG-870 — expand a grant you hold into the chat/agreement ids inside its
|
|
78
|
+
* scope. A grant is a fence, not a listing: discovery says "you hold
|
|
79
|
+
* org:acme", this says which chats/agreements that covers. Holder-only,
|
|
80
|
+
* labels-only, grant-fenced server-side.
|
|
81
|
+
*/
|
|
82
|
+
getReach(grantId: string): Promise<GrantReachResult>;
|
|
56
83
|
revokeGrant(grantId: string): Promise<{
|
|
57
84
|
status: string;
|
|
58
85
|
revokedCount?: number;
|
|
@@ -88,6 +88,31 @@ export class ContextGrantsClient {
|
|
|
88
88
|
}
|
|
89
89
|
return { status: 'granted', grant: parsed.grant };
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* ZIG-870 — expand a grant you hold into the chat/agreement ids inside its
|
|
93
|
+
* scope. A grant is a fence, not a listing: discovery says "you hold
|
|
94
|
+
* org:acme", this says which chats/agreements that covers. Holder-only,
|
|
95
|
+
* labels-only, grant-fenced server-side.
|
|
96
|
+
*/
|
|
97
|
+
async getReach(grantId) {
|
|
98
|
+
const res = await fetch(`${this.baseUrl}/context/grants/${encodeURIComponent(grantId)}/reach`, { headers: buildOperatorHeaders(this.operatorKey, this.agentId) });
|
|
99
|
+
const body = await res.text().catch(() => '');
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
throwApiError(res, body, `getReach failed: ${res.status} ${res.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
const parsed = JSON.parse(body);
|
|
104
|
+
if (!parsed.scope) {
|
|
105
|
+
throw new Error('Invalid response: expected { scope, chats, agreements } from GET /context/grants/:id/reach');
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
grantId: parsed.grantId ?? grantId,
|
|
109
|
+
scope: parsed.scope,
|
|
110
|
+
chats: parsed.chats ?? [],
|
|
111
|
+
agreements: parsed.agreements ?? [],
|
|
112
|
+
truncatedChats: parsed.truncatedChats ?? 0,
|
|
113
|
+
truncatedAgreements: parsed.truncatedAgreements ?? 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
91
116
|
async revokeGrant(grantId) {
|
|
92
117
|
const res = await fetch(`${this.baseUrl}/context/grants/${encodeURIComponent(grantId)}`, {
|
|
93
118
|
method: 'DELETE',
|
|
@@ -84,6 +84,32 @@ export interface InboxAckResult {
|
|
|
84
84
|
ackedUpTo: string;
|
|
85
85
|
}>;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Long-poll option shared by the inbox reads. The server holds the request up
|
|
89
|
+
* to this many seconds (server-clamped, ~25s ceiling) and returns as soon as
|
|
90
|
+
* anything actionable exists. Omit for an immediate snapshot — the response
|
|
91
|
+
* shape is identical either way, so `wait` only changes how long an EMPTY
|
|
92
|
+
* answer is withheld.
|
|
93
|
+
*/
|
|
94
|
+
export interface InboxReadOptions {
|
|
95
|
+
waitSeconds?: number;
|
|
96
|
+
}
|
|
97
|
+
/** One agent's slice of the operator-level multiplexed read. */
|
|
98
|
+
export interface InboxOperatorAgentEntry {
|
|
99
|
+
agentId: string;
|
|
100
|
+
/** That agent's own inbox — fenced to its grants, floored by its cursors. */
|
|
101
|
+
inbox: InboxEnvelope;
|
|
102
|
+
}
|
|
103
|
+
/** GET /inbox/operator: one poll across every agent the key's owner runs. */
|
|
104
|
+
export interface InboxOperatorEnvelope {
|
|
105
|
+
asOf: string;
|
|
106
|
+
/** Agents with actionable content only. */
|
|
107
|
+
agents: InboxOperatorAgentEntry[];
|
|
108
|
+
/** Agents examined whose inboxes were empty. */
|
|
109
|
+
idleAgents: number;
|
|
110
|
+
/** Owned agents beyond the server's per-pass cap — not examined. */
|
|
111
|
+
truncatedAgents: number;
|
|
112
|
+
}
|
|
87
113
|
/**
|
|
88
114
|
* The doorbell, not the door (ZIG-434): references and counts since the
|
|
89
115
|
* agent's last ack — never content. Flow: inbox → read → act → ack (ZIG-446).
|
|
@@ -99,6 +125,14 @@ export declare class InboxClient {
|
|
|
99
125
|
*/
|
|
100
126
|
constructor(operatorKey: string, agentId?: string, baseUrl?: string);
|
|
101
127
|
private headers;
|
|
102
|
-
|
|
128
|
+
private inboxUrl;
|
|
129
|
+
getInbox(opts?: InboxReadOptions): Promise<InboxEnvelope>;
|
|
130
|
+
/**
|
|
131
|
+
* Operator-level multiplexed read: one poll for every agent this key's
|
|
132
|
+
* owner runs, each slice fenced to its own agent (an agent-scoped key
|
|
133
|
+
* collapses to its one agent). Ack stays per agent — use a per-agent
|
|
134
|
+
* client's `ack()` after acting on that agent's slice.
|
|
135
|
+
*/
|
|
136
|
+
getOperatorInbox(opts?: InboxReadOptions): Promise<InboxOperatorEnvelope>;
|
|
103
137
|
ack(scopes: InboxAck[]): Promise<InboxAckResult>;
|
|
104
138
|
}
|