@retinue/agentkit 0.2.0 → 0.3.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 +1 -1
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/content-parts.d.ts +18 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +3 -1
- package/dist/graphql/resolvers.js +14 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +89 -0
- package/dist/models/streaming.js +179 -2
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +46 -0
- package/dist/server/bin.d.ts +72 -0
- package/dist/server/bin.js +304 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli-worker.js +6 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +10 -5
- package/dist/server/config.d.ts +11 -1
- package/dist/server/config.js +18 -0
- package/dist/server/doctor.d.ts +85 -0
- package/dist/server/doctor.js +280 -0
- package/dist/server/pool.d.ts +47 -0
- package/dist/server/pool.js +73 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +29 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect, disconnect, list — the operations a host mounts — task #262, AC-1, AC-7, AC-8 and AC-9.
|
|
3
|
+
*
|
|
4
|
+
* The flow in `./index.ts` produces a URL and consumes a callback. This is what turns that into a stored
|
|
5
|
+
* connection, and what takes one away.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not a router. A host mounts these on its own paths with its own authentication, exactly as
|
|
8
|
+
* `./mcp-server` leaves the transport alone: every method here takes an already-authenticated
|
|
9
|
+
* `ExecutionContext`, so a host that has not authenticated has nothing to pass.
|
|
10
|
+
*/
|
|
11
|
+
import { AgentPlatformError } from "../../core/errors.js";
|
|
12
|
+
import { assertSecureEndpoint } from "./index.js";
|
|
13
|
+
/**
|
|
14
|
+
* What a connection is missing, for a caller deciding whether to send somebody back through consent — AC-9.
|
|
15
|
+
*
|
|
16
|
+
* Reads what was **granted**, never what the deployment's app requests: a tenant using their own OAuth app
|
|
17
|
+
* (#263) may have granted a different set, and the whole point is to say *reconnect and grant X* rather than
|
|
18
|
+
* surfacing a vendor 403 that names nothing actionable.
|
|
19
|
+
*
|
|
20
|
+
* A connection with no recorded grant returns `[]` rather than "everything is missing" — the provider did not
|
|
21
|
+
* tell us, and inventing a refusal from an absence would block working connections.
|
|
22
|
+
*/
|
|
23
|
+
export const missingScopes = (connection, required) => {
|
|
24
|
+
if (connection.grantedScopes === undefined)
|
|
25
|
+
return [];
|
|
26
|
+
const granted = new Set(connection.grantedScopes);
|
|
27
|
+
return required.filter((scope) => !granted.has(scope));
|
|
28
|
+
};
|
|
29
|
+
export const createOAuthConnectionService = (deps) => {
|
|
30
|
+
// Same reasoning as the flow's: a revocation request carries the token and the client secret.
|
|
31
|
+
if (deps.config.revocationUrl !== undefined)
|
|
32
|
+
assertSecureEndpoint("revocationUrl", deps.config.revocationUrl);
|
|
33
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
34
|
+
const newId = deps.newId ?? (() => `${deps.config.provider}-${Math.random().toString(36).slice(2, 10)}`);
|
|
35
|
+
return {
|
|
36
|
+
/**
|
|
37
|
+
* Stores the result of a completed exchange — AC-8.
|
|
38
|
+
*
|
|
39
|
+
* The token is sealed on the way in and the non-secret parts are not, which is what lets a connection list
|
|
40
|
+
* render without a key. `metadata` carries whatever the provider disclosed at consent time — Atlassian's
|
|
41
|
+
* cloud id, Slack's team id — because those are needed on every subsequent request and are per connection.
|
|
42
|
+
*/
|
|
43
|
+
async complete(input) {
|
|
44
|
+
return deps.store.create({
|
|
45
|
+
tenantId: input.context.tenantId,
|
|
46
|
+
connection: {
|
|
47
|
+
id: newId(),
|
|
48
|
+
provider: deps.config.provider,
|
|
49
|
+
...(input.label === undefined ? {} : { label: input.label }),
|
|
50
|
+
mode: input.mode ?? "oauth2",
|
|
51
|
+
scheme: input.scheme ?? "bearer",
|
|
52
|
+
...(input.tokens.metadata === undefined && input.metadata === undefined
|
|
53
|
+
? {}
|
|
54
|
+
: { metadata: { ...input.tokens.metadata, ...input.metadata } }),
|
|
55
|
+
...(input.tokens.grantedScopes === undefined ? {} : { grantedScopes: input.tokens.grantedScopes }),
|
|
56
|
+
sealed: await deps.cipher.seal(input.tokens.accessToken),
|
|
57
|
+
...(input.tokens.expiresAt === undefined ? {} : { expiresAt: input.tokens.expiresAt }),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
/** A tenant's live connections for this provider. Never decrypts anything. */
|
|
62
|
+
async list(context) {
|
|
63
|
+
return deps.store.list({ tenantId: context.tenantId, provider: deps.config.provider });
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* Revokes at the provider, then locally — AC-7.
|
|
67
|
+
*
|
|
68
|
+
* **In that order.** Deleting first and then failing to revoke leaves a live token nobody can see and
|
|
69
|
+
* nobody can stop, which is strictly the worst outcome: the credential still works and the record of it is
|
|
70
|
+
* gone. Revoking first means a provider failure leaves a connection that is still listed and still
|
|
71
|
+
* revocable, which a person can retry.
|
|
72
|
+
*
|
|
73
|
+
* A provider with no revocation endpoint is **reported**, not silently skipped: a caller that believes a
|
|
74
|
+
* token was revoked when it was not will not go and remove it by hand.
|
|
75
|
+
*/
|
|
76
|
+
async disconnect(input) {
|
|
77
|
+
const connection = await deps.store.get({ tenantId: input.context.tenantId, id: input.id });
|
|
78
|
+
if (connection === null)
|
|
79
|
+
throw new AgentPlatformError({
|
|
80
|
+
code: "not_found",
|
|
81
|
+
message: `no connection "${input.id}" for this workspace`,
|
|
82
|
+
retryable: false,
|
|
83
|
+
});
|
|
84
|
+
let revokedAtProvider = false;
|
|
85
|
+
let reason;
|
|
86
|
+
if (deps.config.revocationUrl === undefined) {
|
|
87
|
+
reason = `${deps.config.provider} publishes no token revocation endpoint, so the token remains valid at the provider until it expires or is removed there by hand`;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
try {
|
|
91
|
+
const token = await deps.cipher.open(connection.sealed);
|
|
92
|
+
const body = new URLSearchParams({
|
|
93
|
+
[deps.config.tokenParam ?? "token"]: token,
|
|
94
|
+
client_id: deps.config.clientId,
|
|
95
|
+
});
|
|
96
|
+
if (deps.config.clientSecret !== undefined)
|
|
97
|
+
body.set("client_secret", deps.config.clientSecret);
|
|
98
|
+
const response = await fetchImpl(deps.config.revocationUrl, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
101
|
+
body: body.toString(),
|
|
102
|
+
});
|
|
103
|
+
revokedAtProvider = response.ok;
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
reason = `the provider answered ${response.status} to the revocation request`;
|
|
106
|
+
}
|
|
107
|
+
catch (thrown) {
|
|
108
|
+
// Reported, not thrown: the local revocation below must still happen, or a provider outage leaves a
|
|
109
|
+
// connection nobody can remove.
|
|
110
|
+
reason = thrown instanceof Error ? thrown.message : String(thrown);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await deps.store.revoke({ tenantId: input.context.tenantId, id: input.id });
|
|
114
|
+
return { revokedAtProvider, ...(reason === undefined ? {} : { reason }) };
|
|
115
|
+
},
|
|
116
|
+
/**
|
|
117
|
+
* What a caller should tell somebody when a tool needs a scope this connection lacks — AC-9.
|
|
118
|
+
*
|
|
119
|
+
* Returns the message rather than throwing, because the caller decides what to do with it: #264 turns it
|
|
120
|
+
* into a login URL and pauses the run, and a simpler host may just show it.
|
|
121
|
+
*/
|
|
122
|
+
scopeGap(connection, required) {
|
|
123
|
+
const missing = missingScopes(connection, required);
|
|
124
|
+
if (missing.length === 0)
|
|
125
|
+
return null;
|
|
126
|
+
return (`this ${deps.config.provider} connection was granted ${(connection.grantedScopes ?? []).join(", ") || "no scopes"} ` +
|
|
127
|
+
`and this needs ${missing.join(", ")}. Reconnect and grant ${missing.join(", ")} — the provider will ` +
|
|
128
|
+
"otherwise answer 403, which names nothing you can act on.");
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run that needs a connection pauses and asks — REQ-063 (#259), task #264.
|
|
3
|
+
*
|
|
4
|
+
* A tool call fails because the tenant has no connection, or the one they have expired or lacks a scope. Before
|
|
5
|
+
* this, that was `capability_unavailable`: the run died and a person read an error. Now it **pauses**, hands
|
|
6
|
+
* back a login URL, and resumes when consent completes.
|
|
7
|
+
*
|
|
8
|
+
* ## The third pause, not a new mechanism
|
|
9
|
+
*
|
|
10
|
+
* The runtime already does exactly this shape twice — `waiting-for-question` and `waiting-for-approval`, each
|
|
11
|
+
* with an event the engine emits, a status the worker parks in, and a resume to `queued`. Building a bespoke
|
|
12
|
+
* polling loop, or leaving a failed run for somebody to restart by hand, would be a second mechanism for the
|
|
13
|
+
* thing the durable runtime exists to do.
|
|
14
|
+
*
|
|
15
|
+
* ## Where it does **not** apply
|
|
16
|
+
*
|
|
17
|
+
* Only where a login URL exists. A token-based integration has nothing to redirect to, so a missing token must
|
|
18
|
+
* still fail with the message naming the ref. Getting this backwards produces the worst outcome available: a run
|
|
19
|
+
* that hangs for ever waiting for a consent screen nobody can reach.
|
|
20
|
+
*
|
|
21
|
+
* That decision comes from `ToolkitAuth.modes` (#260) — the reason `AuthMode` and `CredentialScheme` are
|
|
22
|
+
* separate axes. An OAuth access token is presented as a bearer, so a design that only knew the wire format
|
|
23
|
+
* could not answer this question at all.
|
|
24
|
+
*/
|
|
25
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
26
|
+
import type { AuthMode } from "../tools/credentials.js";
|
|
27
|
+
/**
|
|
28
|
+
* Why a connection is not usable. Three triggers, all of which should pause.
|
|
29
|
+
*
|
|
30
|
+
* Separate values because a UI says different things: "connect your GitHub" is not "your GitHub connection
|
|
31
|
+
* expired" is not "grant one more permission", and a person who is told the wrong one goes looking in the wrong
|
|
32
|
+
* place.
|
|
33
|
+
*/
|
|
34
|
+
export declare const CONNECTION_GAPS: readonly ["absent", "expired", "insufficient-scope"];
|
|
35
|
+
export type ConnectionGap = (typeof CONNECTION_GAPS)[number];
|
|
36
|
+
export type ConnectionNeed = {
|
|
37
|
+
readonly provider: string;
|
|
38
|
+
readonly gap: ConnectionGap;
|
|
39
|
+
/** Scopes the consent must ask for. For `insufficient-scope`, the missing ones plus what was already granted. */
|
|
40
|
+
readonly scopes: readonly string[];
|
|
41
|
+
readonly toolName?: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Recognises a failure as *a missing connection* rather than a broken tool.
|
|
45
|
+
*
|
|
46
|
+
* Structural rather than a string match on the message: a `details.connectionGap` set by the resolver, so
|
|
47
|
+
* rewording an error cannot silently turn a pausable failure into a fatal one. The marker is added by the
|
|
48
|
+
* connection resolver, which is the only code that knows the difference.
|
|
49
|
+
*/
|
|
50
|
+
export declare const connectionNeedOf: (thrown: unknown) => ConnectionNeed | null;
|
|
51
|
+
/** Attaches the marker above to an error, so the engine can recognise it without parsing prose. */
|
|
52
|
+
export declare const withConnectionGap: (error: AgentPlatformError, need: ConnectionNeed) => AgentPlatformError;
|
|
53
|
+
/**
|
|
54
|
+
* Whether this provider can be connected by sending somebody somewhere — the branch that decides pause or fail.
|
|
55
|
+
*
|
|
56
|
+
* `undefined` modes means the host has not declared any, and the honest answer there is **no**: pausing a run
|
|
57
|
+
* for a provider that turns out to have no flow would hang it for ever, and failing is recoverable.
|
|
58
|
+
*/
|
|
59
|
+
export declare const canPauseForConsent: (modes: readonly AuthMode[] | undefined) => boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The message a person sees, per gap.
|
|
62
|
+
*
|
|
63
|
+
* Written here rather than in a UI so every client says the same thing, and so the distinction between the
|
|
64
|
+
* three gaps survives — a person told "connect your GitHub" when the real problem is a missing scope will
|
|
65
|
+
* disconnect and reconnect and land in exactly the same place.
|
|
66
|
+
*/
|
|
67
|
+
export declare const consentPrompt: (need: ConnectionNeed) => string;
|
|
68
|
+
//# sourceMappingURL=pause.d.ts.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run that needs a connection pauses and asks — REQ-063 (#259), task #264.
|
|
3
|
+
*
|
|
4
|
+
* A tool call fails because the tenant has no connection, or the one they have expired or lacks a scope. Before
|
|
5
|
+
* this, that was `capability_unavailable`: the run died and a person read an error. Now it **pauses**, hands
|
|
6
|
+
* back a login URL, and resumes when consent completes.
|
|
7
|
+
*
|
|
8
|
+
* ## The third pause, not a new mechanism
|
|
9
|
+
*
|
|
10
|
+
* The runtime already does exactly this shape twice — `waiting-for-question` and `waiting-for-approval`, each
|
|
11
|
+
* with an event the engine emits, a status the worker parks in, and a resume to `queued`. Building a bespoke
|
|
12
|
+
* polling loop, or leaving a failed run for somebody to restart by hand, would be a second mechanism for the
|
|
13
|
+
* thing the durable runtime exists to do.
|
|
14
|
+
*
|
|
15
|
+
* ## Where it does **not** apply
|
|
16
|
+
*
|
|
17
|
+
* Only where a login URL exists. A token-based integration has nothing to redirect to, so a missing token must
|
|
18
|
+
* still fail with the message naming the ref. Getting this backwards produces the worst outcome available: a run
|
|
19
|
+
* that hangs for ever waiting for a consent screen nobody can reach.
|
|
20
|
+
*
|
|
21
|
+
* That decision comes from `ToolkitAuth.modes` (#260) — the reason `AuthMode` and `CredentialScheme` are
|
|
22
|
+
* separate axes. An OAuth access token is presented as a bearer, so a design that only knew the wire format
|
|
23
|
+
* could not answer this question at all.
|
|
24
|
+
*/
|
|
25
|
+
import { AgentPlatformError, isAgentPlatformError } from "../core/errors.js";
|
|
26
|
+
/**
|
|
27
|
+
* Why a connection is not usable. Three triggers, all of which should pause.
|
|
28
|
+
*
|
|
29
|
+
* Separate values because a UI says different things: "connect your GitHub" is not "your GitHub connection
|
|
30
|
+
* expired" is not "grant one more permission", and a person who is told the wrong one goes looking in the wrong
|
|
31
|
+
* place.
|
|
32
|
+
*/
|
|
33
|
+
export const CONNECTION_GAPS = ["absent", "expired", "insufficient-scope"];
|
|
34
|
+
/**
|
|
35
|
+
* Recognises a failure as *a missing connection* rather than a broken tool.
|
|
36
|
+
*
|
|
37
|
+
* Structural rather than a string match on the message: a `details.connectionGap` set by the resolver, so
|
|
38
|
+
* rewording an error cannot silently turn a pausable failure into a fatal one. The marker is added by the
|
|
39
|
+
* connection resolver, which is the only code that knows the difference.
|
|
40
|
+
*/
|
|
41
|
+
export const connectionNeedOf = (thrown) => {
|
|
42
|
+
if (!isAgentPlatformError(thrown))
|
|
43
|
+
return null;
|
|
44
|
+
const details = thrown.details;
|
|
45
|
+
const gap = details?.["connectionGap"];
|
|
46
|
+
const provider = details?.["connectionProvider"];
|
|
47
|
+
if (typeof gap !== "string" || !CONNECTION_GAPS.includes(gap))
|
|
48
|
+
return null;
|
|
49
|
+
if (typeof provider !== "string" || provider === "")
|
|
50
|
+
return null;
|
|
51
|
+
const scopes = details?.["connectionScopes"];
|
|
52
|
+
return {
|
|
53
|
+
provider,
|
|
54
|
+
gap: gap,
|
|
55
|
+
scopes: Array.isArray(scopes) ? scopes.filter((s) => typeof s === "string") : [],
|
|
56
|
+
...(typeof details?.["toolName"] === "string" ? { toolName: details["toolName"] } : {}),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** Attaches the marker above to an error, so the engine can recognise it without parsing prose. */
|
|
60
|
+
export const withConnectionGap = (error, need) => new AgentPlatformError({
|
|
61
|
+
code: error.code,
|
|
62
|
+
message: error.message,
|
|
63
|
+
retryable: false,
|
|
64
|
+
details: {
|
|
65
|
+
...(error.details ?? {}),
|
|
66
|
+
connectionGap: need.gap,
|
|
67
|
+
connectionProvider: need.provider,
|
|
68
|
+
connectionScopes: [...need.scopes],
|
|
69
|
+
...(need.toolName === undefined ? {} : { toolName: need.toolName }),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Whether this provider can be connected by sending somebody somewhere — the branch that decides pause or fail.
|
|
74
|
+
*
|
|
75
|
+
* `undefined` modes means the host has not declared any, and the honest answer there is **no**: pausing a run
|
|
76
|
+
* for a provider that turns out to have no flow would hang it for ever, and failing is recoverable.
|
|
77
|
+
*/
|
|
78
|
+
export const canPauseForConsent = (modes) => modes !== undefined && modes.includes("oauth2");
|
|
79
|
+
/**
|
|
80
|
+
* The message a person sees, per gap.
|
|
81
|
+
*
|
|
82
|
+
* Written here rather than in a UI so every client says the same thing, and so the distinction between the
|
|
83
|
+
* three gaps survives — a person told "connect your GitHub" when the real problem is a missing scope will
|
|
84
|
+
* disconnect and reconnect and land in exactly the same place.
|
|
85
|
+
*/
|
|
86
|
+
export const consentPrompt = (need) => {
|
|
87
|
+
switch (need.gap) {
|
|
88
|
+
case "absent":
|
|
89
|
+
return `Connect ${need.provider} to continue.`;
|
|
90
|
+
case "expired":
|
|
91
|
+
return `Your ${need.provider} connection expired. Reconnect to continue.`;
|
|
92
|
+
case "insufficient-scope":
|
|
93
|
+
return (`Your ${need.provider} connection needs ${need.scopes.join(", ") || "additional permissions"}. ` +
|
|
94
|
+
"Reconnect and grant them to continue.");
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
//# sourceMappingURL=pause.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `CredentialResolver` over stored connections — the half that makes #260 useful, task #261.
|
|
3
|
+
*
|
|
4
|
+
* #260 made a credential a typed value and shipped one resolver: a static map, which serves exactly one tenant.
|
|
5
|
+
* This is the multi-tenant one, and it is the piece the whole integrations milestone was waiting for.
|
|
6
|
+
*
|
|
7
|
+
* ## Resolution is per call, and that is not an implementation detail
|
|
8
|
+
*
|
|
9
|
+
* `credentials.ts` explains why: a credential read once at startup survives its own rotation, and the failure
|
|
10
|
+
* looks like the vendor rejecting a token that "has not changed". This resolver therefore reads the store on
|
|
11
|
+
* every call and caches nothing. A deployment that wants caching adds it knowing its own rotation window —
|
|
12
|
+
* which is a decision only the deployment can make.
|
|
13
|
+
*/
|
|
14
|
+
import { type CredentialResolver, type CredentialScheme } from "../tools/credentials.js";
|
|
15
|
+
import type { SecretCipher } from "./cipher.js";
|
|
16
|
+
import { type Connection, type ConnectionStore } from "./index.js";
|
|
17
|
+
export type ConnectionResolverDeps = {
|
|
18
|
+
readonly store: ConnectionStore;
|
|
19
|
+
readonly cipher: SecretCipher;
|
|
20
|
+
/**
|
|
21
|
+
* Which connection a bare `<provider>` ref means when a tenant has several.
|
|
22
|
+
*
|
|
23
|
+
* Defaults to the oldest, which is stable and explainable — "the first one you connected" — rather than
|
|
24
|
+
* newest, which would silently re-point every agent the moment somebody adds a second account.
|
|
25
|
+
*/
|
|
26
|
+
readonly chooseDefault?: (candidates: readonly Connection[]) => Connection | undefined;
|
|
27
|
+
};
|
|
28
|
+
export declare const createConnectionCredentialResolver: (deps: ConnectionResolverDeps) => CredentialResolver;
|
|
29
|
+
/** Which scheme a connection presents, for `assertToolkitAuth` at wiring time. */
|
|
30
|
+
export declare const schemeOf: (connection: Connection) => CredentialScheme;
|
|
31
|
+
/**
|
|
32
|
+
* Re-seals every connection that is not already under the current key — task #261 AC-7.
|
|
33
|
+
*
|
|
34
|
+
* Rotation without this is theory: `SecretCipher` can *open* an old key's secrets, but nothing moves them
|
|
35
|
+
* forward, so the old key can never be retired and the first key is permanent in practice.
|
|
36
|
+
*
|
|
37
|
+
* The order is what makes it safe to interrupt. Each connection is opened, re-sealed and written **one at a
|
|
38
|
+
* time**, and a failure leaves everything before it done and everything after it untouched — both readable,
|
|
39
|
+
* because the old key is still configured. Running it twice is a no-op: a connection already on the current key
|
|
40
|
+
* is skipped.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately not a transaction over the whole tenant. A rotation across thousands of connections held in one
|
|
43
|
+
* transaction is a long-running write that blocks and, if it fails at the end, achieves nothing.
|
|
44
|
+
*/
|
|
45
|
+
export declare const resealConnections: (input: {
|
|
46
|
+
readonly store: ConnectionStore;
|
|
47
|
+
readonly cipher: SecretCipher;
|
|
48
|
+
readonly tenantId: string;
|
|
49
|
+
/** Reported per connection, so a long rotation is observable rather than silent. */
|
|
50
|
+
readonly onProgress?: (progress: {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly from: string;
|
|
53
|
+
readonly to: string;
|
|
54
|
+
}) => void;
|
|
55
|
+
}) => Promise<{
|
|
56
|
+
readonly resealed: number;
|
|
57
|
+
readonly skipped: number;
|
|
58
|
+
}>;
|
|
59
|
+
//# sourceMappingURL=resolver.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `CredentialResolver` over stored connections — the half that makes #260 useful, task #261.
|
|
3
|
+
*
|
|
4
|
+
* #260 made a credential a typed value and shipped one resolver: a static map, which serves exactly one tenant.
|
|
5
|
+
* This is the multi-tenant one, and it is the piece the whole integrations milestone was waiting for.
|
|
6
|
+
*
|
|
7
|
+
* ## Resolution is per call, and that is not an implementation detail
|
|
8
|
+
*
|
|
9
|
+
* `credentials.ts` explains why: a credential read once at startup survives its own rotation, and the failure
|
|
10
|
+
* looks like the vendor rejecting a token that "has not changed". This resolver therefore reads the store on
|
|
11
|
+
* every call and caches nothing. A deployment that wants caching adds it knowing its own rotation window —
|
|
12
|
+
* which is a decision only the deployment can make.
|
|
13
|
+
*/
|
|
14
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
15
|
+
import { createCredential, } from "../tools/credentials.js";
|
|
16
|
+
import { parseCredentialRef } from "./index.js";
|
|
17
|
+
import { withConnectionGap } from "./pause.js";
|
|
18
|
+
/**
|
|
19
|
+
* How the plaintext behind a connection maps back to a `Credential`.
|
|
20
|
+
*
|
|
21
|
+
* The sealed blob holds **only** the secret parts — the token, or the password of a basic pair. Everything
|
|
22
|
+
* non-secret (the username, the header name, the metadata) is stored in readable columns, so listing
|
|
23
|
+
* connections needs no key and a person can see *which account* a connection is for without decrypting
|
|
24
|
+
* anything.
|
|
25
|
+
*/
|
|
26
|
+
const toCredential = (connection, plaintext) => {
|
|
27
|
+
const shared = {
|
|
28
|
+
mode: connection.mode,
|
|
29
|
+
...(connection.metadata === undefined ? {} : { metadata: connection.metadata }),
|
|
30
|
+
};
|
|
31
|
+
switch (connection.scheme) {
|
|
32
|
+
case "bearer":
|
|
33
|
+
return createCredential({ scheme: "bearer", token: plaintext, ...shared });
|
|
34
|
+
case "basic": {
|
|
35
|
+
// The username lives in metadata because it is not a secret and a person needs to see it.
|
|
36
|
+
const username = connection.metadata?.username;
|
|
37
|
+
if (username === undefined)
|
|
38
|
+
throw new AgentPlatformError({
|
|
39
|
+
code: "invalid_input",
|
|
40
|
+
message: `connection "${connection.id}" is a basic credential with no \`metadata.username\`. The username is ` +
|
|
41
|
+
"stored readable on purpose — a connection list should show which account it is for without a key.",
|
|
42
|
+
retryable: false,
|
|
43
|
+
});
|
|
44
|
+
return createCredential({ scheme: "basic", username, password: plaintext, ...shared });
|
|
45
|
+
}
|
|
46
|
+
case "custom-header": {
|
|
47
|
+
const header = connection.metadata?.header;
|
|
48
|
+
if (header === undefined)
|
|
49
|
+
throw new AgentPlatformError({
|
|
50
|
+
code: "invalid_input",
|
|
51
|
+
message: `connection "${connection.id}" is a custom-header credential with no \`metadata.header\``,
|
|
52
|
+
retryable: false,
|
|
53
|
+
});
|
|
54
|
+
return createCredential({ scheme: "custom-header", header, value: plaintext, ...shared });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
export const createConnectionCredentialResolver = (deps) => {
|
|
59
|
+
const chooseDefault = deps.chooseDefault ?? ((candidates) => candidates[0]);
|
|
60
|
+
return {
|
|
61
|
+
async resolve({ ref, context }) {
|
|
62
|
+
const { provider, id } = parseCredentialRef(ref);
|
|
63
|
+
const connection = id === undefined
|
|
64
|
+
? chooseDefault(await deps.store.list({ tenantId: context.tenantId, provider }))
|
|
65
|
+
: ((await deps.store.get({ tenantId: context.tenantId, id })) ?? undefined);
|
|
66
|
+
if (connection === undefined || connection === null)
|
|
67
|
+
// Marked, so the engine can recognise it as *a missing connection* rather than a broken tool and pause
|
|
68
|
+
// the run instead of failing it — #264. Structural, not a string match: rewording this message must not
|
|
69
|
+
// silently turn a pausable failure into a fatal one.
|
|
70
|
+
throw withConnectionGap(new AgentPlatformError({
|
|
71
|
+
code: "capability_unavailable",
|
|
72
|
+
message: `no ${provider} connection for this workspace` +
|
|
73
|
+
(id === undefined ? "" : ` with id "${id}"`) +
|
|
74
|
+
". Connect one, or check the credential reference.",
|
|
75
|
+
retryable: false,
|
|
76
|
+
}), { provider, gap: "absent", scopes: [] });
|
|
77
|
+
// A ref naming one provider must not resolve to another's credential, which is what a wrong id would do.
|
|
78
|
+
if (id !== undefined && connection.provider !== provider)
|
|
79
|
+
throw new AgentPlatformError({
|
|
80
|
+
code: "invalid_input",
|
|
81
|
+
message: `connection "${id}" is a ${connection.provider} connection, not ${provider}`,
|
|
82
|
+
retryable: false,
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* Expiry is reported, not refreshed — refresh is #233's, deliberately.
|
|
86
|
+
*
|
|
87
|
+
* Saying "expired" rather than sending it is the difference between a message an operator can act on and
|
|
88
|
+
* a vendor 401 that says the token is invalid.
|
|
89
|
+
*/
|
|
90
|
+
if (connection.expiresAt !== undefined && Date.parse(connection.expiresAt) <= Date.now())
|
|
91
|
+
throw withConnectionGap(new AgentPlatformError({
|
|
92
|
+
code: "capability_unavailable",
|
|
93
|
+
message: `the ${provider} connection expired at ${connection.expiresAt}. Reconnect, or wire a refresh.`,
|
|
94
|
+
retryable: false,
|
|
95
|
+
}), { provider, gap: "expired", scopes: connection.grantedScopes ?? [] });
|
|
96
|
+
const plaintext = await deps.cipher.open(connection.sealed);
|
|
97
|
+
return toCredential(connection, plaintext);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
/** Which scheme a connection presents, for `assertToolkitAuth` at wiring time. */
|
|
102
|
+
export const schemeOf = (connection) => connection.scheme;
|
|
103
|
+
/**
|
|
104
|
+
* Re-seals every connection that is not already under the current key — task #261 AC-7.
|
|
105
|
+
*
|
|
106
|
+
* Rotation without this is theory: `SecretCipher` can *open* an old key's secrets, but nothing moves them
|
|
107
|
+
* forward, so the old key can never be retired and the first key is permanent in practice.
|
|
108
|
+
*
|
|
109
|
+
* The order is what makes it safe to interrupt. Each connection is opened, re-sealed and written **one at a
|
|
110
|
+
* time**, and a failure leaves everything before it done and everything after it untouched — both readable,
|
|
111
|
+
* because the old key is still configured. Running it twice is a no-op: a connection already on the current key
|
|
112
|
+
* is skipped.
|
|
113
|
+
*
|
|
114
|
+
* Deliberately not a transaction over the whole tenant. A rotation across thousands of connections held in one
|
|
115
|
+
* transaction is a long-running write that blocks and, if it fails at the end, achieves nothing.
|
|
116
|
+
*/
|
|
117
|
+
export const resealConnections = async (input) => {
|
|
118
|
+
const current = input.cipher.currentKeyId();
|
|
119
|
+
const connections = await input.store.list({ tenantId: input.tenantId });
|
|
120
|
+
let resealed = 0;
|
|
121
|
+
let skipped = 0;
|
|
122
|
+
for (const connection of connections) {
|
|
123
|
+
if (connection.sealed.keyId === current) {
|
|
124
|
+
skipped += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const plaintext = await input.cipher.open(connection.sealed);
|
|
128
|
+
await input.store.update({
|
|
129
|
+
tenantId: input.tenantId,
|
|
130
|
+
id: connection.id,
|
|
131
|
+
patch: { sealed: await input.cipher.seal(plaintext) },
|
|
132
|
+
});
|
|
133
|
+
input.onProgress?.({ id: connection.id, from: connection.sealed.keyId, to: current });
|
|
134
|
+
resealed += 1;
|
|
135
|
+
}
|
|
136
|
+
return { resealed, skipped };
|
|
137
|
+
};
|
|
138
|
+
//# sourceMappingURL=resolver.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resuming a run once consent completes — REQ-063 (#259), task #264, AC-3, AC-5, AC-6 and AC-7.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `hitl`'s `resumeRun`: transition back to `queued` and enqueue. The run picks up from its
|
|
5
|
+
* checkpoint and re-attempts the tool call that paused it, so nothing before the pause is repeated — the same
|
|
6
|
+
* idempotency the approval path already relies on.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExecutionContext } from "../core/context.js";
|
|
9
|
+
import type { RunId } from "../core/ids.js";
|
|
10
|
+
import type { RunEventLog } from "../core/events.js";
|
|
11
|
+
import type { JobDispatcher } from "../runtime/index.js";
|
|
12
|
+
import type { RunStore } from "../persistence/index.js";
|
|
13
|
+
export type ConsentResumeDeps = {
|
|
14
|
+
readonly runs: RunStore;
|
|
15
|
+
readonly dispatcher: JobDispatcher;
|
|
16
|
+
readonly events?: RunEventLog;
|
|
17
|
+
readonly now?: () => string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Resumes a run that was waiting for a connection.
|
|
21
|
+
*
|
|
22
|
+
* ## Only the right principal — AC-5
|
|
23
|
+
*
|
|
24
|
+
* This is the security property of the whole feature, and it is not about the run: it is about the **login
|
|
25
|
+
* URL**. That URL is rendered in a UI, appears in screenshots and may be pasted into a chat. If completing the
|
|
26
|
+
* flow it points at could resume *anyone's* run, a leaked URL would let a stranger attach their own account to
|
|
27
|
+
* somebody else's tenant — and every subsequent action the agent took against that provider would be theirs.
|
|
28
|
+
*
|
|
29
|
+
* So the run must belong to the caller's tenant. The OAuth flow has already bound its `state` to the tenant and
|
|
30
|
+
* the principal who began it (#262), which is the other half: the state cannot be redeemed by another person,
|
|
31
|
+
* and the resume cannot be driven from another tenant.
|
|
32
|
+
*
|
|
33
|
+
* ## Idempotent — AC-6
|
|
34
|
+
*
|
|
35
|
+
* A double-click, a retried redirect, a browser prefetch: the callback firing twice must queue the run **once**.
|
|
36
|
+
* `transition` is guarded by the current status, so a run already moved out of `waiting-for-connection` is
|
|
37
|
+
* reported as `resumed: false` rather than enqueued again — and a second job would mean the tool call that
|
|
38
|
+
* paused the run runs twice.
|
|
39
|
+
*/
|
|
40
|
+
export declare const resumeAfterConsent: (deps: ConsentResumeDeps, input: {
|
|
41
|
+
readonly context: ExecutionContext;
|
|
42
|
+
readonly runId: RunId;
|
|
43
|
+
readonly provider: string;
|
|
44
|
+
readonly connectionId: string;
|
|
45
|
+
}) => Promise<{
|
|
46
|
+
readonly resumed: boolean;
|
|
47
|
+
readonly reason?: string;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* Fails a run that has been waiting for a connection longer than the deployment allows — AC-7.
|
|
51
|
+
*
|
|
52
|
+
* A pause with no ceiling is a run that holds a row for ever, and the reaper does not sweep it: the reaper
|
|
53
|
+
* exists for *abandoned* runs, and this one is not abandoned — it is waiting for a person who is never coming
|
|
54
|
+
* back. So it needs its own ceiling, and failing with a reason beats a row nobody can explain.
|
|
55
|
+
*/
|
|
56
|
+
export declare const expireStaleConsents: (deps: ConsentResumeDeps & {
|
|
57
|
+
readonly listWaiting: (before: string) => Promise<readonly {
|
|
58
|
+
readonly tenantId: string;
|
|
59
|
+
readonly id: RunId;
|
|
60
|
+
}[]>;
|
|
61
|
+
}, input: {
|
|
62
|
+
readonly olderThanMs: number;
|
|
63
|
+
}) => Promise<number>;
|
|
64
|
+
//# sourceMappingURL=resume.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resuming a run once consent completes — REQ-063 (#259), task #264, AC-3, AC-5, AC-6 and AC-7.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `hitl`'s `resumeRun`: transition back to `queued` and enqueue. The run picks up from its
|
|
5
|
+
* checkpoint and re-attempts the tool call that paused it, so nothing before the pause is repeated — the same
|
|
6
|
+
* idempotency the approval path already relies on.
|
|
7
|
+
*/
|
|
8
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
9
|
+
/**
|
|
10
|
+
* Resumes a run that was waiting for a connection.
|
|
11
|
+
*
|
|
12
|
+
* ## Only the right principal — AC-5
|
|
13
|
+
*
|
|
14
|
+
* This is the security property of the whole feature, and it is not about the run: it is about the **login
|
|
15
|
+
* URL**. That URL is rendered in a UI, appears in screenshots and may be pasted into a chat. If completing the
|
|
16
|
+
* flow it points at could resume *anyone's* run, a leaked URL would let a stranger attach their own account to
|
|
17
|
+
* somebody else's tenant — and every subsequent action the agent took against that provider would be theirs.
|
|
18
|
+
*
|
|
19
|
+
* So the run must belong to the caller's tenant. The OAuth flow has already bound its `state` to the tenant and
|
|
20
|
+
* the principal who began it (#262), which is the other half: the state cannot be redeemed by another person,
|
|
21
|
+
* and the resume cannot be driven from another tenant.
|
|
22
|
+
*
|
|
23
|
+
* ## Idempotent — AC-6
|
|
24
|
+
*
|
|
25
|
+
* A double-click, a retried redirect, a browser prefetch: the callback firing twice must queue the run **once**.
|
|
26
|
+
* `transition` is guarded by the current status, so a run already moved out of `waiting-for-connection` is
|
|
27
|
+
* reported as `resumed: false` rather than enqueued again — and a second job would mean the tool call that
|
|
28
|
+
* paused the run runs twice.
|
|
29
|
+
*/
|
|
30
|
+
export const resumeAfterConsent = async (deps, input) => {
|
|
31
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
32
|
+
const run = await deps.runs.findById({ tenantId: input.context.tenantId, id: input.runId });
|
|
33
|
+
if (run === null) {
|
|
34
|
+
/**
|
|
35
|
+
* Absent and not-yours are the same answer, deliberately.
|
|
36
|
+
*
|
|
37
|
+
* Distinguishing them tells a caller whether a run id exists in another tenant, which is exactly the probe
|
|
38
|
+
* a leaked login URL would be used for. `findById` is already tenant-scoped, so this branch covers both.
|
|
39
|
+
*/
|
|
40
|
+
throw new AgentPlatformError({
|
|
41
|
+
code: "not_found",
|
|
42
|
+
message: `no run "${input.runId}" for this workspace`,
|
|
43
|
+
retryable: false,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (run.status !== "waiting-for-connection") {
|
|
47
|
+
// Not an error: a retried callback on an already-resumed run is a normal thing a browser does.
|
|
48
|
+
return { resumed: false, reason: `the run is ${run.status}, not waiting for a connection` };
|
|
49
|
+
}
|
|
50
|
+
if (deps.events !== undefined) {
|
|
51
|
+
// Recorded before the transition, so a crash between the two leaves a log that says what happened rather
|
|
52
|
+
// than a run that moved for no visible reason.
|
|
53
|
+
await deps.events.append({
|
|
54
|
+
tenantId: input.context.tenantId,
|
|
55
|
+
event: {
|
|
56
|
+
type: "connection.completed",
|
|
57
|
+
runId: input.runId,
|
|
58
|
+
sequence: 0,
|
|
59
|
+
occurredAt: now(),
|
|
60
|
+
provider: input.provider,
|
|
61
|
+
connectionId: input.connectionId,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
await deps.runs.transition({
|
|
66
|
+
tenantId: input.context.tenantId,
|
|
67
|
+
id: input.runId,
|
|
68
|
+
workerId: "connections",
|
|
69
|
+
to: "queued",
|
|
70
|
+
now: now(),
|
|
71
|
+
});
|
|
72
|
+
await deps.dispatcher.enqueueRun({ tenantId: input.context.tenantId, runId: input.runId });
|
|
73
|
+
return { resumed: true };
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Fails a run that has been waiting for a connection longer than the deployment allows — AC-7.
|
|
77
|
+
*
|
|
78
|
+
* A pause with no ceiling is a run that holds a row for ever, and the reaper does not sweep it: the reaper
|
|
79
|
+
* exists for *abandoned* runs, and this one is not abandoned — it is waiting for a person who is never coming
|
|
80
|
+
* back. So it needs its own ceiling, and failing with a reason beats a row nobody can explain.
|
|
81
|
+
*/
|
|
82
|
+
export const expireStaleConsents = async (deps, input) => {
|
|
83
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
84
|
+
const cutoff = new Date(Date.parse(now()) - input.olderThanMs).toISOString();
|
|
85
|
+
const stale = await deps.listWaiting(cutoff);
|
|
86
|
+
let failed = 0;
|
|
87
|
+
for (const run of stale) {
|
|
88
|
+
await deps.runs.transition({
|
|
89
|
+
tenantId: run.tenantId,
|
|
90
|
+
id: run.id,
|
|
91
|
+
workerId: "connections",
|
|
92
|
+
to: "failed",
|
|
93
|
+
now: now(),
|
|
94
|
+
});
|
|
95
|
+
failed += 1;
|
|
96
|
+
}
|
|
97
|
+
return failed;
|
|
98
|
+
};
|
|
99
|
+
//# sourceMappingURL=resume.js.map
|