@pikku/core 0.12.63 → 0.12.66
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/CHANGELOG.md +368 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +42 -27
- package/dist/permissions.js +101 -203
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
- package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
- package/dist/wirings/ai-agent/index.d.ts +2 -1
- package/dist/wirings/ai-agent/index.js +2 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +5 -41
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +3 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +4 -12
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -3
- package/package.json +6 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +160 -373
- package/src/permissions.ts +137 -279
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
- package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
- package/src/wirings/ai-agent/index.ts +5 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +9 -40
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +4 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +7 -14
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -34,8 +34,13 @@ import {
|
|
|
34
34
|
buildInstructions,
|
|
35
35
|
buildToolDefs,
|
|
36
36
|
createScopedChannel,
|
|
37
|
+
resolveOwnerResourceId,
|
|
38
|
+
agentSessionScope,
|
|
39
|
+
assertResourceOwner,
|
|
40
|
+
assertAgentAuthorized,
|
|
37
41
|
ToolApprovalRequired,
|
|
38
42
|
ToolCredentialRequired,
|
|
43
|
+
APPROVAL_REQUIRED,
|
|
39
44
|
type RunAIAgentParams,
|
|
40
45
|
type StreamAIAgentOptions,
|
|
41
46
|
type StreamContext,
|
|
@@ -53,7 +58,8 @@ type PersistingChannel = AIStreamChannel & {
|
|
|
53
58
|
function createPersistingChannel(
|
|
54
59
|
parent: AIStreamChannel,
|
|
55
60
|
storage: AIStorageService | undefined,
|
|
56
|
-
threadId: string
|
|
61
|
+
threadId: string,
|
|
62
|
+
logger?: { error: (...args: any[]) => void }
|
|
57
63
|
): PersistingChannel {
|
|
58
64
|
let fullText = ''
|
|
59
65
|
let stepText = ''
|
|
@@ -106,6 +112,23 @@ function createPersistingChannel(
|
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
|
|
115
|
+
/**
|
|
116
|
+
* `send` is synchronous and so cannot await the flush. A rejection would have
|
|
117
|
+
* nothing to propagate to and would take the process down as an unhandled
|
|
118
|
+
* rejection — a model reusing a toolCallId, which is a primary key in AI
|
|
119
|
+
* storage, is enough to trigger it. Persistence from inside `send` is
|
|
120
|
+
* therefore best-effort: the run carries on, and the awaited `flush()` on the
|
|
121
|
+
* suspend paths still surfaces failures to its caller.
|
|
122
|
+
*/
|
|
123
|
+
const flushDetached = () => {
|
|
124
|
+
void flushStep().catch((error) => {
|
|
125
|
+
logger?.error('Failed to persist agent messages', {
|
|
126
|
+
threadId,
|
|
127
|
+
error,
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
const channel: PersistingChannel = {
|
|
110
133
|
channelId: parent.channelId,
|
|
111
134
|
openingData: parent.openingData,
|
|
@@ -152,10 +175,10 @@ function createPersistingChannel(
|
|
|
152
175
|
totalUsage.inputTokens += event.tokens.input
|
|
153
176
|
totalUsage.outputTokens += event.tokens.output
|
|
154
177
|
if (event.model) totalUsage.model = event.model
|
|
155
|
-
|
|
178
|
+
flushDetached()
|
|
156
179
|
break
|
|
157
180
|
case 'done':
|
|
158
|
-
|
|
181
|
+
flushDetached()
|
|
159
182
|
break
|
|
160
183
|
}
|
|
161
184
|
}
|
|
@@ -328,13 +351,23 @@ export function checkForApprovals(
|
|
|
328
351
|
continue
|
|
329
352
|
}
|
|
330
353
|
|
|
354
|
+
// The approval marker is only trusted from a framework tool declared with
|
|
355
|
+
// `forwardsApproval` (the sub-agent delegating tools) AND carrying the
|
|
356
|
+
// non-forgeable `APPROVAL_REQUIRED` Symbol brand. A plain tool's output — or
|
|
357
|
+
// a delegating tool's LLM-shaped `result.object`, which an attacker can
|
|
358
|
+
// influence — is plain JSON and can never carry the Symbol, so it can never
|
|
359
|
+
// forge an approval/suspension.
|
|
360
|
+
if (!toolDef?.forwardsApproval) {
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
|
|
331
364
|
const tr = stepResult.toolResults.find(
|
|
332
365
|
(r) => r.toolCallId === tc.toolCallId
|
|
333
366
|
)
|
|
334
367
|
if (
|
|
335
368
|
tr?.result &&
|
|
336
369
|
typeof tr.result === 'object' &&
|
|
337
|
-
|
|
370
|
+
APPROVAL_REQUIRED in (tr.result as object)
|
|
338
371
|
) {
|
|
339
372
|
const r = tr.result as {
|
|
340
373
|
toolName: string
|
|
@@ -347,6 +380,7 @@ export function checkForApprovals(
|
|
|
347
380
|
toolCallId: string
|
|
348
381
|
toolName: string
|
|
349
382
|
args: unknown
|
|
383
|
+
reason?: string
|
|
350
384
|
runId: string
|
|
351
385
|
}>
|
|
352
386
|
}
|
|
@@ -357,7 +391,7 @@ export function checkForApprovals(
|
|
|
357
391
|
sub.toolCallId,
|
|
358
392
|
r.toolName,
|
|
359
393
|
r.args,
|
|
360
|
-
|
|
394
|
+
sub.reason,
|
|
361
395
|
sub.toolName,
|
|
362
396
|
sub.args,
|
|
363
397
|
r.agentRunId
|
|
@@ -512,7 +546,7 @@ function handleApprovals(
|
|
|
512
546
|
reason: err.reason,
|
|
513
547
|
runId,
|
|
514
548
|
}
|
|
515
|
-
channel.send(approvalEvent
|
|
549
|
+
channel.send(approvalEvent)
|
|
516
550
|
}
|
|
517
551
|
channel.send({ type: 'done' })
|
|
518
552
|
channel.close()
|
|
@@ -545,9 +579,22 @@ function handleCredentialRequests(
|
|
|
545
579
|
pendingApprovals,
|
|
546
580
|
})
|
|
547
581
|
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
582
|
+
// The __credentialRequired tool result is suppressed from the stream, so
|
|
583
|
+
// credential-request events (with the runId needed for /resume) are the
|
|
584
|
+
// client's signal to show Connect/Ignore buttons — mirroring how
|
|
585
|
+
// approval-request suspensions work.
|
|
586
|
+
for (const req of requests) {
|
|
587
|
+
channel.send({
|
|
588
|
+
type: 'credential-request',
|
|
589
|
+
toolCallId: req.toolCallId,
|
|
590
|
+
toolName: req.toolName,
|
|
591
|
+
args: req.args,
|
|
592
|
+
credentialName: req.credentialName,
|
|
593
|
+
credentialType: req.credentialType,
|
|
594
|
+
connectUrl: req.connectUrl,
|
|
595
|
+
runId,
|
|
596
|
+
})
|
|
597
|
+
}
|
|
551
598
|
channel.send({ type: 'done' })
|
|
552
599
|
channel.close()
|
|
553
600
|
})()
|
|
@@ -569,7 +616,14 @@ export async function streamAIAgent(
|
|
|
569
616
|
): Promise<string> {
|
|
570
617
|
const sessionMap = agentSessionMap ?? new Map<string, string>()
|
|
571
618
|
|
|
572
|
-
const normalizedInput =
|
|
619
|
+
const normalizedInput = {
|
|
620
|
+
...input,
|
|
621
|
+
resourceId: resolveOwnerResourceId(
|
|
622
|
+
params,
|
|
623
|
+
agentSessionScope(agentName),
|
|
624
|
+
input.resourceId
|
|
625
|
+
),
|
|
626
|
+
}
|
|
573
627
|
|
|
574
628
|
const streamContext: StreamContext = { channel, options }
|
|
575
629
|
// delegateState is attached after prepareAgentRun resolves the agent config
|
|
@@ -654,6 +708,7 @@ export async function streamAIAgent(
|
|
|
654
708
|
createdAt: new Date(),
|
|
655
709
|
updatedAt: new Date(),
|
|
656
710
|
})
|
|
711
|
+
options?.onRunCreated?.(runId)
|
|
657
712
|
|
|
658
713
|
if (storage) {
|
|
659
714
|
await storage.saveMessages(threadId, [userMessage])
|
|
@@ -694,7 +749,12 @@ export async function streamAIAgent(
|
|
|
694
749
|
}
|
|
695
750
|
)
|
|
696
751
|
|
|
697
|
-
const persistingChannel = createPersistingChannel(
|
|
752
|
+
const persistingChannel = createPersistingChannel(
|
|
753
|
+
channel,
|
|
754
|
+
storage,
|
|
755
|
+
threadId,
|
|
756
|
+
singletonServices.logger
|
|
757
|
+
)
|
|
698
758
|
|
|
699
759
|
const wrappedChannel =
|
|
700
760
|
allChannelMiddleware.length > 0
|
|
@@ -705,6 +765,25 @@ export async function streamAIAgent(
|
|
|
705
765
|
).channel as AIStreamChannel)
|
|
706
766
|
: persistingChannel
|
|
707
767
|
|
|
768
|
+
// Tool results carrying the __credentialRequired marker must never reach
|
|
769
|
+
// the client or persisted history: the run suspends with credential-request
|
|
770
|
+
// events instead (mirroring approvals), and leaving the tool call
|
|
771
|
+
// unresulted is what lets the client resume it after connecting.
|
|
772
|
+
const credentialFilteredChannel: AIStreamChannel = {
|
|
773
|
+
...wrappedChannel,
|
|
774
|
+
send: (event: AIStreamEvent) => {
|
|
775
|
+
if (
|
|
776
|
+
event.type === 'tool-result' &&
|
|
777
|
+
event.result !== null &&
|
|
778
|
+
typeof event.result === 'object' &&
|
|
779
|
+
'__credentialRequired' in event.result
|
|
780
|
+
) {
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
wrappedChannel.send(event)
|
|
784
|
+
},
|
|
785
|
+
}
|
|
786
|
+
|
|
708
787
|
// In delegate mode (default), suppress parent's text from reaching the client
|
|
709
788
|
// AFTER a sub-agent has been called. If the parent responds directly (no delegation),
|
|
710
789
|
// its text goes through normally. Sub-agent text bypasses this path entirely
|
|
@@ -716,18 +795,18 @@ export async function streamAIAgent(
|
|
|
716
795
|
}
|
|
717
796
|
const outputChannel = isDelegateMode
|
|
718
797
|
? {
|
|
719
|
-
...
|
|
798
|
+
...credentialFilteredChannel,
|
|
720
799
|
send: (event: AIStreamEvent) => {
|
|
721
800
|
if (
|
|
722
801
|
delegateState.delegated &&
|
|
723
802
|
(event.type === 'text-delta' || event.type === 'reasoning-delta')
|
|
724
803
|
)
|
|
725
804
|
return
|
|
726
|
-
|
|
805
|
+
credentialFilteredChannel.send(event)
|
|
727
806
|
},
|
|
728
807
|
delegateState,
|
|
729
808
|
}
|
|
730
|
-
:
|
|
809
|
+
: credentialFilteredChannel
|
|
731
810
|
|
|
732
811
|
try {
|
|
733
812
|
const loopResult = await runStreamStepLoop({
|
|
@@ -824,6 +903,15 @@ export async function resumeAIAgent(
|
|
|
824
903
|
if (!run) {
|
|
825
904
|
throw new Error(`No run found for runId ${input.runId}`)
|
|
826
905
|
}
|
|
906
|
+
assertResourceOwner(
|
|
907
|
+
resolveOwnerResourceId(
|
|
908
|
+
params,
|
|
909
|
+
agentSessionScope(run.agentName),
|
|
910
|
+
run.resourceId
|
|
911
|
+
),
|
|
912
|
+
run.resourceId,
|
|
913
|
+
'run'
|
|
914
|
+
)
|
|
827
915
|
|
|
828
916
|
const pending = run.pendingApprovals?.find(
|
|
829
917
|
(p) => p.toolCallId === input.toolCallId
|
|
@@ -834,12 +922,19 @@ export async function resumeAIAgent(
|
|
|
834
922
|
)
|
|
835
923
|
}
|
|
836
924
|
|
|
925
|
+
const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
|
|
926
|
+
|
|
927
|
+
// Gate before resolving the approval: recording it is a persisted side
|
|
928
|
+
// effect, so an unauthorized caller must not reach it. Run ownership alone is
|
|
929
|
+
// not enough — a grant revoked while the run was suspended must stop the
|
|
930
|
+
// caller from approving its pending tool calls.
|
|
931
|
+
await assertAgentAuthorized(agent, params, packageName)
|
|
932
|
+
|
|
837
933
|
await aiRunState.resolveApproval(
|
|
838
934
|
input.toolCallId,
|
|
839
935
|
input.approved ? 'approved' : 'denied'
|
|
840
936
|
)
|
|
841
937
|
|
|
842
|
-
const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
|
|
843
938
|
const { storage } = resolveMemoryServices(agent, singletonServices)
|
|
844
939
|
const memoryConfig = agent.memory
|
|
845
940
|
const agentRunner = singletonServices.aiAgentRunner
|
|
@@ -876,6 +971,16 @@ export async function resumeAIAgent(
|
|
|
876
971
|
])
|
|
877
972
|
}
|
|
878
973
|
|
|
974
|
+
channel.send({
|
|
975
|
+
type: 'tool-result',
|
|
976
|
+
toolCallId: input.toolCallId,
|
|
977
|
+
toolName:
|
|
978
|
+
pending.type === 'tool-call' || pending.type === 'credential-request'
|
|
979
|
+
? pending.toolName
|
|
980
|
+
: pending.agentName,
|
|
981
|
+
result: denialResult,
|
|
982
|
+
})
|
|
983
|
+
|
|
879
984
|
// Check remaining pending approvals
|
|
880
985
|
const updatedRun = await aiRunState.getRun(run.runId)
|
|
881
986
|
const remaining = updatedRun?.pendingApprovals ?? []
|
|
@@ -1155,7 +1260,8 @@ async function continueAfterToolResult(
|
|
|
1155
1260
|
const persistingChannel = createPersistingChannel(
|
|
1156
1261
|
channel,
|
|
1157
1262
|
storage,
|
|
1158
|
-
run.threadId
|
|
1263
|
+
run.threadId,
|
|
1264
|
+
singletonServices.logger
|
|
1159
1265
|
)
|
|
1160
1266
|
|
|
1161
1267
|
const wrappedChannel =
|
|
@@ -104,6 +104,20 @@ export interface AIAgentOutput {
|
|
|
104
104
|
}>
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* How an agent's threads/runs are owned and partitioned.
|
|
109
|
+
* - `'user'` (default): owner is the authenticated `session.userId`; the caller's
|
|
110
|
+
* `resourceId` becomes a sub-partition within that user (`userId:resourceId`).
|
|
111
|
+
* - `'org'`: owner is the authenticated `session.orgId` (`orgId:resourceId`), so
|
|
112
|
+
* threads are shared across everyone in the org. Requires a session with an org
|
|
113
|
+
* (e.g. Better Auth's `organization` plugin) — otherwise access is denied.
|
|
114
|
+
*
|
|
115
|
+
* The trusted principal is always the prefix, so a client-supplied `resourceId`
|
|
116
|
+
* can sub-divide within the caller's own boundary but can never widen access to
|
|
117
|
+
* another user's or org's threads.
|
|
118
|
+
*/
|
|
119
|
+
export type SessionScope = 'user' | 'org'
|
|
120
|
+
|
|
107
121
|
export interface AIAgentToolDef {
|
|
108
122
|
name: string
|
|
109
123
|
description: string
|
|
@@ -111,6 +125,14 @@ export interface AIAgentToolDef {
|
|
|
111
125
|
execute: (input: unknown) => Promise<unknown>
|
|
112
126
|
needsApproval?: boolean
|
|
113
127
|
approvalDescriptionFn?: (input: unknown) => Promise<string>
|
|
128
|
+
/**
|
|
129
|
+
* Set only by the framework on sub-agent delegating tools. Such a tool may
|
|
130
|
+
* legitimately return an `__approvalRequired` marker to forward a nested
|
|
131
|
+
* sub-agent approval. The marker is honored ONLY from a tool with this flag —
|
|
132
|
+
* a plain tool's output (which an attacker may influence) can never forge an
|
|
133
|
+
* approval request. See `checkForApprovals`.
|
|
134
|
+
*/
|
|
135
|
+
forwardsApproval?: boolean
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
export interface PikkuAIMiddlewareHooks<
|
|
@@ -204,6 +226,7 @@ export type AIAgentMemoryConfig = {
|
|
|
204
226
|
export type CoreAIAgent<
|
|
205
227
|
PikkuPermission = CorePikkuPermission<any, any>,
|
|
206
228
|
PikkuMiddleware = CorePikkuMiddleware<any>,
|
|
229
|
+
Scope extends string = string,
|
|
207
230
|
> = {
|
|
208
231
|
name: string
|
|
209
232
|
description: string
|
|
@@ -214,8 +237,11 @@ export type CoreAIAgent<
|
|
|
214
237
|
goal: string
|
|
215
238
|
model: string
|
|
216
239
|
temperature?: number
|
|
240
|
+
/** Ownership/partitioning of this agent's threads and runs. Defaults to `'user'`. */
|
|
241
|
+
sessionScope?: SessionScope
|
|
217
242
|
tools?: unknown[]
|
|
218
243
|
agents?: unknown[]
|
|
244
|
+
workflows?: unknown[]
|
|
219
245
|
agentMode?: 'delegate' | 'supervise'
|
|
220
246
|
memory?: AIAgentMemoryConfig
|
|
221
247
|
maxSteps?: number
|
|
@@ -234,6 +260,23 @@ export type CoreAIAgent<
|
|
|
234
260
|
middleware?: PikkuMiddleware[]
|
|
235
261
|
channelMiddleware?: CorePikkuChannelMiddleware<any, any>[]
|
|
236
262
|
aiMiddleware?: PikkuAIMiddlewareHooks<any, any>[]
|
|
263
|
+
/**
|
|
264
|
+
* Whether a session is required to run this agent. Defaults to `false`, since
|
|
265
|
+
* agents are commonly invoked from an already-authenticated `pikkuFunc` or
|
|
266
|
+
* from genuinely sessionless contexts (crons, queue workers). Set `true` to
|
|
267
|
+
* require a session at the agent itself. `scopes` and `permissions` are
|
|
268
|
+
* enforced either way.
|
|
269
|
+
*/
|
|
270
|
+
auth?: boolean
|
|
271
|
+
/**
|
|
272
|
+
* Scopes the session must hold to run this agent. All of them are required
|
|
273
|
+
* (AND), and they are checked before `permissions` — unlike permissions,
|
|
274
|
+
* which OR together, a scope can only narrow access.
|
|
275
|
+
*
|
|
276
|
+
* Narrowed to the generated `ScopeId` union in a project's own
|
|
277
|
+
* `pikku-types.gen.ts`, so an undeclared scope is a compile error.
|
|
278
|
+
*/
|
|
279
|
+
scopes?: Scope[]
|
|
237
280
|
permissions?: CorePermissionGroup<PikkuPermission>
|
|
238
281
|
}
|
|
239
282
|
|
|
@@ -275,6 +318,7 @@ export type AIStreamEvent =
|
|
|
275
318
|
toolName: string
|
|
276
319
|
args: unknown
|
|
277
320
|
reason?: string
|
|
321
|
+
runId?: string
|
|
278
322
|
agent?: string
|
|
279
323
|
session?: string
|
|
280
324
|
}
|
|
@@ -395,6 +439,17 @@ export interface AgentRunService {
|
|
|
395
439
|
listThreads(options?: {
|
|
396
440
|
agentName?: string
|
|
397
441
|
resourceId?: string
|
|
442
|
+
/**
|
|
443
|
+
* Restrict results to threads owned by one of these session principals. A
|
|
444
|
+
* thread matches when its `resourceId` is the principal itself or one of its
|
|
445
|
+
* `principal:` sub-partitions, mirroring the composition
|
|
446
|
+
* `resolveOwnerResourceId` writes.
|
|
447
|
+
*
|
|
448
|
+
* Unlike `resourceId`, which is an optional exact-match filter, this is an
|
|
449
|
+
* authorization constraint: an empty array matches nothing. Callers exposing
|
|
450
|
+
* threads over the wire must derive it from the session, never from input.
|
|
451
|
+
*/
|
|
452
|
+
owners?: string[]
|
|
398
453
|
limit?: number
|
|
399
454
|
offset?: number
|
|
400
455
|
}): Promise<AIThread[]>
|
|
@@ -413,6 +468,7 @@ export type AIAgentMeta = Record<
|
|
|
413
468
|
| 'output'
|
|
414
469
|
| 'tools'
|
|
415
470
|
| 'agents'
|
|
471
|
+
| 'workflows'
|
|
416
472
|
| 'middleware'
|
|
417
473
|
| 'channelMiddleware'
|
|
418
474
|
| 'aiMiddleware'
|
|
@@ -420,6 +476,7 @@ export type AIAgentMeta = Record<
|
|
|
420
476
|
> & {
|
|
421
477
|
tools?: string[]
|
|
422
478
|
agents?: string[]
|
|
479
|
+
workflows?: string[]
|
|
423
480
|
inputSchema: string | null
|
|
424
481
|
outputSchema: string | null
|
|
425
482
|
workingMemorySchema: string | null
|
|
@@ -4,6 +4,7 @@ export {
|
|
|
4
4
|
agentResume,
|
|
5
5
|
agentApprove,
|
|
6
6
|
} from './ai-agent-helpers.js'
|
|
7
|
+
export { wrapChannelWithAGUI, type AGUIEvent } from './ai-agent-agui.js'
|
|
7
8
|
export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js'
|
|
8
9
|
export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js'
|
|
9
10
|
export { voiceInput } from './voice-input.js'
|
|
@@ -13,6 +14,10 @@ export {
|
|
|
13
14
|
type StreamAIAgentOptions,
|
|
14
15
|
ToolApprovalRequired,
|
|
15
16
|
ToolCredentialRequired,
|
|
17
|
+
canAccessThread,
|
|
18
|
+
isOwnedByPrincipal,
|
|
19
|
+
sessionPrincipals,
|
|
20
|
+
threadOwnerConstraint,
|
|
16
21
|
} from './ai-agent-prepare.js'
|
|
17
22
|
export {
|
|
18
23
|
addAIAgent,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { voiceInput } from './voice-input.js'
|
|
5
|
+
import type { AIContentPart, AIMessage } from './ai-agent.types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A runner whose `transcribe` reads instance state through `this`, mirroring the
|
|
9
|
+
* real `VercelAIAgentRunner` where `transcribe` calls `this.getModel(...)`. If
|
|
10
|
+
* the middleware grabs the method as a bare reference the receiver is lost and
|
|
11
|
+
* `this` is undefined — which is exactly the regression this guards.
|
|
12
|
+
*/
|
|
13
|
+
class ThisDependentRunner {
|
|
14
|
+
private readonly transcript = 'the transcribed spoken words'
|
|
15
|
+
|
|
16
|
+
async transcribe() {
|
|
17
|
+
return {
|
|
18
|
+
text: this.transcript,
|
|
19
|
+
segments: [],
|
|
20
|
+
language: 'en',
|
|
21
|
+
durationInSeconds: 1,
|
|
22
|
+
warnings: [],
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const audioMessage = (): AIMessage => ({
|
|
28
|
+
id: 'm1',
|
|
29
|
+
role: 'user',
|
|
30
|
+
content: [
|
|
31
|
+
{ type: 'file', mediaType: 'audio/wav', data: 'AAAA' },
|
|
32
|
+
] as AIContentPart[],
|
|
33
|
+
createdAt: new Date(0),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('voiceInput', () => {
|
|
37
|
+
test('transcribes an audio part by calling the runner with its receiver intact', async () => {
|
|
38
|
+
const mw = voiceInput({ model: 'mock/whisper' })
|
|
39
|
+
const services = { aiAgentRunner: new ThisDependentRunner() }
|
|
40
|
+
|
|
41
|
+
const result = await mw.modifyInput!(services as any, {
|
|
42
|
+
messages: [audioMessage()],
|
|
43
|
+
instructions: 'sys',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const last = result.messages[result.messages.length - 1]!
|
|
47
|
+
const parts = last.content as AIContentPart[]
|
|
48
|
+
assert.equal(parts.length, 1)
|
|
49
|
+
assert.equal(parts[0]!.type, 'text')
|
|
50
|
+
assert.equal(
|
|
51
|
+
(parts[0] as { text: string }).text,
|
|
52
|
+
'the transcribed spoken words'
|
|
53
|
+
)
|
|
54
|
+
assert.ok(!parts.some((p) => p.type === 'file'))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('leaves the message untouched when there is no audio part', async () => {
|
|
58
|
+
const mw = voiceInput({ model: 'mock/whisper' })
|
|
59
|
+
const services = { aiAgentRunner: new ThisDependentRunner() }
|
|
60
|
+
const messages: AIMessage[] = [
|
|
61
|
+
{
|
|
62
|
+
id: 'm1',
|
|
63
|
+
role: 'user',
|
|
64
|
+
content: 'just text',
|
|
65
|
+
createdAt: new Date(0),
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const result = await mw.modifyInput!(services as any, {
|
|
70
|
+
messages,
|
|
71
|
+
instructions: 'sys',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
assert.equal(result.messages[0]!.content, 'just text')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('throws when an audio part is present but no model is configured', async () => {
|
|
78
|
+
const mw = voiceInput({})
|
|
79
|
+
const services = { aiAgentRunner: new ThisDependentRunner() }
|
|
80
|
+
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
() =>
|
|
83
|
+
mw.modifyInput!(services as any, {
|
|
84
|
+
messages: [audioMessage()],
|
|
85
|
+
instructions: 'sys',
|
|
86
|
+
}),
|
|
87
|
+
/voiceInput requires a transcription model/
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
|
|
2
2
|
import { pikkuAIMiddleware } from '../../types/core.types.js'
|
|
3
|
+
import { safeFetch } from '../../utils/safe-fetch.js'
|
|
3
4
|
import type { AIContentPart } from './ai-agent.types.js'
|
|
4
5
|
|
|
5
6
|
function base64ToUint8Array(base64: string): Uint8Array {
|
|
@@ -13,45 +14,11 @@ function base64ToUint8Array(base64: string): Uint8Array {
|
|
|
13
14
|
|
|
14
15
|
const MAX_AUDIO_SIZE = 50 * 1024 * 1024
|
|
15
16
|
|
|
16
|
-
// Portable SSRF guard: @pikku/core runs in edge runtimes (CF Workers) with no
|
|
17
|
-
// Node `dns`, so we cannot resolve hostnames to check for private targets.
|
|
18
|
-
// Reject the obvious internal literals; callers wanting stricter control pass
|
|
19
|
-
// an explicit `allowedAudioHosts` allowlist. (Does not defend against a public
|
|
20
|
-
// hostname that resolves to a private IP / DNS rebinding — out of reach here.)
|
|
21
|
-
function isPrivateHost(hostname: string): boolean {
|
|
22
|
-
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
23
|
-
if (host === 'localhost' || host === '0.0.0.0' || host === '::1') return true
|
|
24
|
-
if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
|
|
25
|
-
return true
|
|
26
|
-
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/)
|
|
27
|
-
if (v4) {
|
|
28
|
-
const [a, b] = [Number(v4[1]), Number(v4[2])]
|
|
29
|
-
if (a === 127 || a === 10 || a === 0) return true
|
|
30
|
-
if (a === 169 && b === 254) return true // link-local incl. cloud metadata
|
|
31
|
-
if (a === 172 && b >= 16 && b <= 31) return true
|
|
32
|
-
if (a === 192 && b === 168) return true
|
|
33
|
-
}
|
|
34
|
-
return false
|
|
35
|
-
}
|
|
36
|
-
|
|
37
17
|
async function fetchAsUint8Array(
|
|
38
18
|
url: string,
|
|
39
19
|
allowedAudioHosts?: string[]
|
|
40
20
|
): Promise<Uint8Array> {
|
|
41
|
-
const
|
|
42
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
43
|
-
throw new Error('Only HTTP(S) URLs are supported for audio')
|
|
44
|
-
}
|
|
45
|
-
if (allowedAudioHosts) {
|
|
46
|
-
if (!allowedAudioHosts.includes(parsed.hostname)) {
|
|
47
|
-
throw new Error(`Audio URL host is not allowed: ${parsed.hostname}`)
|
|
48
|
-
}
|
|
49
|
-
} else if (isPrivateHost(parsed.hostname)) {
|
|
50
|
-
throw new Error(
|
|
51
|
-
`Refusing to fetch audio from a private/internal host: ${parsed.hostname}`
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
const response = await fetch(url)
|
|
21
|
+
const response = await safeFetch(url, {}, { allowedHosts: allowedAudioHosts })
|
|
55
22
|
const contentLength = response.headers.get('content-length')
|
|
56
23
|
if (contentLength && parseInt(contentLength, 10) > MAX_AUDIO_SIZE) {
|
|
57
24
|
throw new Error('Audio file exceeds maximum size')
|
|
@@ -70,10 +37,12 @@ export const voiceInput = (config?: {
|
|
|
70
37
|
}) =>
|
|
71
38
|
pikkuAIMiddleware({
|
|
72
39
|
modifyInput: async (services, { messages, instructions }) => {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
40
|
+
const aiAgentRunner = (
|
|
41
|
+
services as {
|
|
42
|
+
aiAgentRunner?: AIAgentRunnerService
|
|
43
|
+
}
|
|
44
|
+
).aiAgentRunner
|
|
45
|
+
if (!aiAgentRunner?.transcribe) return { messages, instructions }
|
|
77
46
|
|
|
78
47
|
const last = messages[messages.length - 1]
|
|
79
48
|
if (!last || last.role !== 'user' || typeof last.content === 'string') {
|
|
@@ -105,7 +74,7 @@ export const voiceInput = (config?: {
|
|
|
105
74
|
const audioData = p.data
|
|
106
75
|
? base64ToUint8Array(p.data)
|
|
107
76
|
: await fetchAsUint8Array(p.url!, config.allowedAudioHosts)
|
|
108
|
-
const result = await
|
|
77
|
+
const result = await aiAgentRunner.transcribe({
|
|
109
78
|
model: config.model,
|
|
110
79
|
audio: audioData,
|
|
111
80
|
...(config.language
|
|
@@ -102,11 +102,7 @@ export const processMessageHandlers = (
|
|
|
102
102
|
routingProperty,
|
|
103
103
|
routerValue
|
|
104
104
|
)
|
|
105
|
-
const {
|
|
106
|
-
packageName,
|
|
107
|
-
middleware: routeInheritedMiddleware,
|
|
108
|
-
permissions: inheritedPermissions,
|
|
109
|
-
} = routeMeta
|
|
105
|
+
const { packageName, middleware: routeInheritedMiddleware } = routeMeta
|
|
110
106
|
|
|
111
107
|
const pikkuFuncId = routeMeta.pikkuFuncId
|
|
112
108
|
|
|
@@ -132,8 +128,6 @@ export const processMessageHandlers = (
|
|
|
132
128
|
// Inherited middleware comes from metadata (tag groups, non-inline wire)
|
|
133
129
|
const inheritedMiddleware = routeInheritedMiddleware || []
|
|
134
130
|
|
|
135
|
-
const wirePermissions = isWrapper ? onMessage.permissions : undefined
|
|
136
|
-
|
|
137
131
|
// Create unique cache key that includes routing info to avoid cache collisions
|
|
138
132
|
// when multiple message handlers use the same function
|
|
139
133
|
const cacheKey = routingProperty
|
|
@@ -150,8 +144,6 @@ export const processMessageHandlers = (
|
|
|
150
144
|
wireMiddleware,
|
|
151
145
|
inheritedChannelMiddleware: channelMeta?.channelMiddleware,
|
|
152
146
|
wireChannelMiddleware: wireChannelMiddleware as any,
|
|
153
|
-
inheritedPermissions,
|
|
154
|
-
wirePermissions,
|
|
155
147
|
coerceDataFromSchema: true,
|
|
156
148
|
tags: channelConfig.tags,
|
|
157
149
|
sessionService: userSession,
|
|
@@ -69,17 +69,17 @@ describe('combineChannelMiddleware', () => {
|
|
|
69
69
|
void execution
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
-
test('
|
|
72
|
+
test('caches the statically-resolved inherited middleware until the cache is cleared', () => {
|
|
73
73
|
const shared = async () => {}
|
|
74
74
|
addChannelMiddleware('chat:outbound', [shared])
|
|
75
75
|
|
|
76
76
|
const first = combineChannelMiddleware('channel', 'cached-1', {
|
|
77
77
|
wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
|
|
78
|
-
wireChannelMiddleware: [shared],
|
|
79
78
|
})
|
|
80
79
|
|
|
81
80
|
assert.deepEqual(first, [shared])
|
|
82
81
|
|
|
82
|
+
// Re-registering after the first resolve does not change the cached result.
|
|
83
83
|
addChannelMiddleware('chat:outbound', [async () => {}])
|
|
84
84
|
|
|
85
85
|
const cached = combineChannelMiddleware('channel', 'cached-1', {
|
|
@@ -97,6 +97,32 @@ describe('combineChannelMiddleware', () => {
|
|
|
97
97
|
assert.notStrictEqual(refreshed[0], shared)
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
test('does not cache per-run wireChannelMiddleware across calls (C4 cross-run leak)', () => {
|
|
101
|
+
const shared = async () => {}
|
|
102
|
+
addChannelMiddleware('chat:outbound', [shared])
|
|
103
|
+
|
|
104
|
+
const runA = async () => {}
|
|
105
|
+
const first = combineChannelMiddleware('agent', 'stream:bot', {
|
|
106
|
+
wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
|
|
107
|
+
wireChannelMiddleware: [runA],
|
|
108
|
+
})
|
|
109
|
+
assert.deepEqual(first, [shared, runA])
|
|
110
|
+
|
|
111
|
+
const runB = async () => {}
|
|
112
|
+
const second = combineChannelMiddleware('agent', 'stream:bot', {
|
|
113
|
+
wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
|
|
114
|
+
wireChannelMiddleware: [runB],
|
|
115
|
+
})
|
|
116
|
+
assert.deepEqual(second, [shared, runB])
|
|
117
|
+
assert.strictEqual(second[1], runB)
|
|
118
|
+
assert.notStrictEqual(second[1], runA)
|
|
119
|
+
|
|
120
|
+
const third = combineChannelMiddleware('agent', 'stream:bot', {
|
|
121
|
+
wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
|
|
122
|
+
})
|
|
123
|
+
assert.deepEqual(third, [shared])
|
|
124
|
+
})
|
|
125
|
+
|
|
100
126
|
test('ignores missing named middleware references', () => {
|
|
101
127
|
const combined = combineChannelMiddleware('channel', 'missing-wire', {
|
|
102
128
|
wireInheritedChannelMiddleware: [{ type: 'wire', name: 'missing' }],
|