@vellumai/assistant 0.8.12-dev.202606131247.4d64b75 → 0.8.12-dev.202606131450.c6f2ffd
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/openapi.yaml +2 -1
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +91 -0
- package/src/__tests__/pending-interactions-resolved-event.test.ts +62 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +2 -0
- package/src/api/events/assistant-thinking-delta.ts +10 -0
- package/src/cli/commands/credentials.ts +16 -18
- package/src/daemon/conversation-agent-loop-handlers.ts +1 -0
- package/src/daemon/handlers/shared.ts +19 -14
- package/src/daemon/host-cu-proxy.ts +15 -12
- package/src/daemon/host-file-proxy.ts +15 -12
- package/src/daemon/host-transfer-proxy.ts +30 -24
- package/src/daemon/message-types/messages.ts +2 -10
- package/src/runtime/pending-interactions.ts +20 -1
- package/src/runtime/routes/approval-routes.ts +35 -8
- package/src/runtime/routes/credential-prompt-routes.ts +2 -0
package/openapi.yaml
CHANGED
|
@@ -10561,6 +10561,8 @@ paths:
|
|
|
10561
10561
|
- hostPattern
|
|
10562
10562
|
- injectionType
|
|
10563
10563
|
additionalProperties: false
|
|
10564
|
+
conversationId:
|
|
10565
|
+
type: string
|
|
10564
10566
|
required:
|
|
10565
10567
|
- service
|
|
10566
10568
|
- field
|
|
@@ -20904,7 +20906,6 @@ paths:
|
|
|
20904
20906
|
type: string
|
|
20905
20907
|
required:
|
|
20906
20908
|
- requestId
|
|
20907
|
-
- conversationId
|
|
20908
20909
|
- kind
|
|
20909
20910
|
additionalProperties: false
|
|
20910
20911
|
description: All pending interactions (returned when no filters given)
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
8
8
|
|
|
9
9
|
import type { Conversation } from "../daemon/conversation.js";
|
|
10
10
|
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
11
|
+
import type { SecretPromptResult } from "../permissions/secret-prompter.js";
|
|
11
12
|
|
|
12
13
|
mock.module("../util/logger.js", () => ({
|
|
13
14
|
getLogger: () =>
|
|
@@ -196,6 +197,7 @@ function makeIdleSession(opts?: {
|
|
|
196
197
|
pendingInteractions.resolve(requestId);
|
|
197
198
|
opts?.onConfirmation?.(requestId, decision);
|
|
198
199
|
},
|
|
200
|
+
hasPendingSecret: () => true,
|
|
199
201
|
handleSecretResponse: (
|
|
200
202
|
requestId: string,
|
|
201
203
|
value?: string,
|
|
@@ -593,6 +595,54 @@ describe("standalone approval endpoints — HTTP layer", () => {
|
|
|
593
595
|
await stopServer();
|
|
594
596
|
});
|
|
595
597
|
|
|
598
|
+
test("resolves a conversation-less secret request via its resolver", async () => {
|
|
599
|
+
/**
|
|
600
|
+
* The CLI `credentials prompt` command registers a conversation-less
|
|
601
|
+
* secret interaction whose resolver lives in pendingInteractions. POST
|
|
602
|
+
* /v1/secret must drive that resolver directly — with no owning
|
|
603
|
+
* conversation — so the standalone prompt completes instead of only
|
|
604
|
+
* timing out.
|
|
605
|
+
*/
|
|
606
|
+
// GIVEN a running server
|
|
607
|
+
await startServer(() => makeIdleSession());
|
|
608
|
+
|
|
609
|
+
// AND a conversation-less secret interaction whose resolver is captured
|
|
610
|
+
let resolved: SecretPromptResult | undefined;
|
|
611
|
+
pendingInteractions.register("standalone-secret-1", {
|
|
612
|
+
kind: "secret",
|
|
613
|
+
rpcResolve: (value: unknown) => {
|
|
614
|
+
resolved = value as SecretPromptResult;
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// WHEN the secret is submitted for that requestId
|
|
619
|
+
const res = await fetch(url("secret"), {
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
622
|
+
body: JSON.stringify({
|
|
623
|
+
requestId: "standalone-secret-1",
|
|
624
|
+
value: "cli-secret-value",
|
|
625
|
+
delivery: "store",
|
|
626
|
+
}),
|
|
627
|
+
});
|
|
628
|
+
const body = (await res.json()) as { accepted: boolean };
|
|
629
|
+
|
|
630
|
+
// THEN the request is accepted
|
|
631
|
+
expect(res.status).toBe(200);
|
|
632
|
+
expect(body.accepted).toBe(true);
|
|
633
|
+
|
|
634
|
+
// AND the standalone resolver receives the value and delivery
|
|
635
|
+
expect(resolved).toEqual({
|
|
636
|
+
value: "cli-secret-value",
|
|
637
|
+
delivery: "store",
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// AND the interaction is removed after resolution
|
|
641
|
+
expect(pendingInteractions.get("standalone-secret-1")).toBeUndefined();
|
|
642
|
+
|
|
643
|
+
await stopServer();
|
|
644
|
+
});
|
|
645
|
+
|
|
596
646
|
test("returns 404 for unknown requestId", async () => {
|
|
597
647
|
await startServer(() => makeIdleSession());
|
|
598
648
|
|
|
@@ -638,6 +688,47 @@ describe("standalone approval endpoints — HTTP layer", () => {
|
|
|
638
688
|
|
|
639
689
|
await stopServer();
|
|
640
690
|
});
|
|
691
|
+
|
|
692
|
+
test("rejects a non-secret requestId without consuming it", async () => {
|
|
693
|
+
/**
|
|
694
|
+
* /v1/secret only settles secret prompts. A confirmation (or any other
|
|
695
|
+
* interaction kind) posted here from stale client state must be rejected
|
|
696
|
+
* so its real approval endpoint still finds an intact pending interaction
|
|
697
|
+
* rather than one consumed and resolved with a SecretPromptResult.
|
|
698
|
+
*/
|
|
699
|
+
// GIVEN a running server
|
|
700
|
+
await startServer(() => makeIdleSession());
|
|
701
|
+
|
|
702
|
+
// AND a pending confirmation interaction whose resolver would be corrupted
|
|
703
|
+
let resolverCalled = false;
|
|
704
|
+
pendingInteractions.register("confirm-not-secret", {
|
|
705
|
+
conversationId: "conv-1",
|
|
706
|
+
kind: "confirmation",
|
|
707
|
+
rpcResolve: () => {
|
|
708
|
+
resolverCalled = true;
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// WHEN that confirmation's requestId is posted to /v1/secret
|
|
713
|
+
const res = await fetch(url("secret"), {
|
|
714
|
+
method: "POST",
|
|
715
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
716
|
+
body: JSON.stringify({
|
|
717
|
+
requestId: "confirm-not-secret",
|
|
718
|
+
value: "leaked",
|
|
719
|
+
delivery: "store",
|
|
720
|
+
}),
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// THEN the request is rejected
|
|
724
|
+
expect(res.status).toBe(404);
|
|
725
|
+
|
|
726
|
+
// AND the confirmation interaction is left intact and unresolved
|
|
727
|
+
expect(pendingInteractions.get("confirm-not-secret")).toBeDefined();
|
|
728
|
+
expect(resolverCalled).toBe(false);
|
|
729
|
+
|
|
730
|
+
await stopServer();
|
|
731
|
+
});
|
|
641
732
|
});
|
|
642
733
|
|
|
643
734
|
// ── POST /v1/trust-rules ─────────────────────────────────────────────
|
|
@@ -102,6 +102,28 @@ describe("pendingInteractions.resolve emits interaction_resolved", () => {
|
|
|
102
102
|
expect(publishedMessages).toHaveLength(0);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test("no event is emitted for a conversation-less interaction", () => {
|
|
106
|
+
/**
|
|
107
|
+
* Conversation-less interactions (e.g. the CLI `credentials prompt`
|
|
108
|
+
* command) resolve through their own resolver rather than a conversation.
|
|
109
|
+
* The `interaction_resolved` envelope requires a conversationId, so the
|
|
110
|
+
* tracker skips the broadcast instead of emitting an invalid event.
|
|
111
|
+
*/
|
|
112
|
+
// GIVEN a registered interaction with no owning conversation
|
|
113
|
+
pendingInteractions.register("req-detached", { kind: "secret" });
|
|
114
|
+
|
|
115
|
+
// WHEN it is resolved
|
|
116
|
+
const returned = pendingInteractions.resolve("req-detached", "answered");
|
|
117
|
+
|
|
118
|
+
// THEN the entry is still returned to its caller
|
|
119
|
+
expect(returned).toBeDefined();
|
|
120
|
+
|
|
121
|
+
// AND no interaction_resolved envelope is published
|
|
122
|
+
expect(
|
|
123
|
+
publishedMessages.filter((m) => m.type === "interaction_resolved"),
|
|
124
|
+
).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
105
127
|
test("a single resolve emits exactly one event", () => {
|
|
106
128
|
pendingInteractions.register("req-once", {
|
|
107
129
|
conversationId: "conv-e",
|
|
@@ -186,4 +208,44 @@ describe("removeByConversation emits interaction_resolved per entry", () => {
|
|
|
186
208
|
.state,
|
|
187
209
|
).toBe("cancelled");
|
|
188
210
|
});
|
|
211
|
+
|
|
212
|
+
test("settles a swept secret prompt's resolver with a cancelled result", () => {
|
|
213
|
+
/**
|
|
214
|
+
* A secret prompt blocks its caller (the CLI `credentials prompt` command
|
|
215
|
+
* or the in-conversation SecretPrompter) on `rpcResolve`. Unlike questions
|
|
216
|
+
* (abort-signal teardown) and confirmations (denyAllPendingConfirmations),
|
|
217
|
+
* nothing else settles a secret when it is superseded, so removing the
|
|
218
|
+
* entry alone would hang the caller until its IPC client times out.
|
|
219
|
+
*/
|
|
220
|
+
// GIVEN a pending secret whose caller is blocked on rpcResolve
|
|
221
|
+
const resolved: unknown[] = [];
|
|
222
|
+
pendingInteractions.register("secret-sweep", {
|
|
223
|
+
conversationId: "conv-sweep",
|
|
224
|
+
kind: "secret",
|
|
225
|
+
rpcResolve: (value) => resolved.push(value),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// WHEN a new user message supersedes the conversation's interactions
|
|
229
|
+
pendingInteractions.removeByConversation("conv-sweep");
|
|
230
|
+
|
|
231
|
+
// THEN the secret resolver is settled once with a cancelled result
|
|
232
|
+
expect(resolved).toEqual([{ value: null, delivery: "store" }]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("does not invoke a swept confirmation's resolver with a secret result", () => {
|
|
236
|
+
// GIVEN a pending confirmation carrying an rpcResolve callback
|
|
237
|
+
const resolved: unknown[] = [];
|
|
238
|
+
pendingInteractions.register("conf-sweep", {
|
|
239
|
+
conversationId: "conv-conf",
|
|
240
|
+
kind: "confirmation",
|
|
241
|
+
rpcResolve: (value) => resolved.push(value),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// WHEN the conversation is superseded
|
|
245
|
+
pendingInteractions.removeByConversation("conv-conf");
|
|
246
|
+
|
|
247
|
+
// THEN the confirmation resolver is left untouched — only secret prompts
|
|
248
|
+
// are settled with a SecretPromptResult-shaped value here.
|
|
249
|
+
expect(resolved).toHaveLength(0);
|
|
250
|
+
});
|
|
189
251
|
});
|
|
@@ -138,6 +138,7 @@ describe("SSE HTTP parity — streaming/delta message types", () => {
|
|
|
138
138
|
type: "assistant_thinking_delta" as const,
|
|
139
139
|
thinking: "Let me reason through this...",
|
|
140
140
|
conversationId: "conv-thinking-test",
|
|
141
|
+
timestampMs: 1_700_000_000_000,
|
|
141
142
|
};
|
|
142
143
|
const event = await publishAndReadFrame("parity-thinking-delta", msg);
|
|
143
144
|
|
|
@@ -145,6 +146,7 @@ describe("SSE HTTP parity — streaming/delta message types", () => {
|
|
|
145
146
|
const m = event.message as typeof msg;
|
|
146
147
|
expect(m.thinking).toBe("Let me reason through this...");
|
|
147
148
|
expect(m.conversationId).toBe("conv-thinking-test");
|
|
149
|
+
expect(m.timestampMs).toBe(1_700_000_000_000);
|
|
148
150
|
});
|
|
149
151
|
|
|
150
152
|
// ── tool_input_delta ─────────────────────────────────────────────────────
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* Absent on streams produced by older daemons that pre-date the anchor
|
|
13
13
|
* protocol.
|
|
14
14
|
*
|
|
15
|
+
* `timestampMs` is the daemon's wall-clock emission time for the delta. The
|
|
16
|
+
* transport envelope (`AssistantEvent.emittedAt`) carries the same instant,
|
|
17
|
+
* but it is dropped once the inner message is unwrapped for persistence,
|
|
18
|
+
* debug buffers, and backend storage — so the timestamp is carried on the
|
|
19
|
+
* payload itself to make per-delta timing observable wherever the message
|
|
20
|
+
* travels.
|
|
21
|
+
*
|
|
15
22
|
* Only emitted when thinking streaming is enabled for the turn; turns that
|
|
16
23
|
* suppress reasoning output produce none.
|
|
17
24
|
*
|
|
@@ -26,6 +33,9 @@ export const AssistantThinkingDeltaEventSchema = z.object({
|
|
|
26
33
|
thinking: z.string(),
|
|
27
34
|
messageId: z.string().optional(),
|
|
28
35
|
conversationId: z.string().optional(),
|
|
36
|
+
/** Epoch milliseconds (`Date.now()`) at which the daemon emitted this
|
|
37
|
+
* delta. Absent on streams produced by daemons that pre-date this field. */
|
|
38
|
+
timestampMs: z.number().optional(),
|
|
29
39
|
});
|
|
30
40
|
|
|
31
41
|
export type AssistantThinkingDeltaEvent = z.infer<
|
|
@@ -5,6 +5,7 @@ import type { CredentialPromptResult } from "../../runtime/routes/credential-pro
|
|
|
5
5
|
import { registerCommand } from "../lib/register-command.js";
|
|
6
6
|
import { log } from "../logger.js";
|
|
7
7
|
import { shouldOutputJson, writeOutput } from "../output.js";
|
|
8
|
+
import { tryResolveConversationId } from "../utils/conversation-id.js";
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Format-aware error output
|
|
@@ -114,8 +115,7 @@ export function registerCredentialsCommand(program: Command): void {
|
|
|
114
115
|
description:
|
|
115
116
|
"Manage credentials in the encrypted vault (API keys, tokens, passwords)",
|
|
116
117
|
build: (credential) => {
|
|
117
|
-
credential
|
|
118
|
-
.option("--json", "Machine-readable compact JSON output");
|
|
118
|
+
credential.option("--json", "Machine-readable compact JSON output");
|
|
119
119
|
|
|
120
120
|
credential.addHelpText(
|
|
121
121
|
"after",
|
|
@@ -224,9 +224,7 @@ Examples:
|
|
|
224
224
|
|
|
225
225
|
credential
|
|
226
226
|
.command("status")
|
|
227
|
-
.description(
|
|
228
|
-
"Show the active credential backend and its configuration",
|
|
229
|
-
)
|
|
227
|
+
.description("Show the active credential backend and its configuration")
|
|
230
228
|
.addHelpText(
|
|
231
229
|
"after",
|
|
232
230
|
`
|
|
@@ -248,9 +246,8 @@ Examples:
|
|
|
248
246
|
$ assistant credentials status --json`,
|
|
249
247
|
)
|
|
250
248
|
.action(async (_opts: Record<string, unknown>, cmd: Command) => {
|
|
251
|
-
const r =
|
|
252
|
-
"credentials_status"
|
|
253
|
-
);
|
|
249
|
+
const r =
|
|
250
|
+
await cliIpcCall<CredentialsStatusResponse>("credentials_status");
|
|
254
251
|
if (!r.ok) {
|
|
255
252
|
return exitFromIpcResult(
|
|
256
253
|
r as { ok: false; error?: string; statusCode?: number },
|
|
@@ -286,7 +283,10 @@ Examples:
|
|
|
286
283
|
credential
|
|
287
284
|
.command("set <value>")
|
|
288
285
|
.description("Store a secret and create or update its metadata")
|
|
289
|
-
.requiredOption(
|
|
286
|
+
.requiredOption(
|
|
287
|
+
"--service <service>",
|
|
288
|
+
"Service namespace (e.g. google)",
|
|
289
|
+
)
|
|
290
290
|
.requiredOption("--field <field>", "Field name (e.g. client_secret)")
|
|
291
291
|
.option("--label <label>", 'Human-friendly label (e.g. "prod", "work")')
|
|
292
292
|
.option(
|
|
@@ -345,7 +345,8 @@ Examples:
|
|
|
345
345
|
if (!r.ok) {
|
|
346
346
|
writeError(
|
|
347
347
|
cmd,
|
|
348
|
-
r.error ??
|
|
348
|
+
r.error ??
|
|
349
|
+
`Failed to store credential ${opts.service}:${opts.field}`,
|
|
349
350
|
);
|
|
350
351
|
process.exitCode = 1;
|
|
351
352
|
return;
|
|
@@ -397,7 +398,8 @@ Examples:
|
|
|
397
398
|
if (!r.ok) {
|
|
398
399
|
writeError(
|
|
399
400
|
cmd,
|
|
400
|
-
r.error ??
|
|
401
|
+
r.error ??
|
|
402
|
+
`Failed to delete credential ${opts.service}:${opts.field}`,
|
|
401
403
|
);
|
|
402
404
|
process.exitCode = 1;
|
|
403
405
|
return;
|
|
@@ -410,9 +412,7 @@ Examples:
|
|
|
410
412
|
field: opts.field,
|
|
411
413
|
});
|
|
412
414
|
} else {
|
|
413
|
-
log.info(
|
|
414
|
-
`Deleted credential ${opts.service}:${opts.field}`,
|
|
415
|
-
);
|
|
415
|
+
log.info(`Deleted credential ${opts.service}:${opts.field}`);
|
|
416
416
|
}
|
|
417
417
|
},
|
|
418
418
|
);
|
|
@@ -666,6 +666,7 @@ Examples:
|
|
|
666
666
|
allowedDomains,
|
|
667
667
|
allowedTools,
|
|
668
668
|
injectionTemplates,
|
|
669
|
+
conversationId: tryResolveConversationId(),
|
|
669
670
|
},
|
|
670
671
|
},
|
|
671
672
|
{ timeoutMs: PROMPT_TIMEOUT_MS },
|
|
@@ -681,10 +682,7 @@ Examples:
|
|
|
681
682
|
}
|
|
682
683
|
|
|
683
684
|
if (!ipc.result?.ok) {
|
|
684
|
-
writeError(
|
|
685
|
-
cmd,
|
|
686
|
-
ipc.result?.error ?? "Credential prompt failed",
|
|
687
|
-
);
|
|
685
|
+
writeError(cmd, ipc.result?.error ?? "Credential prompt failed");
|
|
688
686
|
process.exitCode = 1;
|
|
689
687
|
return;
|
|
690
688
|
}
|
|
@@ -867,6 +867,7 @@ function handleThinkingDelta(
|
|
|
867
867
|
thinking: event.thinking,
|
|
868
868
|
conversationId: deps.ctx.conversationId,
|
|
869
869
|
messageId: state.lastAssistantMessageId,
|
|
870
|
+
timestampMs: Date.now(),
|
|
870
871
|
});
|
|
871
872
|
// Mirror thinking into the same running view as text so the debounced
|
|
872
873
|
// partial flush persists it mid-turn -- long reasoning streams survive a
|
|
@@ -13,6 +13,7 @@ import type { SecretPromptResult } from "../../permissions/secret-prompter.js";
|
|
|
13
13
|
import { isPlaceholderSentinelText } from "../../providers/placeholder-sentinels.js";
|
|
14
14
|
import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
|
|
15
15
|
import type { AuthContext } from "../../runtime/auth/types.js";
|
|
16
|
+
import * as pendingInteractions from "../../runtime/pending-interactions.js";
|
|
16
17
|
import { unwrapExternalContentForDisplay } from "../../security/untrusted-content.js";
|
|
17
18
|
import { getLogger } from "../../util/logger.js";
|
|
18
19
|
import { estimateBase64Bytes } from "../assistant-attachments.js";
|
|
@@ -28,15 +29,6 @@ export const CONFIG_RELOAD_DEBOUNCE_MS = 300;
|
|
|
28
29
|
|
|
29
30
|
const HISTORY_ATTACHMENT_TEXT_LIMIT = 500;
|
|
30
31
|
|
|
31
|
-
// Module-level map for non-conversation secret prompts (e.g. publish_page)
|
|
32
|
-
const pendingStandaloneSecrets = new Map<
|
|
33
|
-
string,
|
|
34
|
-
{
|
|
35
|
-
resolve: (result: SecretPromptResult) => void;
|
|
36
|
-
timer: ReturnType<typeof setTimeout>;
|
|
37
|
-
}
|
|
38
|
-
>();
|
|
39
|
-
|
|
40
32
|
/**
|
|
41
33
|
* A single tool call rendered into a history row. Alias of the canonical
|
|
42
34
|
* wire-contract type so `renderHistoryContent` (the producer) cannot drift
|
|
@@ -704,9 +696,15 @@ export function renderHistoryContent(
|
|
|
704
696
|
}
|
|
705
697
|
|
|
706
698
|
/**
|
|
707
|
-
* Send a `secret_request` to the client and wait for the response,
|
|
708
|
-
*
|
|
709
|
-
*
|
|
699
|
+
* Send a `secret_request` to the client and wait for the response, outside of a
|
|
700
|
+
* conversation context (e.g. from IPC routes like credentials/prompt).
|
|
701
|
+
*
|
|
702
|
+
* Lifecycle state (resolver, timer) is registered in pendingInteractions — the
|
|
703
|
+
* same tracker the in-conversation SecretPrompter uses — so `POST /v1/secret`
|
|
704
|
+
* resolves the prompt generically. When a `conversationId` is supplied (the CLI
|
|
705
|
+
* `credentials prompt` command forwards `__CONVERSATION_ID`), the broadcast is
|
|
706
|
+
* scoped to that conversation so clients deliver it; otherwise it is
|
|
707
|
+
* conversation-less.
|
|
710
708
|
*/
|
|
711
709
|
export function requestSecretStandalone(params: {
|
|
712
710
|
service: string;
|
|
@@ -717,15 +715,21 @@ export function requestSecretStandalone(params: {
|
|
|
717
715
|
purpose?: string;
|
|
718
716
|
allowedTools?: string[];
|
|
719
717
|
allowedDomains?: string[];
|
|
718
|
+
conversationId?: string;
|
|
720
719
|
}): Promise<SecretPromptResult> {
|
|
721
720
|
const requestId = uuid();
|
|
722
721
|
const config = getConfig();
|
|
723
722
|
return new Promise((resolve) => {
|
|
724
723
|
const timer = setTimeout(() => {
|
|
725
|
-
|
|
724
|
+
pendingInteractions.resolve(requestId, "cancelled");
|
|
726
725
|
resolve({ value: null, delivery: "store" });
|
|
727
726
|
}, config.timeouts.permissionTimeoutSec * 1000);
|
|
728
|
-
|
|
727
|
+
pendingInteractions.register(requestId, {
|
|
728
|
+
conversationId: params.conversationId,
|
|
729
|
+
kind: "secret",
|
|
730
|
+
rpcResolve: resolve as (value: unknown) => void,
|
|
731
|
+
timer,
|
|
732
|
+
});
|
|
729
733
|
broadcastMessage({
|
|
730
734
|
type: "secret_request",
|
|
731
735
|
requestId,
|
|
@@ -734,6 +738,7 @@ export function requestSecretStandalone(params: {
|
|
|
734
738
|
label: params.label,
|
|
735
739
|
description: params.description,
|
|
736
740
|
placeholder: params.placeholder,
|
|
741
|
+
conversationId: params.conversationId,
|
|
737
742
|
purpose: params.purpose,
|
|
738
743
|
allowedTools: params.allowedTools,
|
|
739
744
|
allowedDomains: params.allowedDomains,
|
|
@@ -473,19 +473,22 @@ export class HostCuProxy {
|
|
|
473
473
|
for (const requestId of this._ownedRequests) {
|
|
474
474
|
const entry = pendingInteractions.resolve(requestId, "cancelled");
|
|
475
475
|
if (!entry) continue;
|
|
476
|
+
const { conversationId } = entry;
|
|
476
477
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
478
|
+
if (conversationId !== undefined) {
|
|
479
|
+
broadcastMessage(
|
|
480
|
+
{
|
|
481
|
+
type: "host_cu_cancel",
|
|
482
|
+
requestId,
|
|
483
|
+
conversationId,
|
|
484
|
+
...(entry.targetClientId != null
|
|
485
|
+
? { targetClientId: entry.targetClientId }
|
|
486
|
+
: {}),
|
|
487
|
+
},
|
|
488
|
+
conversationId,
|
|
489
|
+
{ targetClientId: entry.targetClientId as string | undefined },
|
|
490
|
+
);
|
|
491
|
+
}
|
|
489
492
|
} catch {
|
|
490
493
|
// Best-effort cancel notification
|
|
491
494
|
}
|
|
@@ -277,19 +277,22 @@ export class HostFileProxy {
|
|
|
277
277
|
dispose(): void {
|
|
278
278
|
for (const entry of pendingInteractions.getByKind("host_file")) {
|
|
279
279
|
pendingInteractions.resolve(entry.requestId, "cancelled");
|
|
280
|
+
const { conversationId } = entry;
|
|
280
281
|
try {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
282
|
+
if (conversationId !== undefined) {
|
|
283
|
+
broadcastMessage(
|
|
284
|
+
{
|
|
285
|
+
type: "host_file_cancel",
|
|
286
|
+
requestId: entry.requestId,
|
|
287
|
+
conversationId,
|
|
288
|
+
...(entry.targetClientId != null
|
|
289
|
+
? { targetClientId: entry.targetClientId }
|
|
290
|
+
: {}),
|
|
291
|
+
},
|
|
292
|
+
conversationId,
|
|
293
|
+
{ targetClientId: entry.targetClientId as string | undefined },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
293
296
|
} catch {
|
|
294
297
|
// Best-effort cancel notification
|
|
295
298
|
}
|
|
@@ -663,19 +663,22 @@ export class HostTransferProxy {
|
|
|
663
663
|
const transferId = interaction.metadata?.transferId as string | undefined;
|
|
664
664
|
if (transferId) this.transfers.delete(transferId);
|
|
665
665
|
pendingInteractions.resolve(requestId, "cancelled");
|
|
666
|
+
const { conversationId } = interaction;
|
|
666
667
|
try {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
668
|
+
if (conversationId !== undefined) {
|
|
669
|
+
broadcastMessage(
|
|
670
|
+
{
|
|
671
|
+
type: "host_transfer_cancel",
|
|
672
|
+
requestId,
|
|
673
|
+
conversationId,
|
|
674
|
+
...(interaction.targetClientId != null
|
|
675
|
+
? { targetClientId: interaction.targetClientId }
|
|
676
|
+
: {}),
|
|
677
|
+
},
|
|
678
|
+
conversationId,
|
|
679
|
+
{ targetClientId: interaction.targetClientId },
|
|
680
|
+
);
|
|
681
|
+
}
|
|
679
682
|
} catch {
|
|
680
683
|
// Best-effort cancel notification
|
|
681
684
|
}
|
|
@@ -719,19 +722,22 @@ export class HostTransferProxy {
|
|
|
719
722
|
const transferId = entry.metadata?.transferId as string | undefined;
|
|
720
723
|
if (transferId) this.transfers.delete(transferId);
|
|
721
724
|
pendingInteractions.resolve(entry.requestId, "cancelled");
|
|
725
|
+
const { conversationId } = entry;
|
|
722
726
|
try {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
727
|
+
if (conversationId !== undefined) {
|
|
728
|
+
broadcastMessage(
|
|
729
|
+
{
|
|
730
|
+
type: "host_transfer_cancel",
|
|
731
|
+
requestId: entry.requestId,
|
|
732
|
+
conversationId,
|
|
733
|
+
...(entry.targetClientId != null
|
|
734
|
+
? { targetClientId: entry.targetClientId }
|
|
735
|
+
: {}),
|
|
736
|
+
},
|
|
737
|
+
conversationId,
|
|
738
|
+
{ targetClientId: entry.targetClientId },
|
|
739
|
+
);
|
|
740
|
+
}
|
|
735
741
|
} catch {
|
|
736
742
|
// Best-effort cancel notification
|
|
737
743
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { AssistantActivityStateEvent } from "../../api/events/assistant-activity-state.js";
|
|
4
4
|
import type { AssistantTextDeltaEvent } from "../../api/events/assistant-text-delta.js";
|
|
5
|
+
import type { AssistantThinkingDeltaEvent } from "../../api/events/assistant-thinking-delta.js";
|
|
5
6
|
import type { AssistantTurnStartEvent } from "../../api/events/assistant-turn-start.js";
|
|
6
7
|
import type { ConfirmationRequestEvent } from "../../api/events/confirmation-request.js";
|
|
7
8
|
import type { ErrorEvent } from "../../api/events/error.js";
|
|
@@ -76,15 +77,6 @@ export interface SuggestionRequest {
|
|
|
76
77
|
|
|
77
78
|
// === Server → Client ===
|
|
78
79
|
|
|
79
|
-
export interface AssistantThinkingDelta {
|
|
80
|
-
type: "assistant_thinking_delta";
|
|
81
|
-
thinking: string;
|
|
82
|
-
conversationId?: string;
|
|
83
|
-
/** Database ID of the assistant message this thinking delta belongs to.
|
|
84
|
-
* Same semantics as `AssistantTextDeltaEvent.messageId`. */
|
|
85
|
-
messageId?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
80
|
export interface ToolInputDelta {
|
|
89
81
|
type: "tool_input_delta";
|
|
90
82
|
toolName: string;
|
|
@@ -160,7 +152,7 @@ export type _MessagesServerMessages =
|
|
|
160
152
|
| UserMessageEchoEvent
|
|
161
153
|
| AssistantTurnStartEvent
|
|
162
154
|
| AssistantTextDeltaEvent
|
|
163
|
-
|
|
|
155
|
+
| AssistantThinkingDeltaEvent
|
|
164
156
|
| ToolUseStartEvent
|
|
165
157
|
| ToolUsePreviewStartEvent
|
|
166
158
|
| ToolOutputChunkEvent
|
|
@@ -51,7 +51,12 @@ export interface ConfirmationDetails {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export interface PendingInteraction {
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Owning conversation, when the interaction was raised inside one. Absent
|
|
56
|
+
* for interactions raised outside any conversation (e.g. the CLI
|
|
57
|
+
* `credentials prompt` command), which resolve via {@link rpcResolve}.
|
|
58
|
+
*/
|
|
59
|
+
conversationId?: string;
|
|
55
60
|
kind:
|
|
56
61
|
| "confirmation"
|
|
57
62
|
| "secret"
|
|
@@ -140,6 +145,10 @@ function emitResolved(
|
|
|
140
145
|
},
|
|
141
146
|
"Pending interaction resolved",
|
|
142
147
|
);
|
|
148
|
+
// interaction_resolved is conversation-scoped on the wire; a conversation-less
|
|
149
|
+
// interaction has no conversation for clients to route the event to, so skip
|
|
150
|
+
// the broadcast.
|
|
151
|
+
if (interaction.conversationId === undefined) return;
|
|
143
152
|
broadcastMessage({
|
|
144
153
|
type: "interaction_resolved",
|
|
145
154
|
requestId,
|
|
@@ -204,6 +213,16 @@ export function removeByConversation(
|
|
|
204
213
|
) {
|
|
205
214
|
// resolve() clears the stored timer and detaches abort listeners.
|
|
206
215
|
resolve(requestId, state);
|
|
216
|
+
// Secret prompts have no abort-signal teardown (unlike questions) and
|
|
217
|
+
// are not pre-settled by denyAllPendingConfirmations (unlike
|
|
218
|
+
// confirmations), so removing the entry alone would leave the caller's
|
|
219
|
+
// Promise — the CLI `credentials prompt` command or the in-conversation
|
|
220
|
+
// SecretPrompter — hanging until its IPC client times out. Settle it
|
|
221
|
+
// with a cancelled result, matching the prompt timeout path. rpcResolve
|
|
222
|
+
// is idempotent, so any later resolveSecret/dispose call is a no-op.
|
|
223
|
+
if (interaction.kind === "secret") {
|
|
224
|
+
interaction.rpcResolve?.({ value: null, delivery: "store" });
|
|
225
|
+
}
|
|
207
226
|
}
|
|
208
227
|
}
|
|
209
228
|
}
|
|
@@ -11,6 +11,10 @@ import { z } from "zod";
|
|
|
11
11
|
|
|
12
12
|
import { findConversation } from "../../daemon/conversation-registry.js";
|
|
13
13
|
import { getConversationByKey } from "../../memory/conversation-key-store.js";
|
|
14
|
+
import type {
|
|
15
|
+
SecretDelivery,
|
|
16
|
+
SecretPromptResult,
|
|
17
|
+
} from "../../permissions/secret-prompter.js";
|
|
14
18
|
import type { UserDecision } from "../../permissions/types.js";
|
|
15
19
|
import { getLogger } from "../../util/logger.js";
|
|
16
20
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
@@ -127,24 +131,47 @@ function handleSecret({ body }: RouteHandlerArgs) {
|
|
|
127
131
|
throw new BadRequestError('delivery must be "store" or "transient_send"');
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
// Use get() — SecretPrompter.resolveSecret() owns deregistration.
|
|
131
134
|
const interaction = pendingInteractions.get(requestId);
|
|
132
135
|
if (!interaction) {
|
|
133
136
|
throw new NotFoundError("No pending interaction found for this requestId");
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
// /v1/secret only settles secret prompts. A requestId belonging to another
|
|
140
|
+
// interaction kind (a confirmation or host-proxy request posted here from
|
|
141
|
+
// stale or mismatched client state) must not be consumed or resolved with a
|
|
142
|
+
// SecretPromptResult, which would strand its real approval/result endpoint.
|
|
143
|
+
if (interaction.kind !== "secret") {
|
|
138
144
|
throw new NotFoundError(
|
|
139
|
-
"
|
|
145
|
+
"No pending secret request found for this requestId",
|
|
140
146
|
);
|
|
141
147
|
}
|
|
142
148
|
|
|
143
|
-
conversation
|
|
149
|
+
// When a live conversation owns the request, route through it so the
|
|
150
|
+
// SecretPrompter's ownership tracking and dispose path stay consistent (this
|
|
151
|
+
// also drives the voice auto-resolve path). The prompter owns deregistration.
|
|
152
|
+
const conversation = interaction.conversationId
|
|
153
|
+
? findConversation(interaction.conversationId)
|
|
154
|
+
: undefined;
|
|
155
|
+
if (conversation?.hasPendingSecret(requestId)) {
|
|
156
|
+
conversation.handleSecretResponse(
|
|
157
|
+
requestId,
|
|
158
|
+
value,
|
|
159
|
+
delivery as "store" | "transient_send" | undefined,
|
|
160
|
+
);
|
|
161
|
+
return { accepted: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Conversation-less requests (e.g. the CLI `credentials prompt` command) and
|
|
165
|
+
// any request no live conversation owns resolve generically via the resolver
|
|
166
|
+
// stored on the interaction, with no Conversation in the loop.
|
|
167
|
+
const resolved = pendingInteractions.resolve(
|
|
144
168
|
requestId,
|
|
145
|
-
value,
|
|
146
|
-
delivery as "store" | "transient_send" | undefined,
|
|
169
|
+
value === undefined ? "cancelled" : "answered",
|
|
147
170
|
);
|
|
171
|
+
(resolved?.rpcResolve as ((r: SecretPromptResult) => void) | undefined)?.({
|
|
172
|
+
value: value ?? null,
|
|
173
|
+
delivery: (delivery as SecretDelivery) ?? "store",
|
|
174
|
+
});
|
|
148
175
|
return { accepted: true };
|
|
149
176
|
}
|
|
150
177
|
|
|
@@ -321,7 +348,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
321
348
|
.array(
|
|
322
349
|
z.object({
|
|
323
350
|
requestId: z.string(),
|
|
324
|
-
conversationId: z.string(),
|
|
351
|
+
conversationId: z.string().optional(),
|
|
325
352
|
kind: z.string(),
|
|
326
353
|
toolName: z.string().optional(),
|
|
327
354
|
riskLevel: z.string().optional(),
|
|
@@ -40,6 +40,7 @@ const CredentialPromptParams = z.object({
|
|
|
40
40
|
allowedDomains: z.array(z.string()).optional(),
|
|
41
41
|
allowedTools: z.array(z.string()).optional(),
|
|
42
42
|
injectionTemplates: z.array(InjectionTemplateSchema).optional(),
|
|
43
|
+
conversationId: z.string().optional(),
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
// ---------------------------------------------------------------------------
|
|
@@ -70,6 +71,7 @@ async function handleCredentialPrompt({ body = {} }: RouteHandlerArgs) {
|
|
|
70
71
|
placeholder: validated.placeholder,
|
|
71
72
|
allowedTools: validated.allowedTools,
|
|
72
73
|
allowedDomains: validated.allowedDomains,
|
|
74
|
+
conversationId: validated.conversationId,
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
if (!result.value) {
|