authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 16:14:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 16:14:12-07:00
log9f61ba674115f3ddb67a8a1fbe0a0ac8839551bf
tree46ea0c7ee8c6180ca918a0deaca37c2ac4f15a36
parent25d5c7cacb99bc50056edc0ea8d201eac31cfdf4
signature Signed by PGP key B5690EEEBB952194

feat: agents ask the user for a secret through a private card (#15907)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

49 files changed, 2250 insertions(+), 27 deletions(-)

apps/mobile/src/features/threads/SecretRequestCard.tsx created+170
...@@ -0,0 +1,170 @@
1import {
2 SECRET_REQUEST_DEFAULT_PLACEHOLDER,
3 SECRET_REQUEST_PRIVACY_NOTE,
4 secretRequestAnswerInput,
5 secretRequestDisplay,
6 secretRequestFailureMessage,
7 type SecretRequestItem,
8} from "@t3tools/client-runtime/secret-request";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
13import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
14import { useRef, useState } from "react";
15import { Pressable, View, type ColorValue } from "react-native";
16
17import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
18import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
19import { serverEnvironment } from "../../state/server";
20import { useAtomCommand } from "../../state/use-atom-command";
21import { RequestActionButton } from "./RequestActionButton";
22
23/**
24 * Feed card for a secret an agent asked the user for. The typed value lives
25 * only in this component's state and the RPC payload: it is never logged,
26 * alerted, or persisted, and the field clears once the answer is sent.
27 */
28const LOCK_SYMBOL: AppSymbolName = { ios: "lock", android: "lock" };
29const PRIVATE_SYMBOL: AppSymbolName = { ios: "checkmark.shield", android: "lock" };
30
31export function SecretRequestCard(props: {
32 readonly environmentId: EnvironmentId;
33 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
34 readonly iconColor: ColorValue;
35}) {
36 const { item, visibility } = props.projectedItem;
37 if (item.type !== "secret_request") return null;
38 const display = secretRequestDisplay(item, visibility);
39 if (display.kind === "pending") {
40 return (
41 <PendingSecretRequestForm
42 environmentId={props.environmentId}
43 item={item}
44 iconColor={props.iconColor}
45 />
46 );
47 }
48 const icon: AppSymbolName =
49 display.kind === "pending-elsewhere"
50 ? LOCK_SYMBOL
51 : display.outcome === "saved"
52 ? "checkmark"
53 : "minus";
54 return (
55 <View className="mb-3 min-h-9 flex-row items-center gap-2 px-1">
56 <SymbolView name={icon} size={13} tintColor={props.iconColor} type="monochrome" />
57 <Text className="flex-1 font-sans text-sm text-foreground-muted" numberOfLines={2}>
58 {item.label} · {display.label}
59 </Text>
60 </View>
61 );
62}
63
64function PendingSecretRequestForm(props: {
65 readonly environmentId: EnvironmentId;
66 readonly item: SecretRequestItem;
67 readonly iconColor: ColorValue;
68}) {
69 const { item } = props;
70 const answer = useAtomCommand(serverEnvironment.answerSecretRequest, {
71 label: "answer secret request",
72 // The failure cause holds the request; keep it out of the console.
73 reportFailure: false,
74 reportDefect: false,
75 });
76 const [secret, setSecret] = useState("");
77 const [submitting, setSubmitting] = useState(false);
78 const [error, setError] = useState<string | null>(null);
79 // Submit then a tap can both run before a re-render; this guard is synchronous.
80 const inFlight = useRef(false);
81
82 const send = async (
83 reply: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
84 ) => {
85 const input = secretRequestAnswerInput(item, reply);
86 if (input === null || inFlight.current) return;
87 inFlight.current = true;
88 setSubmitting(true);
89 setError(null);
90 const result = await answer({ environmentId: props.environmentId, input }).finally(() => {
91 inFlight.current = false;
92 setSubmitting(false);
93 });
94 if (result._tag === "Success") {
95 // The card switches to its answered row once the item updates.
96 setSecret("");
97 return;
98 }
99 if (!isAtomCommandInterrupted(result)) {
100 setError(secretRequestFailureMessage(squashAtomCommandFailure(result)));
101 }
102 };
103
104 // Same hierarchy as web: what is asked, why, the field, then the promise
105 // about where the value goes.
106 return (
107 <View className="mb-3 gap-3 rounded-[20px] border border-border bg-card-alt p-4">
108 <View className="gap-1">
109 <Text className="font-t3-bold text-base text-foreground">{item.label}</Text>
110 {item.reason.trim() ? (
111 <Text className="font-sans text-sm leading-5 text-foreground-muted">{item.reason}</Text>
112 ) : null}
113 </View>
114 <TextInput
115 accessibilityLabel={item.label}
116 placeholder={item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
117 value={secret}
118 onChangeText={setSecret}
119 editable={!submitting}
120 secureTextEntry
121 autoCorrect={false}
122 autoCapitalize="none"
123 autoComplete="off"
124 textContentType="none"
125 importantForAutofill="no"
126 spellCheck={false}
127 returnKeyType="done"
128 onSubmitEditing={() => void send({ type: "save", secret })}
129 />
130 {error !== null ? (
131 <Text
132 accessibilityRole="alert"
133 accessibilityLiveRegion="polite"
134 className="font-sans text-sm text-danger-foreground"
135 >
136 {error}
137 </Text>
138 ) : null}
139 <RequestActionButton
140 label="Save securely"
141 disabled={submitting || secret.trim().length === 0}
142 onPress={() => void send({ type: "save", secret })}
143 />
144 <View className="flex-row items-center justify-between gap-2">
145 <View className="flex-1 flex-row items-center gap-1.5">
146 <SymbolView
147 name={PRIVATE_SYMBOL}
148 size={13}
149 tintColor={props.iconColor}
150 type="monochrome"
151 />
152 <Text className="flex-1 font-sans text-xs text-foreground-muted">
153 {SECRET_REQUEST_PRIVACY_NOTE}
154 </Text>
155 </View>
156 {/* Quiet like the web card's: the field and Save are the action. */}
157 <Pressable
158 accessibilityRole="button"
159 accessibilityState={{ disabled: submitting }}
160 disabled={submitting}
161 hitSlop={8}
162 className="px-1 py-1 active:opacity-60 disabled:opacity-50"
163 onPress={() => void send({ type: "decline" })}
164 >
165 <Text className="font-sans text-xs text-foreground-muted">Decline</Text>
166 </Pressable>
167 </View>
168 </View>
169 );
170}
apps/mobile/src/features/threads/ThreadFeed.tsx+12
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { ThreadContextDivider } from "./thread-context-divider";1import { ThreadContextDivider } from "./thread-context-divider";
2import { ThreadHandoffRow } from "./thread-handoff-row";2import { ThreadHandoffRow } from "./thread-handoff-row";
3import { SecretRequestCard } from "./SecretRequestCard";
3import {4import {
4 WorktreeWorkingHeader,5 WorktreeWorkingHeader,
5 WorktreeSetupCard,6 WorktreeSetupCard,
...@@ -162,6 +163,7 @@ import {...@@ -162,6 +163,7 @@ import {
162 threadFeedRunIsUnsettled,163 threadFeedRunIsUnsettled,
163 isContextCompactionActivityGroup,164 isContextCompactionActivityGroup,
164 isContextHandoffActivityGroup,165 isContextHandoffActivityGroup,
166 isSecretRequestActivityGroup,
165 type ThreadFeedEntry,167 type ThreadFeedEntry,
166 type ThreadFeedLatestRun,168 type ThreadFeedLatestRun,
167} from "../../lib/threadActivity";169} from "../../lib/threadActivity";
...@@ -1621,6 +1623,16 @@ function renderFeedEntry(...@@ -1621,6 +1623,16 @@ function renderFeedEntry(
1621 );1623 );
1622 }1624 }
16231625
1626 if (entry.type === "activity-group" && isSecretRequestActivityGroup(entry)) {
1627 return (
1628 <SecretRequestCard
1629 environmentId={props.environmentId}
1630 projectedItem={entry.activities[0]!.projectedItem}
1631 iconColor={iconSubtleColor}
1632 />
1633 );
1634 }
1635
1624 if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {1636 if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
1625 const label = entry.activities[0]!.summary;1637 const label = entry.activities[0]!.summary;
1626 const active =1638 const active =
apps/mobile/src/lib/threadActivity.ts+20-1
...@@ -321,6 +321,13 @@ export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): b...@@ -321,6 +321,13 @@ export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): b
321 );321 );
322}322}
323323
324export function isSecretRequestActivityGroup(entry: ThreadFeedActivityGroup): boolean {
325 return (
326 entry.activities.length === 1 &&
327 entry.activities[0]?.projectedItem.item.type === "secret_request"
328 );
329}
330
324function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {331function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
325 return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);332 return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
326}333}
...@@ -420,7 +427,13 @@ function itemIsToolLike(item: OrchestrationV2TurnItem): boolean {...@@ -420,7 +427,13 @@ function itemIsToolLike(item: OrchestrationV2TurnItem): boolean {
420}427}
421428
422function itemIsProminent(item: OrchestrationV2TurnItem): boolean {429function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
423 return item.type === "fork" || item.type === "thread_created" || item.type === "system_notice";430 return (
431 item.type === "fork" ||
432 item.type === "thread_created" ||
433 item.type === "system_notice" ||
434 // An answerable card: it must stand alone and never fold away with the run.
435 item.type === "secret_request"
436 );
424}437}
425438
426function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {439function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
...@@ -537,6 +550,8 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {...@@ -537,6 +550,8 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
537 case "fork":550 case "fork":
538 case "thread_created":551 case "thread_created":
539 return "zap";552 return "zap";
553 case "secret_request":
554 return "lock";
540 }555 }
541}556}
542557
...@@ -590,6 +605,8 @@ function itemSummary(...@@ -590,6 +605,8 @@ function itemSummary(
590 return "Thread forked";605 return "Thread forked";
591 case "thread_created":606 case "thread_created":
592 return "Thread created";607 return "Thread created";
608 case "secret_request":
609 return item.label;
593 case "dynamic_tool": {610 case "dynamic_tool": {
594 const classified = classifyToolActivity({611 const classified = classifyToolActivity({
595 itemType: "dynamic_tool_call",612 itemType: "dynamic_tool_call",
...@@ -647,6 +664,8 @@ function itemPreview(item: OrchestrationV2TurnItem): string | null {...@@ -647,6 +664,8 @@ function itemPreview(item: OrchestrationV2TurnItem): string | null {
647 case "fork":664 case "fork":
648 case "thread_created":665 case "thread_created":
649 return item.targetThreadId;666 return item.targetThreadId;
667 case "secret_request":
668 return item.reason || null;
650 case "subagent":669 case "subagent":
651 return item.result ?? item.progress ?? item.prompt;670 return item.result ?? item.progress ?? item.prompt;
652 case "dynamic_tool":671 case "dynamic_tool":
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -96,6 +96,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -96,6 +96,7 @@ export const RPC_REQUIRED_SCOPES = {
96 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,96 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
97 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,97 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
98 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,98 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
99 [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,
99 // Delivery logs hold request bodies, so they need the same scope as the URL.100 // Delivery logs hold request bodies, so they need the same scope as the URL.
100 [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,101 [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
101 [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,102 [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+5
...@@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg...@@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
20import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";20import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
21import * as ProjectService from "../project/ProjectService.ts";21import * as ProjectService from "../project/ProjectService.ts";
22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
23import * as SecretRequests from "../secrets/SecretRequests.ts";
23import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";24import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
24import type * as McpInvocationContext from "./McpInvocationContext.ts";25import type * as McpInvocationContext from "./McpInvocationContext.ts";
25import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";26import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
...@@ -149,6 +150,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a...@@ -149,6 +150,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
149 getProviders: Effect.succeed([]),150 getProviders: Effect.succeed([]),
150 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),151 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
151 Layer.mock(ProjectService.ProjectService)({}),152 Layer.mock(ProjectService.ProjectService)({}),
153 Layer.mock(SecretRequests.SecretRequests)({}),
152 Layer.mock(ScheduledTaskService.ScheduledTaskService)({154 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
153 list: () => Effect.succeed({ tasks: [] }),155 list: () => Effect.succeed({ tasks: [] }),
154 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),156 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
...@@ -213,6 +215,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run...@@ -213,6 +215,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
213 getProviders: Effect.succeed([]),215 getProviders: Effect.succeed([]),
214 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),216 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
215 Layer.mock(ProjectService.ProjectService)({}),217 Layer.mock(ProjectService.ProjectService)({}),
218 Layer.mock(SecretRequests.SecretRequests)({}),
216 Layer.mock(ScheduledTaskService.ScheduledTaskService)({219 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
217 list: () => Effect.succeed({ tasks: [] }),220 list: () => Effect.succeed({ tasks: [] }),
218 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),221 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
...@@ -325,6 +328,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy...@@ -325,6 +328,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
325 getProviders: Effect.succeed([]),328 getProviders: Effect.succeed([]),
326 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),329 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
327 Layer.mock(ProjectService.ProjectService)({}),330 Layer.mock(ProjectService.ProjectService)({}),
331 Layer.mock(SecretRequests.SecretRequests)({}),
328 Layer.mock(ScheduledTaskService.ScheduledTaskService)({332 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
329 list: () => Effect.succeed({ tasks: [] }),333 list: () => Effect.succeed({ tasks: [] }),
330 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),334 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
...@@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {...@@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {
447 getProviders: Effect.succeed([]),451 getProviders: Effect.succeed([]),
448 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),452 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
449 Layer.mock(ProjectService.ProjectService)({}),453 Layer.mock(ProjectService.ProjectService)({}),
454 Layer.mock(SecretRequests.SecretRequests)({}),
450 Layer.mock(ScheduledTaskService.ScheduledTaskService)({455 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
451 list: () =>456 list: () =>
452 Effect.succeed({457 Effect.succeed({
apps/server/src/mcp/OrchestratorMcpService.test.ts+12
...@@ -24,6 +24,7 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";...@@ -24,6 +24,7 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
24import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";24import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
25import * as ProjectService from "../project/ProjectService.ts";25import * as ProjectService from "../project/ProjectService.ts";
26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";
27import type { McpInvocationScope } from "./McpInvocationContext.ts";28import type { McpInvocationScope } from "./McpInvocationContext.ts";
28import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";29import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
2930
...@@ -120,6 +121,7 @@ describe("OrchestratorMcpService", () => {...@@ -120,6 +121,7 @@ describe("OrchestratorMcpService", () => {
120 list: () => Effect.succeed([]),121 list: () => Effect.succeed([]),
121 }),122 }),
122 Layer.mock(ProjectService.ProjectService)({}),123 Layer.mock(ProjectService.ProjectService)({}),
124 Layer.mock(SecretRequests.SecretRequests)({}),
123 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),125 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
124 );126 );
125 const scope: McpInvocationScope = {127 const scope: McpInvocationScope = {
...@@ -209,6 +211,7 @@ describe("OrchestratorMcpService", () => {...@@ -209,6 +211,7 @@ describe("OrchestratorMcpService", () => {
209 list: () => Effect.succeed([]),211 list: () => Effect.succeed([]),
210 }),212 }),
211 Layer.mock(ProjectService.ProjectService)({}),213 Layer.mock(ProjectService.ProjectService)({}),
214 Layer.mock(SecretRequests.SecretRequests)({}),
212 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),215 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
213 );216 );
214 const scope: McpInvocationScope = {217 const scope: McpInvocationScope = {
...@@ -290,6 +293,7 @@ describe("OrchestratorMcpService", () => {...@@ -290,6 +293,7 @@ describe("OrchestratorMcpService", () => {
290 list: () => Effect.succeed([]),293 list: () => Effect.succeed([]),
291 }),294 }),
292 Layer.mock(ProjectService.ProjectService)({}),295 Layer.mock(ProjectService.ProjectService)({}),
296 Layer.mock(SecretRequests.SecretRequests)({}),
293 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),297 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
294 );298 );
295 const scope: McpInvocationScope = {299 const scope: McpInvocationScope = {
...@@ -363,6 +367,7 @@ describe("OrchestratorMcpService", () => {...@@ -363,6 +367,7 @@ describe("OrchestratorMcpService", () => {
363 list: () => Effect.succeed([]),367 list: () => Effect.succeed([]),
364 }),368 }),
365 Layer.mock(ProjectService.ProjectService)({}),369 Layer.mock(ProjectService.ProjectService)({}),
370 Layer.mock(SecretRequests.SecretRequests)({}),
366 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),371 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
367 );372 );
368 const scope: McpInvocationScope = {373 const scope: McpInvocationScope = {
...@@ -444,6 +449,7 @@ describe("OrchestratorMcpService", () => {...@@ -444,6 +449,7 @@ describe("OrchestratorMcpService", () => {
444 list: () => Effect.succeed([]),449 list: () => Effect.succeed([]),
445 }),450 }),
446 Layer.mock(ProjectService.ProjectService)({}),451 Layer.mock(ProjectService.ProjectService)({}),
452 Layer.mock(SecretRequests.SecretRequests)({}),
447 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),453 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
448 );454 );
449 const scope: McpInvocationScope = {455 const scope: McpInvocationScope = {
...@@ -652,6 +658,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -652,6 +658,7 @@ describe("OrchestratorMcpService provider resolution", () => {
652 disabledAntigravityInstanceId,658 disabledAntigravityInstanceId,
653 ]),659 ]),
654 Layer.mock(ProjectService.ProjectService)({}),660 Layer.mock(ProjectService.ProjectService)({}),
661 Layer.mock(SecretRequests.SecretRequests)({}),
655 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),662 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
656 );663 );
657664
...@@ -796,6 +803,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -796,6 +803,7 @@ describe("OrchestratorMcpService provider resolution", () => {
796 }),803 }),
797 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),804 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
798 Layer.mock(ProjectService.ProjectService)({}),805 Layer.mock(ProjectService.ProjectService)({}),
806 Layer.mock(SecretRequests.SecretRequests)({}),
799 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),807 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
800 );808 );
801809
...@@ -890,6 +898,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -890,6 +898,7 @@ describe("OrchestratorMcpService provider resolution", () => {
890 }),898 }),
891 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),899 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
892 Layer.mock(ProjectService.ProjectService)({}),900 Layer.mock(ProjectService.ProjectService)({}),
901 Layer.mock(SecretRequests.SecretRequests)({}),
893 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),902 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
894 );903 );
895904
...@@ -937,6 +946,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -937,6 +946,7 @@ describe("OrchestratorMcpService provider resolution", () => {
937 ]),946 ]),
938 adapterRegistryLayer([codexInstanceId]),947 adapterRegistryLayer([codexInstanceId]),
939 Layer.mock(ProjectService.ProjectService)({}),948 Layer.mock(ProjectService.ProjectService)({}),
949 Layer.mock(SecretRequests.SecretRequests)({}),
940 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),950 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
941 );951 );
942952
...@@ -1044,6 +1054,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1044,6 +1054,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1044 adapterRegistryLayer([codexInstanceId, claudeInstanceId]),1054 adapterRegistryLayer([codexInstanceId, claudeInstanceId]),
1045 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),1055 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1046 Layer.mock(ProjectService.ProjectService)({}),1056 Layer.mock(ProjectService.ProjectService)({}),
1057 Layer.mock(SecretRequests.SecretRequests)({}),
1047 );1058 );
10481059
1049 yield* Effect.gen(function* () {1060 yield* Effect.gen(function* () {
...@@ -1200,6 +1211,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1200,6 +1211,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1200 ]),1211 ]),
1201 adapterRegistryLayer([codexInstanceId, codexAltInstanceId]),1212 adapterRegistryLayer([codexInstanceId, codexAltInstanceId]),
1202 Layer.mock(ProjectService.ProjectService)({}),1213 Layer.mock(ProjectService.ProjectService)({}),
1214 Layer.mock(SecretRequests.SecretRequests)({}),
1203 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),1215 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1204 );1216 );
12051217
apps/server/src/mcp/OrchestratorMcpService.ts+159-1
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 MessageId,5 MessageId,
6 type ModelSelection,6 type ModelSelection,
7 NodeId,7 NodeId,
8 TurnItemId,
8 type OrchestrationV2Run,9 type OrchestrationV2Run,
9 type OrchestrationV2Subagent,10 type OrchestrationV2Subagent,
10 type OrchestrationV2ThreadProjection,11 type OrchestrationV2ThreadProjection,
...@@ -19,6 +20,8 @@ import {...@@ -19,6 +20,8 @@ import {
19 type OrchestratorMcpDelegateTaskResult,20 type OrchestratorMcpDelegateTaskResult,
20 type OrchestratorMcpInteractionMode,21 type OrchestratorMcpInteractionMode,
21 type OrchestratorMcpDeleteScheduledTaskInput,22 type OrchestratorMcpDeleteScheduledTaskInput,
23 type OrchestratorMcpRequestSecretInput,
24 type OrchestratorMcpRequestSecretResult,
22 type OrchestratorMcpDeleteScheduledTaskResult,25 type OrchestratorMcpDeleteScheduledTaskResult,
23 type OrchestratorMcpListScheduledTasksResult,26 type OrchestratorMcpListScheduledTasksResult,
24 type OrchestratorMcpRuntimeMode,27 type OrchestratorMcpRuntimeMode,
...@@ -60,6 +63,7 @@ import * as Crypto from "effect/Crypto";...@@ -60,6 +63,7 @@ import * as Crypto from "effect/Crypto";
60import * as DateTime from "effect/DateTime";63import * as DateTime from "effect/DateTime";
61import * as Duration from "effect/Duration";64import * as Duration from "effect/Duration";
62import * as Effect from "effect/Effect";65import * as Effect from "effect/Effect";
66import * as Exit from "effect/Exit";
63import * as Layer from "effect/Layer";67import * as Layer from "effect/Layer";
64import * as Option from "effect/Option";68import * as Option from "effect/Option";
65import * as Schema from "effect/Schema";69import * as Schema from "effect/Schema";
...@@ -79,6 +83,8 @@ import {...@@ -79,6 +83,8 @@ import {
79 type McpThreadInvocationScope,83 type McpThreadInvocationScope,
80 requireThreadScope,84 requireThreadScope,
81} from "./McpInvocationContext.ts";85} from "./McpInvocationContext.ts";
86import * as Metrics from "../observability/Metrics.ts";
87import * as SecretRequests from "../secrets/SecretRequests.ts";
8288
83const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;89const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
84const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;90const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
...@@ -90,6 +96,8 @@ const TASK_WAKE_EVENTS = [...@@ -90,6 +96,8 @@ const TASK_WAKE_EVENTS = [
90 { thread: "child", eventType: "subagent.updated" },96 { thread: "child", eventType: "subagent.updated" },
91 { thread: "child", eventType: "provider-thread.updated" },97 { thread: "child", eventType: "provider-thread.updated" },
92] as const;98] as const;
99/** A person answers the card, so a slower poll is plenty. */
100const SECRET_REQUEST_POLL_INTERVAL_MS = 500;
93const DEFAULT_THREAD_LIST_LIMIT = 50;101const DEFAULT_THREAD_LIST_LIMIT = 50;
94const DEFAULT_THREAD_READ_LIMIT = 50;102const DEFAULT_THREAD_READ_LIMIT = 50;
95const DEFAULT_THREAD_RUN_LIMIT = 10;103const DEFAULT_THREAD_RUN_LIMIT = 10;
...@@ -140,6 +148,14 @@ export interface OrchestratorMcpServiceShape {...@@ -140,6 +148,14 @@ export interface OrchestratorMcpServiceShape {
140 scope: McpInvocationScope,148 scope: McpInvocationScope,
141 input: OrchestratorMcpDeleteScheduledTaskInput,149 input: OrchestratorMcpDeleteScheduledTaskInput,
142 ) => Effect.Effect<OrchestratorMcpDeleteScheduledTaskResult, OrchestratorMcpFailure>;150 ) => Effect.Effect<OrchestratorMcpDeleteScheduledTaskResult, OrchestratorMcpFailure>;
151 /**
152 * Asks the user for a secret through a card in the calling thread and waits
153 * for the answer. The value never reaches the agent: the result is a status.
154 */
155 readonly requestSecret: (
156 scope: McpInvocationScope,
157 input: OrchestratorMcpRequestSecretInput,
158 ) => Effect.Effect<OrchestratorMcpRequestSecretResult, OrchestratorMcpFailure>;
143 readonly listThreads: (159 readonly listThreads: (
144 scope: McpInvocationScope,160 scope: McpInvocationScope,
145 input: OrchestratorMcpThreadListInput,161 input: OrchestratorMcpThreadListInput,
...@@ -220,7 +236,11 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask...@@ -220,7 +236,11 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask
220 schedule: task.schedule,236 schedule: task.schedule,
221 nextRunAt: task.nextRunAt,237 nextRunAt: task.nextRunAt,
222 lastRunStatus: task.lastRunStatus,238 lastRunStatus: task.lastRunStatus,
223 ...(task.webhook === undefined ? {} : { webhookUrl: task.webhook.url ?? task.webhook.path }),239 // A bare path is not a URL anyone can call, so agents never get one to share.
240 ...(task.webhook?.url == null ? {} : { webhookUrl: task.webhook.url }),
241 ...(task.webhook === undefined
242 ? {}
243 : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }),
224 };244 };
225}245}
226246
...@@ -725,6 +745,8 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null {...@@ -725,6 +745,8 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null {
725 return `Forked to thread ${item.targetThreadId}.`;745 return `Forked to thread ${item.targetThreadId}.`;
726 case "thread_created":746 case "thread_created":
727 return `Created thread ${item.targetThreadId} with ${item.targetProviderInstanceId} (${item.targetModel}).`;747 return `Created thread ${item.targetThreadId} with ${item.targetProviderInstanceId} (${item.targetModel}).`;
748 case "secret_request":
749 return `Asked the user for ${item.label}: ${item.secretStatus}.`;
728 case "subagent":750 case "subagent":
729 return item.result ?? item.progress ?? item.prompt;751 return item.result ?? item.progress ?? item.prompt;
730 case "dynamic_tool":752 case "dynamic_tool":
...@@ -806,6 +828,7 @@ const make = Effect.gen(function* () {...@@ -806,6 +828,7 @@ const make = Effect.gen(function* () {
806 ),828 ),
807 )829 )
808 : Effect.succeed(project.defaultModelSelection);830 : Effect.succeed(project.defaultModelSelection);
831 const secretRequests = yield* SecretRequests.SecretRequests;
809832
810 const requireCapability = (scope: McpInvocationScope) =>833 const requireCapability = (scope: McpInvocationScope) =>
811 scope.capabilities.has("orchestration")834 scope.capabilities.has("orchestration")
...@@ -1514,6 +1537,140 @@ const make = Effect.gen(function* () {...@@ -1514,6 +1537,140 @@ const make = Effect.gen(function* () {
1514 );1537 );
1515 return { scheduledTaskId: existing.id, deleted: true };1538 return { scheduledTaskId: existing.id, deleted: true };
1516 }),1539 }),
1540 requestSecret: (scope, input) =>
1541 Effect.gen(function* () {
1542 // The card is shown in, and answered from, the caller's own thread.
1543 const { scope: threadScope, parent } = yield* loadThreadCaller(scope, "request_secret");
1544 const threadId = threadScope.thread.threadId;
1545 const run = ThreadManagementService.latestActiveRun(parent);
1546 if (
1547 run === undefined ||
1548 run.rootNodeId === null ||
1549 run.providerInstanceId !== threadScope.thread.providerInstanceId
1550 ) {
1551 return yield* failure(
1552 "parent_not_active",
1553 "Asking for a secret requires an active run owned by this MCP provider session.",
1554 );
1555 }
1556 const runId = run.id;
1557 const nodeId = run.rootNodeId;
1558 const key = yield* requestKey(input.clientRequestId);
1559 // Turn item ids are global; scope the key to this thread. A retry with
1560 // the same clientRequestId finds this card, answered or not.
1561 const turnItemId = TurnItemId.make(
1562 `turn-item:secret-request:${stablePart(threadId)}:${stablePart(key)}`,
1563 );
1564 const record = (secretStatus: "pending" | "cancelled") =>
1565 threadManagement
1566 .dispatch({
1567 type: "secret_request.record",
1568 commandId: stableCommandId({
1569 scope,
1570 requestKey: key,
1571 operation: `secret-${secretStatus}`,
1572 }),
1573 threadId: threadId,
1574 runId,
1575 nodeId,
1576 turnItemId,
1577 label: input.label,
1578 reason: input.reason,
1579 ...(input.placeholder === undefined ? {} : { placeholder: input.placeholder }),
1580 secretStatus,
1581 })
1582 .pipe(
1583 Effect.mapError((error) =>
1584 failure(
1585 "orchestration_error",
1586 `Could not record the secret request: ${errorMessage(error)}`,
1587 ),
1588 ),
1589 );
1590 yield* record("pending");
1591 // Only this call can hand the agent its ref, so the card must not
1592 // outlive it: a timeout, a failed wait or an aborted call closes it as
1593 // cancelled. If even that fails, the server still refuses an answer
1594 // once the run ends, and an unused value expires.
1595 const closeCard = record("cancelled").pipe(
1596 Effect.catch((error) =>
1597 Effect.logWarning("Could not close a secret request card", { error: error.message }),
1598 ),
1599 );
1600
1601 // The user answers the card (secrets.answerRequest), or it ends with
1602 // the run; poll it like a delegated task.
1603 const answered = yield* Effect.gen(function* () {
1604 while (true) {
1605 const projection = yield* threadManagement
1606 .getThreadRecords(threadId, ["runs", "turnItems"], {
1607 turnItemTypes: ["secret_request"],
1608 messageRoles: [],
1609 })
1610 .pipe(
1611 Effect.mapError((error) =>
1612 failure(
1613 "orchestration_error",
1614 `Unable to read the secret request: ${errorMessage(error)}`,
1615 ),
1616 ),
1617 );
1618 const item = projection.turnItems.find((candidate) => candidate.id === turnItemId);
1619 if (item?.type === "secret_request" && item.secretStatus !== "pending") {
1620 return item.secretStatus;
1621 }
1622 const current = projection.runs.find((candidate) => candidate.id === runId);
1623 if (
1624 current === undefined ||
1625 ThreadManagementService.isTerminalRunStatus(current.status)
1626 ) {
1627 yield* record("cancelled");
1628 return "cancelled" as const;
1629 }
1630 yield* Effect.sleep(Duration.millis(SECRET_REQUEST_POLL_INTERVAL_MS));
1631 }
1632 }).pipe(
1633 Effect.timeoutOption(
1634 Duration.millis(
1635 Math.min(input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS),
1636 ),
1637 ),
1638 Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : closeCard)),
1639 );
1640 if (Option.isNone(answered)) yield* closeCard;
1641 // An answer that raced the timeout still wins: the card is answered once.
1642 const status = Option.isSome(answered)
1643 ? answered.value
1644 : yield* threadManagement
1645 .getThreadRecords(threadId, ["turnItems"], {
1646 turnItemTypes: ["secret_request"],
1647 messageRoles: [],
1648 })
1649 .pipe(
1650 Effect.map((records) => {
1651 const item = records.turnItems.find((candidate) => candidate.id === turnItemId);
1652 return item?.type === "secret_request" &&
1653 (item.secretStatus === "saved" || item.secretStatus === "declined")
1654 ? item.secretStatus
1655 : ("timed_out" as const);
1656 }),
1657 Effect.orElseSucceed(() => "timed_out" as const),
1658 );
1659 yield* Effect.annotateCurrentSpan({ "secret_request.status": status });
1660 yield* Metrics.increment(Metrics.secretRequestsTotal, { status });
1661 if (status !== "saved") return { status };
1662 // Saved means the value was stored before the card said so; a missing
1663 // value is a storage fault, not an answer the agent can act on.
1664 const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId });
1665 if (Option.isNone(secretRef)) {
1666 return yield* failure(
1667 "orchestration_error",
1668 "The user saved the secret, but it could not be read. Ask again with a new clientRequestId.",
1669 );
1670 }
1671 return { status, secretRef: secretRef.value };
1672 }).pipe(Effect.withSpan("OrchestratorMcpService.requestSecret")),
1673
1517 capabilities: (scope) =>1674 capabilities: (scope) =>
1518 Effect.gen(function* () {1675 Effect.gen(function* () {
1519 const { parent, limits } = yield* loadCaller(scope);1676 const { parent, limits } = yield* loadCaller(scope);
...@@ -2192,4 +2349,5 @@ export const layer: Layer.Layer<...@@ -2192,4 +2349,5 @@ export const layer: Layer.Layer<
2192 | ProviderAdapterRegistry.ProviderAdapterRegistryV22349 | ProviderAdapterRegistry.ProviderAdapterRegistryV2
2193 | ScheduledTaskService.ScheduledTaskService2350 | ScheduledTaskService.ScheduledTaskService
2194 | ProjectService.ProjectService2351 | ProjectService.ProjectService
2352 | SecretRequests.SecretRequests
2195> = Layer.effect(OrchestratorMcpService, make);2353> = Layer.effect(OrchestratorMcpService, make);
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+156-1
...@@ -44,12 +44,14 @@ import * as Option from "effect/Option";...@@ -44,12 +44,14 @@ import * as Option from "effect/Option";
44import * as PubSub from "effect/PubSub";44import * as PubSub from "effect/PubSub";
45import * as Ref from "effect/Ref";45import * as Ref from "effect/Ref";
46import * as Schema from "effect/Schema";46import * as Schema from "effect/Schema";
47import * as Fiber from "effect/Fiber";
47import * as Stream from "effect/Stream";48import * as Stream from "effect/Stream";
48import { McpSchema, McpServer } from "effect/ai";49import { McpSchema, McpServer } from "effect/ai";
4950
50import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";51import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
51import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";52import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
52import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts";53import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts";
54import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts";
53import * as EventSink from "../orchestration-v2/EventSink.ts";55import * as EventSink from "../orchestration-v2/EventSink.ts";
54import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";56import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
55import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";57import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
...@@ -73,6 +75,8 @@ import {...@@ -73,6 +75,8 @@ import {
73import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";75import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
74import * as ProjectService from "../project/ProjectService.ts";76import * as ProjectService from "../project/ProjectService.ts";
75import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";77import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
78import * as SecretRequests from "../secrets/SecretRequests.ts";
79import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
76import * as McpHttpServer from "./McpHttpServer.ts";80import * as McpHttpServer from "./McpHttpServer.ts";
77import * as McpInvocationContext from "./McpInvocationContext.ts";81import * as McpInvocationContext from "./McpInvocationContext.ts";
78import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts";82import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts";
...@@ -444,7 +448,19 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask...@@ -444,7 +448,19 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask
444 prompt: input.prompt,448 prompt: input.prompt,
445 enabled: input.enabled,449 enabled: input.enabled,
446 schedule:450 schedule:
447 input.schedule.type === "webhook" ? { type: "webhook", signature: null } : input.schedule,451 input.schedule.type === "webhook"
452 ? {
453 type: "webhook",
454 signature:
455 input.schedule.signature == null
456 ? null
457 : {
458 header: input.schedule.signature.header,
459 encoding: input.schedule.signature.encoding,
460 prefix: input.schedule.signature.prefix,
461 },
462 }
463 : input.schedule,
448 projectId: input.projectId,464 projectId: input.projectId,
449 threadId: input.threadId ?? null,465 threadId: input.threadId ?? null,
450 workspaceStrategy: input.workspaceStrategy,466 workspaceStrategy: input.workspaceStrategy,
...@@ -463,6 +479,25 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask...@@ -463,6 +479,25 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask
463 };479 };
464}480}
465481
482/** In-memory server secret store for tests that exercise secret requests. */
483const memorySecretStoreLayer = Layer.sync(ServerSecretStore.ServerSecretStore, () => {
484 const stored = new Map<string, Uint8Array>();
485 return ServerSecretStore.ServerSecretStore.of({
486 get: (name) => Effect.succeed(Option.fromNullishOr(stored.get(name))),
487 set: (name, value) => Effect.sync(() => void stored.set(name, value)),
488 create: (name, value) => Effect.sync(() => void stored.set(name, value)),
489 getOrCreateRandom: (name, bytes) =>
490 Effect.sync(() => {
491 const existing = stored.get(name);
492 if (existing) return existing;
493 const value = new Uint8Array(bytes).fill(7);
494 stored.set(name, value);
495 return value;
496 }),
497 remove: (name) => Effect.sync(() => void stored.delete(name)),
498 });
499});
500
466const unusedScheduledTaskStubLayer = Layer.succeed(501const unusedScheduledTaskStubLayer = Layer.succeed(
467 ScheduledTaskService.ScheduledTaskService,502 ScheduledTaskService.ScheduledTaskService,
468 ScheduledTaskService.ScheduledTaskService.of({503 ScheduledTaskService.ScheduledTaskService.of({
...@@ -658,6 +693,12 @@ describe("orchestrator MCP toolkit", () => {...@@ -658,6 +693,12 @@ describe("orchestrator MCP toolkit", () => {
658 ),693 ),
659 }),694 }),
660 ),695 ),
696 Layer.provideMerge(
697 SecretRequests.layer.pipe(
698 Layer.provide(memorySecretStoreLayer),
699 Layer.provide(orchestrationLayer),
700 ),
701 ),
661 Layer.provide(NodeServices.layer),702 Layer.provide(NodeServices.layer),
662 );703 );
663704
...@@ -1472,6 +1513,114 @@ describe("orchestrator MCP toolkit", () => {...@@ -1472,6 +1513,114 @@ describe("orchestrator MCP toolkit", () => {
1472 });1513 });
1473 expect(yield* Ref.get(scheduledStore)).toHaveLength(0);1514 expect(yield* Ref.get(scheduledStore)).toHaveLength(0);
14741515
1516 // The agent asks for a secret; the tool waits for the user and
1517 // returns a one-use ref, never the value, which a signed webhook
1518 // task then consumes.
1519 const secretRequests = yield* SecretRequests.SecretRequests;
1520 const secretFiber = yield* invoke("request_secret", {
1521 label: "GitHub webhook secret",
1522 reason: "Signs release webhooks. Enter the same value in GitHub's webhook settings.",
1523 placeholder: "Paste the webhook secret",
1524 clientRequestId: "release-webhook-secret",
1525 }).pipe(Effect.forkChild);
1526 // Polled without the helper's short budget: under load the tool's
1527 // own reads come first.
1528 const asked = yield* Effect.gen(function* () {
1529 while (true) {
1530 const projection = yield* orchestrator.getThreadProjection(parentThreadId);
1531 if (
1532 projection.turnItems.some(
1533 (item) => item.type === "secret_request" && item.secretStatus === "pending",
1534 )
1535 ) {
1536 return projection;
1537 }
1538 yield* Effect.sleep("5 millis");
1539 }
1540 });
1541 const card = asked.turnItems.find((item) => item.type === "secret_request");
1542 if (card?.type !== "secret_request") {
1543 return yield* Effect.die(new Error("Secret request card missing."));
1544 }
1545 expect(card).toMatchObject({
1546 label: "GitHub webhook secret",
1547 placeholder: "Paste the webhook secret",
1548 });
1549 // Asking again with the same id leaves the open card exactly as it was.
1550 yield* orchestrator.dispatch({
1551 type: "secret_request.record",
1552 commandId: CommandId.make("command:test:secret-request-replay"),
1553 threadId: parentThreadId,
1554 runId: card.runId!,
1555 nodeId: card.nodeId!,
1556 turnItemId: card.id,
1557 label: "Something else",
1558 reason: "A different reason.",
1559 secretStatus: "pending",
1560 });
1561 expect(
1562 (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.find(
1563 (item) => item.id === card.id,
1564 ),
1565 ).toMatchObject({ label: "GitHub webhook secret", runId: card.runId });
1566 // The agent is blocked on the user, so the thread asks for input
1567 // like a question does, in both shell paths.
1568 expect(
1569 (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest,
1570 ).toMatchObject({ kind: "user_input" });
1571 expect(threadShellFromProjection(asked).pendingRuntimeRequest).toMatchObject({
1572 kind: "user_input",
1573 });
1574 // What the card's Save sends (secrets.answerRequest).
1575 yield* secretRequests.answer({
1576 threadId: parentThreadId,
1577 turnItemId: card.id,
1578 answer: { type: "save", secret: "github-webhook-secret" },
1579 });
1580 const secretCall = yield* Fiber.join(secretFiber);
1581 expect(secretCall.isError).toBe(false);
1582 const secretResult = secretCall.structuredContent as {
1583 status: string;
1584 secretRef?: string;
1585 };
1586 expect(secretResult.status).toBe("saved");
1587 expect(
1588 (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest ?? null,
1589 ).toBeNull();
1590 expect(secretResult.secretRef).toMatch(/^secret-ref:[0-9a-f]{32}$/);
1591 // The value appears nowhere in what the agent received.
1592 const received = [
1593 ...secretCall.content.map((part) => ("text" in part ? part.text : "")),
1594 ...Object.values(secretResult),
1595 ];
1596 expect(received.some((value) => value.includes("github-webhook-secret"))).toBe(false);
1597
1598 // A retry that lost the first result gets the same answer, with no
1599 // second card for the user.
1600 const retried = yield* invoke("request_secret", {
1601 label: "GitHub webhook secret",
1602 reason: "Signs release webhooks. Enter the same value in GitHub's webhook settings.",
1603 clientRequestId: "release-webhook-secret",
1604 });
1605 expect(retried.structuredContent).toEqual(secretResult);
1606 expect(
1607 (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.filter(
1608 (item) => item.type === "secret_request",
1609 ),
1610 ).toHaveLength(1);
1611
1612 // The ref is the secret for exactly one consumer.
1613 expect(
1614 yield* secretRequests.consume({
1615 ref: secretResult.secretRef as never,
1616 projectId,
1617 }),
1618 ).toBe("github-webhook-secret");
1619 const reused = yield* secretRequests
1620 .consume({ ref: secretResult.secretRef as never, projectId })
1621 .pipe(Effect.flip);
1622 expect(reused.message).toContain("already used");
1623
1475 const delegatedCall = yield* invoke("delegate_task", {1624 const delegatedCall = yield* invoke("delegate_task", {
1476 task: delegatedPrompt,1625 task: delegatedPrompt,
1477 target: {1626 target: {
...@@ -3570,6 +3719,12 @@ describe("orchestrator MCP toolkit", () => {...@@ -3570,6 +3719,12 @@ describe("orchestrator MCP toolkit", () => {
3570 Layer.provide(providerRegistryLayer),3719 Layer.provide(providerRegistryLayer),
3571 Layer.provide(unusedScheduledTaskStubLayer),3720 Layer.provide(unusedScheduledTaskStubLayer),
3572 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),3721 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
3722 Layer.provideMerge(
3723 SecretRequests.layer.pipe(
3724 Layer.provide(memorySecretStoreLayer),
3725 Layer.provide(orchestrationLayer),
3726 ),
3727 ),
3573 Layer.provide(NodeServices.layer),3728 Layer.provide(NodeServices.layer),
3574 );3729 );
35753730
apps/server/src/mcp/toolkits/core.test.ts+4
...@@ -23,6 +23,7 @@ import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapter...@@ -23,6 +23,7 @@ import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapter
23import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";23import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
24import * as ProjectService from "../../project/ProjectService.ts";24import * as ProjectService from "../../project/ProjectService.ts";
25import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts";25import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts";
26import * as SecretRequests from "../../secrets/SecretRequests.ts";
26import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";27import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
27import * as McpHttpServer from "../McpHttpServer.ts";28import * as McpHttpServer from "../McpHttpServer.ts";
28import * as McpInvocationContext from "../McpInvocationContext.ts";29import * as McpInvocationContext from "../McpInvocationContext.ts";
...@@ -386,6 +387,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>...@@ -386,6 +387,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>
386 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),387 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
387 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),388 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
388 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),389 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
390 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
389 ),391 ),
390 ),392 ),
391 ),393 ),
...@@ -437,6 +439,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode...@@ -437,6 +439,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode
437 }),439 }),
438 ),440 ),
439 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),441 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
442 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
440 ),443 ),
441 ),444 ),
442 ),445 ),
...@@ -505,6 +508,7 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()...@@ -505,6 +508,7 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()
505 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),508 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
506 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),509 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
507 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),510 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
511 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
508 ),512 ),
509 ),513 ),
510 ),514 ),
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+6
...@@ -54,6 +54,12 @@ const handlers = {...@@ -54,6 +54,12 @@ const handlers = {
54 const service = yield* OrchestratorMcpService.OrchestratorMcpService;54 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
55 return yield* service.deleteScheduledTask(scope, input);55 return yield* service.deleteScheduledTask(scope, input);
56 }),56 }),
57 request_secret: (input) =>
58 Effect.gen(function* () {
59 const scope = yield* McpInvocationContext.McpInvocationContext;
60 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
61 return yield* service.requestSecret(scope, input);
62 }),
57 create_threads: (input) =>63 create_threads: (input) =>
58 Effect.gen(function* () {64 Effect.gen(function* () {
59 const scope = yield* McpInvocationContext.McpInvocationContext;65 const scope = yield* McpInvocationContext.McpInvocationContext;
apps/server/src/mcp/toolkits/orchestrator/tools.ts+16-1
...@@ -6,6 +6,8 @@ import {...@@ -6,6 +6,8 @@ import {
6 OrchestratorMcpDelegateTaskResult,6 OrchestratorMcpDelegateTaskResult,
7 OrchestratorMcpDeleteScheduledTaskInput,7 OrchestratorMcpDeleteScheduledTaskInput,
8 OrchestratorMcpDeleteScheduledTaskResult,8 OrchestratorMcpDeleteScheduledTaskResult,
9 OrchestratorMcpRequestSecretInput,
10 OrchestratorMcpRequestSecretResult,
9 OrchestratorMcpFailure,11 OrchestratorMcpFailure,
10 OrchestratorMcpListScheduledTasksInput,12 OrchestratorMcpListScheduledTasksInput,
11 OrchestratorMcpListScheduledTasksResult,13 OrchestratorMcpListScheduledTasksResult,
...@@ -97,7 +99,7 @@ const TaskCancelTool = Tool.make("task_cancel", {...@@ -97,7 +99,7 @@ const TaskCancelTool = Tool.make("task_cancel", {
9799
98export const ScheduleTaskTool = Tool.make("schedule_task", {100export const ScheduleTaskTool = Tool.make("schedule_task", {
99 description:101 description:
100 "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings; {type:'webhook'} runs on each request to a generated URL (returned as webhookUrl), and its prompt may use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} or {{request}} placeholders, which are the only request data the run sees. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true); use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread. Report the returned schedule and nextRunAt after success.",102 "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
101 parameters: OrchestratorMcpScheduleTaskInput,103 parameters: OrchestratorMcpScheduleTaskInput,
102 success: OrchestratorMcpScheduleTaskResult,104 success: OrchestratorMcpScheduleTaskResult,
103 failure: OrchestratorMcpFailure,105 failure: OrchestratorMcpFailure,
...@@ -146,6 +148,18 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", {...@@ -146,6 +148,18 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", {
146 .annotate(Tool.Title, "Delete a scheduled task")148 .annotate(Tool.Title, "Delete a scheduled task")
147 .annotate(Tool.Destructive, true);149 .annotate(Tool.Destructive, true);
148150
151const RequestSecretTool = Tool.make("request_secret", {
152 description:
153 "Ask the user for a secret (a token, API key, signing secret, password) through a private card in this thread, and wait for them to answer. The value is kept by the app and NEVER returned to you or shown in the transcript. When saved, the result carries a secretRef: pass it to a tool that accepts one (e.g. schedule_task's signature.secretRef). It works once. Never ask for secrets in chat, and never invent one.",
154 parameters: OrchestratorMcpRequestSecretInput,
155 success: OrchestratorMcpRequestSecretResult,
156 failure: OrchestratorMcpFailure,
157 failureMode: "return",
158 dependencies,
159})
160 .annotate(Tool.Title, "Request a secret from the user")
161 .annotate(Tool.Destructive, false);
162
149export const CreateThreadsTool = Tool.make("create_threads", {163export const CreateThreadsTool = Tool.make("create_threads", {
150 description:164 description:
151 "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.",165 "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.",
...@@ -248,6 +262,7 @@ export const OrchestratorToolkit = Toolkit.make(...@@ -248,6 +262,7 @@ export const OrchestratorToolkit = Toolkit.make(
248 ListScheduledTasksTool,262 ListScheduledTasksTool,
249 UpdateScheduledTaskTool,263 UpdateScheduledTaskTool,
250 DeleteScheduledTaskTool,264 DeleteScheduledTaskTool,
265 RequestSecretTool,
251 CreateThreadsTool,266 CreateThreadsTool,
252 ThreadListTool,267 ThreadListTool,
253 ThreadReadTool,268 ThreadReadTool,
apps/server/src/mcp/toolkits/worktree/registration.test.ts+2
...@@ -19,6 +19,7 @@ import * as ProjectService from "../../../project/ProjectService.ts";...@@ -19,6 +19,7 @@ import * as ProjectService from "../../../project/ProjectService.ts";
19import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";19import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
20import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts";20import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts";
21import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";21import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";
22import * as SecretRequests from "../../../secrets/SecretRequests.ts";
22import * as ServerSettings from "../../../serverSettings.ts";23import * as ServerSettings from "../../../serverSettings.ts";
23import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";24import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";
24import * as McpHttpServer from "../../McpHttpServer.ts";25import * as McpHttpServer from "../../McpHttpServer.ts";
...@@ -33,6 +34,7 @@ const StubServicesLive = Layer.mergeAll(...@@ -33,6 +34,7 @@ const StubServicesLive = Layer.mergeAll(
33 Layer.mock(ProviderRegistry.ProviderRegistry)({}),34 Layer.mock(ProviderRegistry.ProviderRegistry)({}),
34 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),35 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
35 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),36 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
37 Layer.mock(SecretRequests.SecretRequests)({}),
36 Layer.mock(ProjectService.ProjectService)({}),38 Layer.mock(ProjectService.ProjectService)({}),
37 ServerSettings.layerTest({}),39 ServerSettings.layerTest({}),
38 Layer.mock(GitWorkflowService.GitWorkflowService)({}),40 Layer.mock(GitWorkflowService.GitWorkflowService)({}),
apps/server/src/observability/Metrics.ts+10
...@@ -94,6 +94,16 @@ export const webhookRunsTotal = Metric.counter("t3_webhook_runs_total", {...@@ -94,6 +94,16 @@ export const webhookRunsTotal = Metric.counter("t3_webhook_runs_total", {
94 description: "Runs started from webhook deliveries, by outcome.",94 description: "Runs started from webhook deliveries, by outcome.",
95});95});
9696
97/** Secrets agents asked users for, by how each ended: saved, declined, cancelled, timed_out. */
98export const secretRequestsTotal = Metric.counter("t3_secret_requests_total", {
99 description: "Secrets agents asked users for, by how each request ended.",
100});
101
102/** One-use secret refs a tool tried to use, by result: used, rejected. */
103export const secretRefsConsumedTotal = Metric.counter("t3_secret_refs_consumed_total", {
104 description: "Secret refs tools tried to use, by result.",
105});
106
97export const metricAttributes = (107export const metricAttributes = (
98 attributes: Readonly<Record<string, unknown>>,108 attributes: Readonly<Record<string, unknown>>,
99): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes));109): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes));
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+2
...@@ -24,6 +24,7 @@ import * as Queue from "effect/Queue";...@@ -24,6 +24,7 @@ import * as Queue from "effect/Queue";
24import * as Schema from "effect/Schema";24import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";25import * as Stream from "effect/Stream";
26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";
27import * as Scheduler from "../scheduling/Scheduler.ts";28import * as Scheduler from "../scheduling/Scheduler.ts";
28import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";29import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
29import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";30import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";
...@@ -315,6 +316,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con...@@ -315,6 +316,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
315 }),316 }),
316 ),317 ),
317 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),318 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
319 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
318 Layer.provide(320 Layer.provide(
319 Layer.mergeAll(NodeCrypto.layer, Scheduler.layer, SqlitePersistenceMemory),321 Layer.mergeAll(NodeCrypto.layer, Scheduler.layer, SqlitePersistenceMemory),
320 ),322 ),
apps/server/src/orchestration-v2/Orchestrator.ts+98
...@@ -439,6 +439,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {...@@ -439,6 +439,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
439 case "delegated_task.completion-delivery.dispose":439 case "delegated_task.completion-delivery.dispose":
440 case "thread.created.record":440 case "thread.created.record":
441 return command.parentThreadId;441 return command.parentThreadId;
442 case "secret_request.record":
443 return command.threadId;
442 case "thread.fork":444 case "thread.fork":
443 case "thread.merge_back":445 case "thread.merge_back":
444 return command.targetThreadId;446 return command.targetThreadId;
...@@ -6889,6 +6891,99 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -6889,6 +6891,99 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
6889 },6891 },
6890 );6892 );
68916893
6894 /**
6895 * Records or updates the card for a secret an agent asked the user for. The
6896 * item carries the request and its status only; the value goes straight to
6897 * the server's secret store and never through orchestration.
6898 */
6899 const dispatchSecretRequestRecord = Effect.fn("orchestrationV2.dispatch.secretRequestRecord")(
6900 function* (
6901 command: Extract<OrchestrationV2InternalCommand, { readonly type: "secret_request.record" }>,
6902 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
6903 ) {
6904 const projection = yield* projectionStore
6905 .getThreadRecords(
6906 command.threadId,
6907 ["runs", "nodes", "turnItems", "attempts", "providerTurns"],
6908 { turnItemTypes: ["secret_request"], messageRoles: [] },
6909 )
6910 .pipe(
6911 Effect.mapError(
6912 (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
6913 ),
6914 );
6915 const run = projection.runs.find((candidate) => candidate.id === command.runId);
6916 if (run === undefined || run.rootNodeId !== command.nodeId) {
6917 return yield* new OrchestratorDispatchError({
6918 commandId: command.commandId,
6919 commandType: command.type,
6920 cause: `Node ${command.nodeId} is not the root of run ${command.runId}.`,
6921 });
6922 }
6923 const existing = projection.turnItems.find((item) => item.id === command.turnItemId);
6924 if (existing !== undefined && existing.type !== "secret_request") {
6925 return yield* new OrchestratorDispatchError({
6926 commandId: command.commandId,
6927 commandType: command.type,
6928 cause: `Turn item ${command.turnItemId} is not a secret request.`,
6929 });
6930 }
6931 if (existing !== undefined && existing.runId !== command.runId) {
6932 return yield* new OrchestratorDispatchError({
6933 commandId: command.commandId,
6934 commandType: command.type,
6935 cause: `Secret request ${command.turnItemId} belongs to another run.`,
6936 });
6937 }
6938 const now = yield* DateTime.now;
6939 // A request is answered once, and a retry finds its card as it was
6940 // asked: either one records the card unchanged.
6941 const unchanged =
6942 existing !== undefined &&
6943 (existing.secretStatus !== "pending" || command.secretStatus === "pending");
6944 const pending = command.secretStatus === "pending";
6945 const turnItem: OrchestrationV2TurnItem = unchanged
6946 ? existing
6947 : {
6948 id: command.turnItemId,
6949 threadId: command.threadId,
6950 runId: command.runId,
6951 nodeId: command.nodeId,
6952 providerThreadId: run.providerThreadId,
6953 providerTurnId: providerTurnForRun(projection, run)?.id ?? null,
6954 nativeItemRef: null,
6955 parentItemId: null,
6956 ordinal: existing?.ordinal ?? (yield* nextTurnItemOrdinal(projection)),
6957 status: pending
6958 ? "waiting"
6959 : command.secretStatus === "saved"
6960 ? "completed"
6961 : "cancelled",
6962 title: command.label,
6963 startedAt: existing?.startedAt ?? now,
6964 completedAt: pending ? null : now,
6965 updatedAt: now,
6966 type: "secret_request",
6967 label: command.label,
6968 reason: command.reason,
6969 ...(command.placeholder === undefined ? {} : { placeholder: command.placeholder }),
6970 secretStatus: command.secretStatus,
6971 };
6972 yield* emit(
6973 events,
6974 command,
6975 )({
6976 type: "turn-item.updated",
6977 threadId: command.threadId,
6978 runId: command.runId,
6979 nodeId: command.nodeId,
6980 providerInstanceId: run.providerInstanceId,
6981 occurredAt: now,
6982 payload: turnItem,
6983 });
6984 },
6985 );
6986
6892 const dispatchRuntimeRequestRespond = (6987 const dispatchRuntimeRequestRespond = (
6893 command: Extract<OrchestrationV2Command, { readonly type: "runtime-request.respond" }>,6988 command: Extract<OrchestrationV2Command, { readonly type: "runtime-request.respond" }>,
6894 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,6989 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
...@@ -10008,6 +10103,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -10008,6 +10103,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10008 case "thread.created.record":10103 case "thread.created.record":
10009 yield* dispatchCreatedThreadRecord(command, events);10104 yield* dispatchCreatedThreadRecord(command, events);
10010 break;10105 break;
10106 case "secret_request.record":
10107 yield* dispatchSecretRequestRecord(command, events);
10108 break;
10011 default:10109 default:
10012 return yield* dispatchUnsupported(command);10110 return yield* dispatchUnsupported(command);
10013 }10111 }
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+4
...@@ -521,5 +521,9 @@ it.effect("shell failure lookups stay on the thread's own turn items", () =>...@@ -521,5 +521,9 @@ it.effect("shell failure lookups stay on the thread's own turn items", () =>
521 const itemLookups = plan.filter((row) => row.detail.startsWith("SEARCH item "));521 const itemLookups = plan.filter((row) => row.detail.startsWith("SEARCH item "));
522 assert.lengthOf(itemLookups, 2);522 assert.lengthOf(itemLookups, 2);
523 assert.isTrue(itemLookups.every((row) => row.detail.includes("turn_items_thread_run_idx")));523 assert.isTrue(itemLookups.every((row) => row.detail.includes("turn_items_thread_run_idx")));
524 // The pending secret request lookup is bounded the same way.
525 const secretLookups = plan.filter((row) => row.detail.startsWith("SEARCH secret "));
526 assert.lengthOf(secretLookups, 1);
527 assert.include(secretLookups[0]!.detail, "turn_items_thread_run_idx");
524 }).pipe(Effect.provide(SqlLayer)),528 }).pipe(Effect.provide(SqlLayer)),
525);529);
apps/server/src/orchestration-v2/ProjectionStore.ts+59-5
...@@ -28,7 +28,6 @@ import type {...@@ -28,7 +28,6 @@ import type {
28 ProviderThreadId,28 ProviderThreadId,
29 ProviderTurnId,29 ProviderTurnId,
30 RunAttemptId,30 RunAttemptId,
31 RuntimeRequestId,
32 MessageId,31 MessageId,
33} from "@t3tools/contracts";32} from "@t3tools/contracts";
34import {33import {
...@@ -50,6 +49,7 @@ import {...@@ -50,6 +49,7 @@ import {
50 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,49 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,
51 orchestrationV2RunWorkStartedAt,50 orchestrationV2RunWorkStartedAt,
52 RunId,51 RunId,
52 RuntimeRequestId,
53 CheckpointScopeId,53 CheckpointScopeId,
54 ThreadId,54 ThreadId,
55 TurnItemId,55 TurnItemId,
...@@ -919,6 +919,7 @@ type ShellThreadRow = {...@@ -919,6 +919,7 @@ type ShellThreadRow = {
919 readonly blocking_run_completed_at: string | null;919 readonly blocking_run_completed_at: string | null;
920 readonly blocking_failure_payload_json: string | null;920 readonly blocking_failure_payload_json: string | null;
921 readonly pending_request_payload_json: string | null;921 readonly pending_request_payload_json: string | null;
922 readonly pending_secret_request_payload_json: string | null;
922 readonly latest_user_message_at: string | null;923 readonly latest_user_message_at: string | null;
923 readonly latest_user_authored_message_at: string | null;924 readonly latest_user_authored_message_at: string | null;
924 readonly has_actionable_proposed_plan: number;925 readonly has_actionable_proposed_plan: number;
...@@ -1303,6 +1304,29 @@ function buildVisibleTurnItems(input: {...@@ -1303,6 +1304,29 @@ function buildVisibleTurnItems(input: {
1303 ]);1304 ]);
1304}1305}
13051306
1307/**
1308 * An agent waiting on a secret is waiting on the user just like a question,
1309 * so the shell reports it as pending user input. Secret requests have no
1310 * runtime request of their own; this stands one in for the shell summary
1311 * only, keyed by the card's turn item.
1312 */
1313function secretRequestAsPendingInput(
1314 item: OrchestrationV2TurnItem | null,
1315): OrchestrationV2ThreadProjection["runtimeRequests"][number] | null {
1316 if (item?.type !== "secret_request" || item.nodeId === null) return null;
1317 return {
1318 id: RuntimeRequestId.make(item.id),
1319 nodeId: item.nodeId,
1320 providerTurnId: item.providerTurnId,
1321 nativeRequestRef: null,
1322 kind: "user_input",
1323 status: "pending",
1324 responseCapability: { type: "message" },
1325 createdAt: item.startedAt ?? item.updatedAt,
1326 resolvedAt: null,
1327 };
1328}
1329
1306export function threadShellFromProjection(1330export function threadShellFromProjection(
1307 projection: OrchestrationV2ThreadProjection,1331 projection: OrchestrationV2ThreadProjection,
1308): OrchestrationV2ThreadShell {1332): OrchestrationV2ThreadShell {
...@@ -1327,13 +1351,28 @@ export function threadShellFromProjection(...@@ -1327,13 +1351,28 @@ export function threadShellFromProjection(
1327 projection.runs1351 projection.runs
1328 .filter(isActivityRunForShell)1352 .filter(isActivityRunForShell)
1329 .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null;1353 .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null;
1354 const liveRunIds = new Set(projection.runs.filter(isActivityRunForShell).map((run) => run.id));
1330 const pendingRuntimeRequest =1355 const pendingRuntimeRequest =
1331 projection.runtimeRequests1356 projection.runtimeRequests
1332 .filter((request) => request.status === "pending")1357 .filter((request) => request.status === "pending")
1333 .toSorted(1358 .toSorted(
1334 (left, right) =>1359 (left, right) =>
1335 DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt),1360 DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt),
1336 )[0] ?? null;1361 )[0] ??
1362 secretRequestAsPendingInput(
1363 projection.turnItems
1364 .filter(
1365 (item) =>
1366 item.type === "secret_request" &&
1367 item.status === "waiting" &&
1368 item.runId !== null &&
1369 liveRunIds.has(item.runId),
1370 )
1371 .toSorted(
1372 (left, right) =>
1373 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
1374 )[0] ?? null,
1375 );
1337 const userMessages = projection.messages1376 const userMessages = projection.messages
1338 .filter((message) => message.role === "user")1377 .filter((message) => message.role === "user")
1339 .toSorted(1378 .toSorted(
...@@ -4958,6 +4997,17 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -4958,6 +4997,17 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
4958 ORDER BY request.created_at DESC, request.runtime_request_id DESC4997 ORDER BY request.created_at DESC, request.runtime_request_id DESC
4959 LIMIT 14998 LIMIT 1
4960 ) AS pending_request_payload_json,4999 ) AS pending_request_payload_json,
5000 (
5001 SELECT secret.payload_json
5002 FROM orchestration_v2_projection_turn_items secret
5003 INDEXED BY orchestration_v2_projection_turn_items_thread_run_idx
5004 INNER JOIN orchestration_v2_projection_runs r ON r.run_id = secret.run_id
5005 WHERE secret.thread_id = t.thread_id
5006 AND secret.type = 'secret_request' AND secret.status = 'waiting'
5007 AND r.status IN ('preparing', 'starting', 'running', 'waiting')
5008 ORDER BY secret.updated_at DESC, secret.turn_item_id DESC
5009 LIMIT 1
5010 ) AS pending_secret_request_payload_json,
4961 (5011 (
4962 SELECT message.updated_at5012 SELECT message.updated_at
4963 FROM orchestration_v2_projection_messages message5013 FROM orchestration_v2_projection_messages message
...@@ -5329,9 +5379,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5329,9 +5379,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5329 } = input;5379 } = input;
5330 const thread = yield* decodeThreadPayload(row.payload_json);5380 const thread = yield* decodeThreadPayload(row.payload_json);
5331 const pendingRuntimeRequest =5381 const pendingRuntimeRequest =
5332 row.pending_request_payload_json === null5382 row.pending_request_payload_json !== null
5333 ? null5383 ? yield* decodeRuntimeRequestPayload(row.pending_request_payload_json)
5334 : yield* decodeRuntimeRequestPayload(row.pending_request_payload_json);5384 : row.pending_secret_request_payload_json !== null
5385 ? secretRequestAsPendingInput(
5386 yield* decodeTurnItemPayload(row.pending_secret_request_payload_json),
5387 )
5388 : null;
5335 let terminalFailureItem =5389 let terminalFailureItem =
5336 row.terminal_failure_payload_json === null5390 row.terminal_failure_payload_json === null
5337 ? null5391 ? null
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts+65
...@@ -578,6 +578,71 @@ it.effect("cancels a stale waiting run when no checkpoint capture can finish it"...@@ -578,6 +578,71 @@ it.effect("cancels a stale waiting run when no checkpoint capture can finish it"
578 }).pipe(Effect.provide(layer));578 }).pipe(Effect.provide(layer));
579});579});
580580
581it.effect("closes a secret request card's form when its run is recovered", () => {
582 const threadId = ThreadId.make("thread_secret_recovery");
583 const runId = RunId.make("run_secret_recovery");
584 let committedInput: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | null =
585 null;
586 const projection = {
587 thread: { id: threadId },
588 runtimeRequests: [],
589 providerSessions: [],
590 providerThreads: [],
591 providerTurns: [],
592 runs: [{ id: runId, status: "running", providerInstanceId: ProviderInstanceId.make("codex") }],
593 attempts: [],
594 nodes: [],
595 subagents: [],
596 messages: [],
597 turnItems: [
598 {
599 id: "turn-item:secret-request:recovery",
600 threadId,
601 runId,
602 nodeId: null,
603 type: "secret_request",
604 status: "waiting",
605 secretStatus: "pending",
606 label: "GitHub token",
607 reason: "Used as GH_TOKEN.",
608 },
609 ],
610 } as unknown as OrchestrationV2ThreadProjection;
611 const layer = ProviderRuntimeRecovery.layer.pipe(
612 Layer.provide(ServerSettings.layerTest()),
613 Layer.provide(
614 Layer.mergeAll(
615 Layer.mock(ProjectionStore.ProjectionStoreV2)({
616 getRecoveryThreadIds: () => Effect.succeed([threadId]),
617 getRuntimeRecoveryProjection: () => Effect.succeed(projection),
618 }),
619 Layer.mock(EventSink.EventSinkV2)({
620 commitCommand: (input) => {
621 committedInput = input;
622 return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never);
623 },
624 }),
625 IdAllocator.layer,
626 Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({
627 runRecoveryOnce: Effect.succeed(false),
628 }),
629 Layer.mock(EffectOutbox.EffectOutboxV2)({
630 listByCommandId: () => Effect.succeed([]),
631 reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }),
632 }),
633 ),
634 ),
635 );
636
637 return Effect.gen(function* () {
638 yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup");
639 const itemEvent = committedInput?.events.find((event) => event.type === "turn-item.updated");
640 const card = itemEvent?.type === "turn-item.updated" ? itemEvent.payload : null;
641 assert.equal(card?.status, "cancelled");
642 assert.equal(card?.type === "secret_request" ? card.secretStatus : null, "cancelled");
643 }).pipe(Effect.provide(layer));
644});
645
581it.effect("holds accepted queued work without cancelling its execution state after restart", () => {646it.effect("holds accepted queued work without cancelling its execution state after restart", () => {
582 const threadId = ThreadId.make("thread_queued_restart");647 const threadId = ThreadId.make("thread_queued_restart");
583 const runId = RunId.make("run_queued_restart");648 const runId = RunId.make("run_queued_restart");
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts+11-2
...@@ -149,6 +149,15 @@ function resolveStaleBackgroundItemProviderInstanceId(...@@ -149,6 +149,15 @@ function resolveStaleBackgroundItemProviderInstanceId(
149 return projection.providerThreads[0]?.providerInstanceId ?? projection.thread.providerInstanceId;149 return projection.providerThreads[0]?.providerInstanceId ?? projection.thread.providerInstanceId;
150}150}
151151
152/** An open item closed by recovery. A secret card also closes its form, so it takes no answer. */
153const cancelledItem = (
154 item: OrchestrationV2ThreadProjection["turnItems"][number],
155 now: DateTime.Utc,
156): OrchestrationV2ThreadProjection["turnItems"][number] =>
157 item.type === "secret_request" && item.secretStatus === "pending"
158 ? { ...item, status: "cancelled", secretStatus: "cancelled", completedAt: now, updatedAt: now }
159 : { ...item, status: "cancelled", completedAt: now, updatedAt: now };
160
152/**161/**
153 * A provider thread's latest started run: the last turn that provider saw.162 * A provider thread's latest started run: the last turn that provider saw.
154 * Restart recovery records the thread's cancelled background work on it, and163 * Restart recovery records the thread's cancelled background work on it, and
...@@ -426,7 +435,7 @@ export const make = Effect.gen(function* () {...@@ -426,7 +435,7 @@ export const make = Effect.gen(function* () {
426 ...(item.nodeId === null ? {} : { nodeId: item.nodeId }),435 ...(item.nodeId === null ? {} : { nodeId: item.nodeId }),
427 providerInstanceId: run.providerInstanceId,436 providerInstanceId: run.providerInstanceId,
428 occurredAt: now,437 occurredAt: now,
429 payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now },438 payload: cancelledItem(item, now),
430 });439 });
431 }440 }
432 }441 }
...@@ -456,7 +465,7 @@ export const make = Effect.gen(function* () {...@@ -456,7 +465,7 @@ export const make = Effect.gen(function* () {
456 ...(item.nodeId === null || item.nodeId === undefined ? {} : { nodeId: item.nodeId }),465 ...(item.nodeId === null || item.nodeId === undefined ? {} : { nodeId: item.nodeId }),
457 providerInstanceId,466 providerInstanceId,
458 occurredAt: now,467 occurredAt: now,
459 payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now },468 payload: cancelledItem(item, now),
460 });469 });
461 if (item.nodeId !== null && item.nodeId !== undefined) {470 if (item.nodeId !== null && item.nodeId !== undefined) {
462 const staleItemNode = projection.nodes.find(471 const staleItemNode = projection.nodes.find(
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+9-1
...@@ -48,6 +48,7 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";...@@ -48,6 +48,7 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
48import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";48import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
49import * as ServerSettings from "../serverSettings.ts";49import * as ServerSettings from "../serverSettings.ts";
50import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";50import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
51import * as SecretRequests from "../secrets/SecretRequests.ts";
51import * as TextGeneration from "../textGeneration/TextGeneration.ts";52import * as TextGeneration from "../textGeneration/TextGeneration.ts";
52import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";53import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
53import * as CommandReceiptStore from "./CommandReceiptStore.ts";54import * as CommandReceiptStore from "./CommandReceiptStore.ts";
...@@ -288,7 +289,14 @@ it.effect.each(...@@ -288,7 +289,14 @@ it.effect.each(
288 ({ target, createdBy }) => {289 ({ target, createdBy }) => {
289 const harness = makeHarness();290 const harness = makeHarness();
290 const scheduledTasks = ScheduledTasks.layer.pipe(291 const scheduledTasks = ScheduledTasks.layer.pipe(
291 Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)),292 Layer.provide(
293 Layer.mergeAll(
294 harness.layer,
295 NodeCrypto.layer,
296 Scheduler.layer,
297 Layer.mock(SecretRequests.SecretRequests)({}),
298 ),
299 ),
292 );300 );
293 return Effect.gen(function* () {301 return Effect.gen(function* () {
294 const tasks = yield* ScheduledTasks.ScheduledTaskService;302 const tasks = yield* ScheduledTasks.ScheduledTaskService;
apps/server/src/orchestration-v2/runtimeLayer.ts+6-1
...@@ -51,6 +51,7 @@ import { layer as threadLifecycleServiceLayer } from "./ThreadLifecycleService.t...@@ -51,6 +51,7 @@ import { layer as threadLifecycleServiceLayer } from "./ThreadLifecycleService.t
51import { layer as threadForkServiceLayer } from "./ThreadForkService.ts";51import { layer as threadForkServiceLayer } from "./ThreadForkService.ts";
52import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts";52import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts";
53import { layer as scheduledTaskServiceLayer } from "../scheduledTasks/ScheduledTaskService.ts";53import { layer as scheduledTaskServiceLayer } from "../scheduledTasks/ScheduledTaskService.ts";
54import * as SecretRequests from "../secrets/SecretRequests.ts";
5455
55/** The shared application event log and its command receipts. */56/** The shared application event log and its command receipts. */
56export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(57export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
...@@ -256,8 +257,11 @@ const threadLaunchProvided = threadLaunchServiceLayer.pipe(...@@ -256,8 +257,11 @@ const threadLaunchProvided = threadLaunchServiceLayer.pipe(
256const threadLifecycleProvided = threadLifecycleServiceLayer.pipe(257const threadLifecycleProvided = threadLifecycleServiceLayer.pipe(
257 Layer.provide(threadManagementProvided),258 Layer.provide(threadManagementProvided),
258);259);
260const secretRequestsProvided = SecretRequests.layer.pipe(Layer.provide(threadManagementProvided));
259const scheduledTaskProvided = scheduledTaskServiceLayer.pipe(261const scheduledTaskProvided = scheduledTaskServiceLayer.pipe(
260 Layer.provide(Layer.mergeAll(threadLaunchProvided, threadManagementProvided)),262 Layer.provide(
263 Layer.mergeAll(threadLaunchProvided, threadManagementProvided, secretRequestsProvided),
264 ),
261);265);
262const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe(266const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe(
263 Layer.provide(267 Layer.provide(
...@@ -314,6 +318,7 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll(...@@ -314,6 +318,7 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll(
314 threadLaunchProvided,318 threadLaunchProvided,
315 threadLifecycleProvided,319 threadLifecycleProvided,
316 scheduledTaskProvided,320 scheduledTaskProvided,
321 secretRequestsProvided,
317 UsageLimitRecoveryWorker.workerLive.pipe(322 UsageLimitRecoveryWorker.workerLive.pipe(
318 Layer.provide(Layer.mergeAll(projectionStoreLayer, threadManagementProvided)),323 Layer.provide(Layer.mergeAll(projectionStoreLayer, threadManagementProvided)),
319 ),324 ),
apps/server/src/provider/T3OrchestrationInstructions.ts+2-1
...@@ -9,7 +9,8 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept...@@ -9,7 +9,8 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept
9- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round.9- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round.
10- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.10- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.
11- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.11- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.
12- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the prompt may use \`{{body.path}}\`-style placeholders). By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time.12- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing.
13- When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one.
1314
14### Choose the workspace before starting a new thread15### Choose the workspace before starting a new thread
1516
apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts+3
...@@ -10,6 +10,7 @@ import * as TestClock from "effect/testing/TestClock";...@@ -10,6 +10,7 @@ import * as TestClock from "effect/testing/TestClock";
1010
11import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";11import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
12import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";12import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
13import * as SecretRequests from "../secrets/SecretRequests.ts";
13import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";14import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
14import * as ScheduledTaskService from "./ScheduledTaskService.ts";15import * as ScheduledTaskService from "./ScheduledTaskService.ts";
1516
...@@ -22,6 +23,7 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id...@@ -22,6 +23,7 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id
22 Scheduler.layer,23 Scheduler.layer,
23 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),24 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
24 Layer.mock(ThreadManagementService.ThreadManagementService)({}),25 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
26 Layer.mock(SecretRequests.SecretRequests)({}),
25 );27 );
26 yield* Effect.gen(function* () {28 yield* Effect.gen(function* () {
27 const service = yield* ScheduledTaskService.ScheduledTaskService;29 const service = yield* ScheduledTaskService.ScheduledTaskService;
...@@ -64,6 +66,7 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () =>...@@ -64,6 +66,7 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () =>
64 Scheduler.layer,66 Scheduler.layer,
65 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),67 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
66 Layer.mock(ThreadManagementService.ThreadManagementService)({}),68 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
69 Layer.mock(SecretRequests.SecretRequests)({}),
67 );70 );
68 yield* Effect.gen(function* () {71 yield* Effect.gen(function* () {
69 const service = yield* ScheduledTaskService.ScheduledTaskService;72 const service = yield* ScheduledTaskService.ScheduledTaskService;
apps/server/src/scheduledTasks/ScheduledTaskService.test.ts+3
...@@ -17,6 +17,7 @@ import * as TestClock from "effect/testing/TestClock";...@@ -17,6 +17,7 @@ import * as TestClock from "effect/testing/TestClock";
1717
18import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";18import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
19import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";19import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
20import * as SecretRequests from "../secrets/SecretRequests.ts";
20import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";21import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
21import * as ScheduledTaskService from "./ScheduledTaskService.ts";22import * as ScheduledTaskService from "./ScheduledTaskService.ts";
2223
...@@ -211,6 +212,7 @@ it.effect(...@@ -211,6 +212,7 @@ it.effect(
211 ),212 ),
212 }),213 }),
213 Layer.mock(ThreadManagementService.ThreadManagementService)({}),214 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
215 Layer.mock(SecretRequests.SecretRequests)({}),
214 NodeCrypto.layer,216 NodeCrypto.layer,
215 Scheduler.layer,217 Scheduler.layer,
216 ),218 ),
...@@ -316,6 +318,7 @@ it.effect(...@@ -316,6 +318,7 @@ it.effect(
316 ),318 ),
317 }),319 }),
318 Layer.mock(ThreadManagementService.ThreadManagementService)({}),320 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
321 Layer.mock(SecretRequests.SecretRequests)({}),
319 NodeCrypto.layer,322 NodeCrypto.layer,
320 Scheduler.layer,323 Scheduler.layer,
321 ),324 ),
apps/server/src/scheduledTasks/ScheduledTaskService.ts+29-8
...@@ -43,6 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient";...@@ -43,6 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient";
43import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";43import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
44import * as Metrics from "../observability/Metrics.ts";44import * as Metrics from "../observability/Metrics.ts";
45import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";45import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
46import * as SecretRequests from "../secrets/SecretRequests.ts";
46import * as Scheduler from "../scheduling/Scheduler.ts";47import * as Scheduler from "../scheduling/Scheduler.ts";
47import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";48import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
48import {49import {
...@@ -393,6 +394,7 @@ export const layer = Layer.effect(...@@ -393,6 +394,7 @@ export const layer = Layer.effect(
393 const crypto = yield* Crypto.Crypto;394 const crypto = yield* Crypto.Crypto;
394 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;395 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
395 const threadManagement = yield* ThreadManagementService.ThreadManagementService;396 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
397 const secretRequests = yield* SecretRequests.SecretRequests;
396 const scheduler = yield* Scheduler.Scheduler;398 const scheduler = yield* Scheduler.Scheduler;
397 const readWebhookOrigin = yield* ScheduledTaskWebhookOrigin;399 const readWebhookOrigin = yield* ScheduledTaskWebhookOrigin;
398 // Webhook deliveries for one task dispatch in arrival order rather than400 // Webhook deliveries for one task dispatch in arrival order rather than
...@@ -1042,10 +1044,28 @@ export const layer = Layer.effect(...@@ -1042,10 +1044,28 @@ export const layer = Layer.effect(
1042 const token = existing?.token ?? (yield* newWebhookToken);1044 const token = existing?.token ?? (yield* newWebhookToken);
1043 const signature =1045 const signature =
1044 input.schedule.type === "webhook" ? input.schedule.signature : null;1046 input.schedule.type === "webhook" ? input.schedule.signature : null;
1045 const secret =1047 // A secretRef is a value the user entered for an agent; this
1046 signature == null ? null : (signature.secret ?? existing?.secret ?? null);1048 // save consumes it, so it cannot be used again. A replay of a
1049 // save that already used it keeps the secret that save stored.
1050 const replay = input.commandId !== undefined && existing?.secret != null;
1051 const fromRef =
1052 signature?.secretRef === undefined
1053 ? undefined
1054 : yield* secretRequests
1055 .consume({ ref: signature.secretRef, projectId: input.projectId })
1056 .pipe(
1057 Effect.catch((error) =>
1058 replay
1059 ? Effect.succeed(undefined)
1060 : Effect.fail(taskError(error.message, { taskId: id })),
1061 ),
1062 );
1063 // A ref, when given, is the only source: a plain secret sent
1064 // alongside it must not replace what a replayed save stored.
1065 const provided = signature?.secretRef === undefined ? signature?.secret : fromRef;
1066 const secret = signature == null ? null : (provided ?? existing?.secret ?? null);
1047 const secretChanged =1067 const secretChanged =
1048 signature == null || signature.secret !== undefined || existing === null;1068 signature == null || provided !== undefined || existing === null;
1049 if (signature != null && secret === null) {1069 if (signature != null && secret === null) {
1050 return yield* taskError("A webhook signature check needs a signing secret.", {1070 return yield* taskError("A webhook signature check needs a signing secret.", {
1051 taskId: id,1071 taskId: id,
...@@ -1608,11 +1628,12 @@ export const layer = Layer.effect(...@@ -1608,11 +1628,12 @@ export const layer = Layer.effect(
1608 )1628 )
1609 : runOutcome("started"),1629 : runOutcome("started"),
1610 ),1630 ),
1611 Effect.catchTag("WebhookDeliverySkipped", (skipped) =>1631 Effect.catchTags({
1612 runOutcome("skipped").pipe(1632 WebhookDeliverySkipped: (skipped) =>
1613 Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)),1633 runOutcome("skipped").pipe(
1614 ),1634 Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)),
1615 ),1635 ),
1636 }),
1616 // The log is readable over RPC, so it gets a fixed reason; the1637 // The log is readable over RPC, so it gets a fixed reason; the
1617 // cause, which can carry request data, stays in the server log.1638 // cause, which can carry request data, stays in the server log.
1618 Effect.catchCause((cause) =>1639 Effect.catchCause((cause) =>
apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts+126-2
...@@ -2,7 +2,7 @@ import * as NodeCrypto from "node:crypto";...@@ -2,7 +2,7 @@ import * as NodeCrypto from "node:crypto";
22
3import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";3import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";
4import { assert, it } from "@effect/vitest";4import { assert, it } from "@effect/vitest";
5import { ScheduledTaskUpsertInput } from "@t3tools/contracts";5import { ScheduledTaskUpsertInput, SecretRequestError } from "@t3tools/contracts";
6import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
7import * as Deferred from "effect/Deferred";7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -16,6 +16,7 @@ import * as TestClock from "effect/testing/TestClock";...@@ -16,6 +16,7 @@ import * as TestClock from "effect/testing/TestClock";
1616
17import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";17import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
18import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";18import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
19import * as SecretRequests from "../secrets/SecretRequests.ts";
19import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";20import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
20import * as Scheduler from "../scheduling/Scheduler.ts";21import * as Scheduler from "../scheduling/Scheduler.ts";
21import * as ScheduledTaskService from "./ScheduledTaskService.ts";22import * as ScheduledTaskService from "./ScheduledTaskService.ts";
...@@ -66,11 +67,14 @@ const withService = <A, E>(...@@ -66,11 +67,14 @@ const withService = <A, E>(
66 body: (input: {67 body: (input: {
67 readonly service: ScheduledTaskService.ScheduledTaskService["Service"];68 readonly service: ScheduledTaskService.ScheduledTaskService["Service"];
68 readonly launches: Queue.Queue<LaunchInput>;69 readonly launches: Queue.Queue<LaunchInput>;
70 /** Secrets the user entered for an agent, by ref; consuming one removes it. */
71 readonly secretsByRef: Map<string, string>;
69 }) => Effect.Effect<A, E, never>,72 }) => Effect.Effect<A, E, never>,
70 options: { readonly gate?: Deferred.Deferred<void>; readonly relayHookBaseUrl?: string } = {},73 options: { readonly gate?: Deferred.Deferred<void>; readonly relayHookBaseUrl?: string } = {},
71) =>74) =>
72 Effect.gen(function* () {75 Effect.gen(function* () {
73 const launches = yield* Queue.unbounded<LaunchInput>();76 const launches = yield* Queue.unbounded<LaunchInput>();
77 const secretsByRef = new Map<string, string>();
74 const dependencies = Layer.mergeAll(78 const dependencies = Layer.mergeAll(
75 NodePlatformCrypto.layer,79 NodePlatformCrypto.layer,
76 Scheduler.layer,80 Scheduler.layer,
...@@ -82,6 +86,15 @@ const withService = <A, E>(...@@ -82,6 +86,15 @@ const withService = <A, E>(
82 ),86 ),
83 }),87 }),
84 Layer.mock(ThreadManagementService.ThreadManagementService)({}),88 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
89 Layer.mock(SecretRequests.SecretRequests)({
90 consume: ({ ref }) => {
91 const value = secretsByRef.get(ref);
92 secretsByRef.delete(ref);
93 return value === undefined
94 ? Effect.fail(new SecretRequestError({ reason: "ref_unavailable" }))
95 : Effect.succeed(value);
96 },
97 }),
85 Layer.succeed(98 Layer.succeed(
86 ScheduledTaskService.ScheduledTaskWebhookOrigin,99 ScheduledTaskService.ScheduledTaskWebhookOrigin,
87 Effect.succeed({ relayHookBaseUrl: options.relayHookBaseUrl ?? null }),100 Effect.succeed({ relayHookBaseUrl: options.relayHookBaseUrl ?? null }),
...@@ -89,7 +102,7 @@ const withService = <A, E>(...@@ -89,7 +102,7 @@ const withService = <A, E>(
89 );102 );
90 return yield* Effect.gen(function* () {103 return yield* Effect.gen(function* () {
91 const service = yield* ScheduledTaskService.ScheduledTaskService;104 const service = yield* ScheduledTaskService.ScheduledTaskService;
92 return yield* body({ service, launches });105 return yield* body({ service, launches, secretsByRef });
93 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(dependencies))));106 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(dependencies))));
94 }).pipe(Effect.provide(SqlitePersistenceMemory));107 }).pipe(Effect.provide(SqlitePersistenceMemory));
95108
...@@ -708,6 +721,117 @@ it.effect("deleting a task removes its delivery log", () =>...@@ -708,6 +721,117 @@ it.effect("deleting a task removes its delivery log", () =>
708 ),721 ),
709);722);
710723
724const githubSignature = (secret: string) =>
725 `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`;
726
727it.effect("a signature can take the user's secret by ref, which works only once", () =>
728 withService(({ service, launches, secretsByRef }) =>
729 Effect.gen(function* () {
730 secretsByRef.set("secret-ref:00000000000000000000000000000001", "github-secret");
731 const githubSchedule = (secretRef: string) => ({
732 type: "webhook",
733 signature: { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=", secretRef },
734 });
735 const { task } = yield* service.upsert(
736 yield* webhookTaskInput({
737 schedule: githubSchedule("secret-ref:00000000000000000000000000000001"),
738 }),
739 );
740 assert.isTrue(task.webhook!.hasSecret);
741 const signed = yield* service.triggerWebhook(
742 requestFor(task, {
743 headers: {
744 "content-type": "application/json",
745 "x-hub-signature-256": githubSignature("github-secret"),
746 },
747 }),
748 );
749 assert.equal(signed._tag, "accepted");
750 yield* Queue.take(launches);
751
752 // The ref was consumed by that save.
753 const reused = yield* service
754 .upsert(
755 yield* webhookTaskInput({
756 id: "scheduled-task:other",
757 schedule: githubSchedule("secret-ref:00000000000000000000000000000001"),
758 }),
759 )
760 .pipe(Effect.flip);
761 assert.include(reused.message, "already used");
762 }),
763 ),
764);
765
766it.effect("a retried save with an already used secretRef keeps the stored secret", () =>
767 withService(({ service, launches, secretsByRef }) =>
768 Effect.gen(function* () {
769 secretsByRef.set("secret-ref:00000000000000000000000000000002", "github-secret");
770 const save = webhookTaskInput({
771 id: undefined,
772 commandId: "command:mcp:schedule-task:release-hook",
773 schedule: {
774 type: "webhook",
775 signature: {
776 header: "x-hub-signature-256",
777 encoding: "hex",
778 prefix: "sha256=",
779 secretRef: "secret-ref:00000000000000000000000000000002",
780 },
781 },
782 });
783 const first = yield* service.upsert(yield* save);
784 // The agent never saw the first result, so it sends the same call again,
785 // this time with a plain secret alongside the used ref.
786 const retried = yield* service.upsert(
787 yield* webhookTaskInput({
788 id: undefined,
789 commandId: "command:mcp:schedule-task:release-hook",
790 schedule: {
791 type: "webhook",
792 signature: {
793 header: "x-hub-signature-256",
794 encoding: "hex",
795 prefix: "sha256=",
796 secretRef: "secret-ref:00000000000000000000000000000002",
797 secret: "made-up-secret",
798 },
799 },
800 }),
801 );
802 assert.equal(retried.task.id, first.task.id);
803 const signed = yield* service.triggerWebhook(
804 requestFor(retried.task, {
805 headers: {
806 "content-type": "application/json",
807 "x-hub-signature-256": githubSignature("github-secret"),
808 },
809 }),
810 );
811 assert.equal(signed._tag, "accepted");
812 yield* Queue.take(launches);
813 }),
814 ),
815);
816
817it.effect("a signature without any secret is refused", () =>
818 withService(({ service }) =>
819 Effect.gen(function* () {
820 const failure = yield* service
821 .upsert(
822 yield* webhookTaskInput({
823 schedule: {
824 type: "webhook",
825 signature: { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=" },
826 },
827 }),
828 )
829 .pipe(Effect.flip);
830 assert.include(failure.message, "needs a signing secret");
831 }),
832 ),
833);
834
711const signatureFor = (secret: string) =>835const signatureFor = (secret: string) =>
712 `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`;836 `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`;
713837
apps/server/src/scheduling/Scheduler.integration.test.ts+2
...@@ -24,6 +24,7 @@ import * as ThreadManagementService from "../orchestration-v2/ThreadManagementSe...@@ -24,6 +24,7 @@ import * as ThreadManagementService from "../orchestration-v2/ThreadManagementSe
24import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts";24import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts";
25import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";25import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
26import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";26import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";
27import * as ServerSettings from "../serverSettings.ts";28import * as ServerSettings from "../serverSettings.ts";
28import * as Scheduler from "./Scheduler.ts";29import * as Scheduler from "./Scheduler.ts";
2930
...@@ -112,6 +113,7 @@ it.effect.each(["on time", "after restart"])(...@@ -112,6 +113,7 @@ it.effect.each(["on time", "after restart"])(
112 Layer.mock(ServerSettings.ServerSettingsService)({113 Layer.mock(ServerSettings.ServerSettingsService)({
113 getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),114 getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),
114 }),115 }),
116 Layer.mock(SecretRequests.SecretRequests)({}),
115 );117 );
116 const workers = Layer.mergeAll(118 const workers = Layer.mergeAll(
117 ScheduledTasks.layer,119 ScheduledTasks.layer,
apps/server/src/secrets/SecretRequests.test.ts created+413
...@@ -0,0 +1,413 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import {
5 ProjectId,
6 type OrchestrationV2ServerCommand,
7 ThreadId,
8 TurnItemId,
9} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Metric from "effect/Metric";
14import * as TestClock from "effect/testing/TestClock";
15import * as Tracer from "effect/Tracer";
16import * as Option from "effect/Option";
17import * as PlatformError from "effect/PlatformError";
18
19import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
20import * as ServerConfig from "../config.ts";
21import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
22import * as SecretRequests from "./SecretRequests.ts";
23
24const threadId = ThreadId.make("thread-orchestrator");
25const turnItemId = TurnItemId.make("turn-item:secret-request:1");
26const projectId = ProjectId.make("project-1");
27
28/** Runs `body` against the service with an in-memory store and a thread holding one request. */
29const withService = <A, E>(
30 body: (input: {
31 readonly service: SecretRequests.SecretRequests["Service"];
32 readonly stored: Map<string, Uint8Array>;
33 readonly dispatched: Array<OrchestrationV2ServerCommand>;
34 }) => Effect.Effect<A, E>,
35 options: {
36 readonly threadId?: ThreadId;
37 readonly runStatus?: string;
38 readonly removeFails?: boolean;
39 /** The agent's wait closes the card just before the answer's record lands. */
40 readonly closedFirst?: boolean;
41 /** How many record dispatches fail before one succeeds. */
42 readonly failedRecords?: number;
43 } = {},
44) =>
45 Effect.gen(function* () {
46 const stored = new Map<string, Uint8Array>();
47 const dispatched: Array<OrchestrationV2ServerCommand> = [];
48 let secretStatus = "pending";
49 let failedRecords = options.failedRecords ?? 0;
50 const requestThreadId = options.threadId ?? threadId;
51 const dependencies = Layer.mergeAll(
52 NodeCrypto.layer,
53 NodeServices.layer,
54 Layer.succeed(
55 ServerSecretStore.ServerSecretStore,
56 ServerSecretStore.ServerSecretStore.of({
57 // Yields like a real file read, so concurrent callers can interleave.
58 get: (name) => Effect.yieldNow.pipe(Effect.as(Option.fromNullishOr(stored.get(name)))),
59 set: (name, value) => Effect.sync(() => void stored.set(name, value)),
60 create: (name, value) =>
61 stored.has(name)
62 ? Effect.fail(
63 new ServerSecretStore.SecretStorePersistError({
64 resource: name,
65 cause: new PlatformError.PlatformError(
66 new PlatformError.SystemError({
67 _tag: "AlreadyExists",
68 module: "FileSystem",
69 method: "open",
70 }),
71 ),
72 } as never),
73 )
74 : Effect.sync(() => void stored.set(name, value)),
75 getOrCreateRandom: (name, bytes) =>
76 Effect.sync(() => {
77 const existing = stored.get(name);
78 if (existing) return existing;
79 const value = new Uint8Array(bytes).fill(7);
80 stored.set(name, value);
81 return value;
82 }),
83 remove: (name) =>
84 options.removeFails
85 ? Effect.fail(
86 new ServerSecretStore.SecretStorePersistError({
87 resource: name,
88 cause: new Error("read-only"),
89 }),
90 )
91 : Effect.sync(() => void stored.delete(name)),
92 }),
93 ),
94 Layer.mock(ThreadManagementService.ThreadManagementService)({
95 getThreadRecords: () =>
96 Effect.succeed({
97 thread: { projectId },
98 runs: [{ id: "run-1", status: options.runStatus ?? "running" }],
99 turnItems: [
100 {
101 id: turnItemId,
102 threadId: requestThreadId,
103 runId: "run-1",
104 nodeId: "node-root",
105 type: "secret_request",
106 label: "GitHub token",
107 reason: "Used as GH_TOKEN.",
108 secretStatus,
109 },
110 ],
111 } as never),
112 dispatch: (command) => {
113 if (command.type === "secret_request.record" && failedRecords > 0) {
114 failedRecords -= 1;
115 return Effect.fail(new Error("orchestrator unavailable") as never);
116 }
117 return Effect.sync(() => {
118 dispatched.push(command);
119 // Like the orchestrator, a card that is no longer pending keeps its answer.
120 if (options.closedFirst && command.type === "secret_request.record") {
121 secretStatus = "cancelled";
122 } else if (command.type === "secret_request.record" && secretStatus === "pending") {
123 secretStatus = command.secretStatus;
124 }
125 return {} as never;
126 });
127 },
128 }),
129 );
130 return yield* Effect.gen(function* () {
131 const service = yield* SecretRequests.SecretRequests;
132 return yield* body({ service, stored, dispatched });
133 }).pipe(Effect.provide(SecretRequests.layer.pipe(Layer.provide(dependencies))));
134 });
135
136/** The secret values in the store, leaving out the server's own salt. */
137const valuesOf = (stored: Map<string, Uint8Array>) =>
138 Array.from(stored.entries())
139 .filter(([name]) => name !== "secret-request-salt")
140 .map(([, bytes]) => new TextDecoder().decode(bytes));
141
142it.effect("a saved answer becomes a one-use ref, and the thread only learns it was saved", () =>
143 withService(({ service, stored, dispatched }) =>
144 Effect.gen(function* () {
145 yield* service.answer({
146 threadId,
147 turnItemId,
148 answer: { type: "save", secret: "ghp_secret" },
149 });
150 assert.equal(dispatched.length, 1);
151 assert.include(dispatched[0] as object, {
152 type: "secret_request.record",
153 secretStatus: "saved",
154 });
155 assert.notInclude(Object.values(dispatched[0] as object).map(String), "ghp_secret");
156
157 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
158 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
159 // Used once: the value is gone from the store and the ref fails.
160 assert.isFalse(valuesOf(stored).some((value) => value.includes("ghp_secret")));
161 const again = yield* service.consume({ ref, projectId }).pipe(Effect.flip);
162 assert.include(again.message, "already used");
163 }),
164 ),
165);
166
167it.effect("two concurrent uses of one ref hand the value out once", () =>
168 withService(({ service }) =>
169 Effect.gen(function* () {
170 yield* service.answer({
171 threadId,
172 turnItemId,
173 answer: { type: "save", secret: "ghp_secret" },
174 });
175 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
176 const results = yield* Effect.all(
177 [service.consume({ ref, projectId }), service.consume({ ref, projectId })].map(
178 Effect.result,
179 ),
180 { concurrency: "unbounded" },
181 );
182 assert.deepEqual(results.map((result) => result._tag).toSorted(), ["Failure", "Success"]);
183 }),
184 ),
185);
186
187it.effect("a save that loses to the card closing deletes the value and says so", () =>
188 withService(
189 ({ service, stored }) =>
190 Effect.gen(function* () {
191 const error = yield* service
192 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
193 .pipe(Effect.flip);
194 assert.equal(error.reason, "agent_stopped");
195 assert.isFalse(valuesOf(stored).some((value) => value.includes("ghp_secret")));
196 }),
197 { closedFirst: true },
198 ),
199);
200
201it.effect("a save whose record failed can be saved again", () =>
202 withService(
203 ({ service }) =>
204 Effect.gen(function* () {
205 const failed = yield* service
206 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
207 .pipe(Effect.flip);
208 assert.equal(failed.reason, "record_failed");
209 yield* service.answer({
210 threadId,
211 turnItemId,
212 answer: { type: "save", secret: "ghp_secret" },
213 });
214 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
215 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
216 }),
217 { failedRecords: 1 },
218 ),
219);
220
221it.effect("a save whose record and cleanup both failed is finished by saving again", () =>
222 withService(
223 ({ service }) =>
224 Effect.gen(function* () {
225 yield* service
226 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
227 .pipe(Effect.flip);
228 yield* service.answer({
229 threadId,
230 turnItemId,
231 answer: { type: "save", secret: "ghp_secret" },
232 });
233 assert.isTrue(Option.isSome(yield* service.savedRef({ threadId, turnItemId })));
234 }),
235 { failedRecords: 1, removeFails: true },
236 ),
237);
238
239it.effect("a ref only works in the project it was entered for", () =>
240 withService(({ service }) =>
241 Effect.gen(function* () {
242 yield* service.answer({
243 threadId,
244 turnItemId,
245 answer: { type: "save", secret: "ghp_secret" },
246 });
247 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
248 const elsewhere = yield* service
249 .consume({ ref, projectId: ProjectId.make("project-other") })
250 .pipe(Effect.flip);
251 assert.include(elsewhere.message, "does not exist");
252 // A failed attempt from another project does not burn the ref.
253 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
254 }),
255 ),
256);
257
258it.effect("declining stores nothing, and a request is answered once", () =>
259 withService(({ service, stored, dispatched }) =>
260 Effect.gen(function* () {
261 yield* service.answer({ threadId, turnItemId, answer: { type: "decline" } });
262 assert.deepEqual(valuesOf(stored), []);
263 assert.isTrue(Option.isNone(yield* service.savedRef({ threadId, turnItemId })));
264 const late = yield* service
265 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
266 .pipe(Effect.flip);
267 assert.include(late.message, "already answered");
268 assert.equal(dispatched.length, 1);
269 assert.deepEqual(valuesOf(stored), []);
270 }),
271 ),
272);
273
274it.effect("traces and counts a saved answer without ever recording the value", () =>
275 Effect.gen(function* () {
276 const spans: Array<Tracer.NativeSpan> = [];
277 const tracer = Tracer.make({
278 span: (options) => {
279 const span = new Tracer.NativeSpan(options);
280 spans.push(span);
281 return span;
282 },
283 });
284 const counted = Metric.snapshot.pipe(
285 Effect.map((snapshots) => {
286 const found = snapshots.find(
287 (snapshot) =>
288 snapshot.id === "t3_secret_refs_consumed_total" &&
289 snapshot.attributes?.result === "used",
290 );
291 return found?.type === "Counter" ? Number(found.state.count) : 0;
292 }),
293 );
294 const before = yield* counted;
295 yield* withService(({ service }) =>
296 Effect.gen(function* () {
297 yield* service.answer({
298 threadId,
299 turnItemId,
300 answer: { type: "save", secret: "ghp_secret" },
301 });
302 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
303 yield* service.consume({ ref, projectId });
304 }),
305 ).pipe(Effect.withTracer(tracer));
306 assert.equal((yield* counted) - before, 1);
307 const recorded = spans.flatMap((span) => [
308 span.name,
309 ...Array.from(span.attributes.values(), String),
310 ]);
311 assert.include(recorded, "SecretRequests.answer");
312 assert.include(recorded, "SecretRequests.consume");
313 assert.isFalse(recorded.some((value) => value.includes("ghp_secret")));
314 }),
315);
316
317it.effect("works in threads with long ids, such as a delegated subagent's", () => {
318 const delegatedThreadId = ThreadId.make(
319 `thread:delegated-task:command%3Amcp%3A${"a".repeat(36)}%3Adelegate-task%3Arelease-notes-v0.2.0-${"b".repeat(40)}`,
320 );
321 return withService(
322 ({ service, stored }) =>
323 Effect.gen(function* () {
324 yield* service.answer({
325 threadId: delegatedThreadId,
326 turnItemId,
327 answer: { type: "save", secret: "ghp_secret" },
328 });
329 // Store names never grow with the thread id, so they fit any filesystem.
330 assert.isTrue(Array.from(stored.keys()).every((name) => name.length < 100));
331 const ref = Option.getOrThrow(
332 yield* service.savedRef({ threadId: delegatedThreadId, turnItemId }),
333 );
334 assert.equal(yield* service.consume({ ref, projectId }), "ghp_secret");
335 }),
336 { threadId: delegatedThreadId },
337 );
338});
339
340it.effect("refuses an answer once the agent that asked has stopped", () =>
341 withService(
342 ({ service, stored, dispatched }) =>
343 Effect.gen(function* () {
344 const late = yield* service
345 .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } })
346 .pipe(Effect.flip);
347 assert.include(late.message, "has stopped");
348 assert.deepEqual(valuesOf(stored), []);
349 assert.equal(dispatched.length, 0);
350 }),
351 { runStatus: "completed" },
352 ),
353);
354
355it.effect("drops values nobody used once they expire, and keeps the rest", () =>
356 Effect.gen(function* () {
357 const store = yield* ServerSecretStore.ServerSecretStore;
358 const encode = (savedAt: number) =>
359 new TextEncoder().encode(
360 `{"projectId":"project-1","value":"ghp_secret","savedAt":${savedAt}}`,
361 );
362 yield* TestClock.adjust("2 days");
363 const now = yield* Clock.currentTimeMillis;
364 const stale = `secret-request-${"a".repeat(32)}`;
365 const fresh = `secret-request-${"b".repeat(32)}`;
366 yield* store.set(stale, encode(now - 25 * 60 * 60 * 1000));
367 yield* store.set(fresh, encode(now));
368 yield* store.set("unrelated", new Uint8Array([1]));
369
370 // Building the service runs the first sweep.
371 yield* Effect.gen(function* () {
372 yield* SecretRequests.SecretRequests;
373 }).pipe(
374 Effect.provide(
375 SecretRequests.layer.pipe(
376 Layer.provide(Layer.mock(ThreadManagementService.ThreadManagementService)({})),
377 ),
378 ),
379 Effect.scoped,
380 );
381
382 assert.isTrue(Option.isNone(yield* store.get(stale)));
383 assert.isTrue(Option.isSome(yield* store.get(fresh)));
384 assert.isTrue(Option.isSome(yield* store.get("unrelated")));
385 assert.isTrue(Option.isSome(yield* store.get("secret-request-salt")));
386 }).pipe(
387 Effect.provide(
388 ServerSecretStore.layer.pipe(
389 Layer.provideMerge(
390 ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-requests-" }),
391 ),
392 Layer.provideMerge(NodeServices.layer),
393 ),
394 ),
395 ),
396);
397
398it.effect("a used value that cannot be deleted is not handed out", () =>
399 withService(
400 ({ service }) =>
401 Effect.gen(function* () {
402 yield* service.answer({
403 threadId,
404 turnItemId,
405 answer: { type: "save", secret: "ghp_secret" },
406 });
407 const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId }));
408 const failed = yield* service.consume({ ref, projectId }).pipe(Effect.flip);
409 assert.include(failed.message, "Could not use");
410 }),
411 { removeFails: true },
412 ),
413);
apps/server/src/secrets/SecretRequests.ts created+279
...@@ -0,0 +1,279 @@
1/**
2 * SecretRequests - secrets an agent asks the user for.
3 *
4 * The user's answer goes straight to the server's secret store under a
5 * one-use SecretRef; orchestration only records the request and its status.
6 * A tool that needs the value takes the ref and consumes it, so the value
7 * never reaches the transcript, projections, clients, or model context.
8 *
9 * @module SecretRequests
10 */
11import {
12 CommandId,
13 SecretRef,
14 SecretRequestError,
15 type SecretRequestFailureReason,
16 type ProjectId,
17 type SecretRequestAnswerInput,
18 type ThreadId,
19} from "@t3tools/contracts";
20import * as NodeCrypto from "node:crypto";
21
22import * as Clock from "effect/Clock";
23import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as Schedule from "effect/Schedule";
29import * as Schema from "effect/Schema";
30import * as Semaphore from "effect/Semaphore";
31
32import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
33import * as Metrics from "../observability/Metrics.ts";
34import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
35
36const SECRET_REF_PREFIX = "secret-ref:";
37/** Store name for a ref's value; refs are fixed-length hex, so names stay short and safe. */
38const storeName = (ref: SecretRef) => `secret-request-${ref.slice(SECRET_REF_PREFIX.length)}`;
39const REF_PATTERN = /^secret-ref:[0-9a-f]{32}$/;
40/** A value nobody used within this long is dropped; the agent can ask again. */
41const SECRET_REF_TTL_MS = 24 * 60 * 60 * 1000;
42const STORE_NAME_PATTERN = /^(secret-request-[0-9a-f]{32})\.bin$/;
43
44/**
45 * Each request has exactly one ref, derived from where it was asked. Its
46 * length never depends on the thread id, so the store's file names stay
47 * within filesystem limits, and the requesting tool finds it without a
48 * second record. Unguessable without the server's own salt.
49 */
50const refFor = (salt: string, threadId: ThreadId, turnItemId: string) =>
51 SecretRef.make(
52 `${SECRET_REF_PREFIX}${NodeCrypto.createHmac("sha256", salt)
53 .update(`${threadId}\u0000${turnItemId}`)
54 .digest("hex")
55 .slice(0, 32)}`,
56 );
57
58/** A ref's value, the project it was entered for, and when it was saved. */
59const StoredSecret = Schema.fromJsonString(
60 Schema.Struct({ projectId: Schema.String, value: Schema.String, savedAt: Schema.Number }),
61);
62const encodeStored = Schema.encodeEffect(StoredSecret);
63const decodeStored = Schema.decodeUnknownOption(StoredSecret);
64
65const fail = (reason: SecretRequestFailureReason, cause?: unknown) =>
66 new SecretRequestError({ reason, ...(cause === undefined ? {} : { cause }) });
67
68export class SecretRequests extends Context.Service<
69 SecretRequests,
70 {
71 /**
72 * Answers a pending request in a thread. Saving stores the value under a
73 * new ref that the requesting tool reads from the request's status.
74 */
75 readonly answer: (input: SecretRequestAnswerInput) => Effect.Effect<void, SecretRequestError>;
76 /** The ref minted when this request was saved, for the tool that asked. */
77 readonly savedRef: (input: {
78 readonly threadId: ThreadId;
79 readonly turnItemId: string;
80 }) => Effect.Effect<Option.Option<SecretRef>>;
81 /**
82 * Reads and deletes a ref's value. Fails for unknown or used refs, and
83 * for refs entered in another project.
84 */
85 readonly consume: (input: {
86 readonly ref: SecretRef;
87 readonly projectId: ProjectId;
88 }) => Effect.Effect<string, SecretRequestError>;
89 }
90>()("t3/secrets/SecretRequests") {}
91
92const make = Effect.gen(function* () {
93 const store = yield* ServerSecretStore.ServerSecretStore;
94 const fileSystem = yield* FileSystem.FileSystem;
95 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
96
97 const salt = Buffer.from(
98 yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie),
99 ).toString("hex");
100
101 const answer: SecretRequests["Service"]["answer"] = (input) =>
102 Effect.gen(function* () {
103 yield* Effect.annotateCurrentSpan({
104 "orchestration_v2.thread_id": input.threadId,
105 "secret_request.answer": input.answer.type,
106 });
107 const records = yield* threadManagement
108 .getThreadRecords(input.threadId, ["runs", "turnItems"], {
109 turnItemTypes: ["secret_request"],
110 messageRoles: [],
111 })
112 .pipe(Effect.mapError((cause) => fail("load_failed", cause)));
113 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);
114 if (item?.type !== "secret_request" || item.runId === null || item.nodeId === null) {
115 return yield* fail("not_found");
116 }
117 if (item.secretStatus !== "pending") {
118 return yield* fail("already_answered");
119 }
120 // The agent is waiting inside the run that asked; once it has ended,
121 // nobody will ever receive the ref, so a value saved now would be lost.
122 const run = records.runs.find((candidate) => candidate.id === item.runId);
123 if (run === undefined || ThreadManagementService.isTerminalRunStatus(run.status)) {
124 return yield* fail("agent_stopped");
125 }
126 // Store first: the card only says saved once the value is kept. Create,
127 // not set: a second answer racing this one must not replace the value.
128 // A value already there on a card still pending is an earlier save whose
129 // record failed and could not be cleaned up; recording it finishes that save.
130 if (input.answer.type === "save") {
131 const encoded = yield* encodeStored({
132 projectId: records.thread.projectId,
133 value: input.answer.secret,
134 savedAt: yield* Clock.currentTimeMillis,
135 }).pipe(Effect.orDie);
136 yield* store
137 .create(
138 storeName(refFor(salt, input.threadId, item.id)),
139 new TextEncoder().encode(encoded),
140 )
141 .pipe(
142 Effect.catchIf(ServerSecretStore.isSecretAlreadyExistsError, () => Effect.void),
143 Effect.mapError((error) => fail("store_failed", error)),
144 );
145 }
146 const secretStatus = input.answer.type === "save" ? "saved" : "declined";
147 yield* threadManagement
148 .dispatch({
149 type: "secret_request.record",
150 commandId: CommandId.make(`secret-request:${item.id}:${secretStatus}`),
151 threadId: input.threadId,
152 runId: item.runId,
153 nodeId: item.nodeId,
154 turnItemId: item.id,
155 label: item.label,
156 reason: item.reason,
157 ...(item.placeholder === undefined ? {} : { placeholder: item.placeholder }),
158 secretStatus,
159 })
160 .pipe(
161 Effect.mapError((cause) => fail("record_failed", cause)),
162 // The card still says pending, so the user can save again; the value
163 // stored above would make that retry look already answered.
164 Effect.tapError(() =>
165 input.answer.type === "save"
166 ? removeLogged(storeName(refFor(salt, input.threadId, item.id)))
167 : Effect.void,
168 ),
169 );
170 if (input.answer.type !== "save") return;
171 // A request is answered once: if the agent's wait closed the card between
172 // the checks above and this record, the record changed nothing. Nobody
173 // will receive the ref, so the value is deleted rather than left to expire.
174 const recorded = yield* threadManagement
175 .getThreadRecords(input.threadId, ["turnItems"], {
176 turnItemTypes: ["secret_request"],
177 messageRoles: [],
178 })
179 .pipe(Effect.mapError((cause) => fail("record_failed", cause)));
180 const card = recorded.turnItems.find((candidate) => candidate.id === item.id);
181 if (card?.type === "secret_request" && card.secretStatus !== "saved") {
182 yield* removeLogged(storeName(refFor(salt, input.threadId, item.id)));
183 return yield* fail("agent_stopped");
184 }
185 }).pipe(Effect.withSpan("SecretRequests.answer"));
186
187 const savedRef: SecretRequests["Service"]["savedRef"] = (input) =>
188 Effect.gen(function* () {
189 const ref = refFor(salt, input.threadId, input.turnItemId);
190 const stored = yield* store.get(storeName(ref)).pipe(Effect.orElseSucceed(Option.none));
191 return Option.map(stored, () => ref);
192 });
193
194 /** Removes a stored value; a failure is logged, since the value is still on disk. */
195 const removeLogged = (name: string) =>
196 store.remove(name).pipe(
197 Effect.as(true),
198 Effect.catch((error) =>
199 Effect.logWarning("Could not delete a secret request value", {
200 errorTag: error._tag,
201 }).pipe(Effect.as(false)),
202 ),
203 );
204
205 // get and remove are separate store calls; one consumer at a time keeps two
206 // concurrent calls from both reading a ref before either deletes it.
207 const consumeLock = yield* Semaphore.make(1);
208 const consume: SecretRequests["Service"]["consume"] = (input) =>
209 consumeRef(input).pipe(
210 consumeLock.withPermits(1),
211 Effect.tap(() => Metrics.increment(Metrics.secretRefsConsumedTotal, { result: "used" })),
212 Effect.tapError(() =>
213 Metrics.increment(Metrics.secretRefsConsumedTotal, { result: "rejected" }),
214 ),
215 Effect.withSpan("SecretRequests.consume"),
216 );
217
218 const consumeRef = (input: { readonly ref: SecretRef; readonly projectId: ProjectId }) =>
219 Effect.gen(function* () {
220 if (!REF_PATTERN.test(input.ref)) return yield* fail("invalid_ref");
221 const stored = yield* store
222 .get(storeName(input.ref))
223 .pipe(Effect.mapError((cause) => fail("read_failed", cause)));
224 const decoded = Option.flatMap(stored, (bytes) =>
225 decodeStored(new TextDecoder().decode(bytes)),
226 );
227 if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) {
228 return yield* fail("ref_unavailable");
229 }
230 if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) {
231 // The hourly sweep retries a removal that fails here.
232 yield* removeLogged(storeName(input.ref));
233 return yield* fail("ref_expired");
234 }
235 // One use: the value moves into whatever consumed it. If it cannot be
236 // deleted, it is not handed out, so a ref is never used twice.
237 if (!(yield* removeLogged(storeName(input.ref)))) {
238 return yield* fail("consume_failed");
239 }
240 return decoded.value.value;
241 });
242
243 /**
244 * Drops values nobody used before they expired, so an agent that never
245 * consumed its ref does not leave the user's secret on disk.
246 */
247 const sweepExpired = Effect.gen(function* () {
248 if (store.directory === undefined) return;
249 const now = yield* Clock.currentTimeMillis;
250 const names = (yield* fileSystem.readDirectory(store.directory)).flatMap((file) => {
251 const match = STORE_NAME_PATTERN.exec(file);
252 return match?.[1] === undefined ? [] : [match[1]];
253 });
254 let removed = 0;
255 for (const name of names) {
256 const stored = yield* store.get(name).pipe(Effect.orElseSucceed(Option.none));
257 const decoded = Option.flatMap(stored, (bytes) =>
258 decodeStored(new TextDecoder().decode(bytes)),
259 );
260 if (Option.isSome(decoded) && now - decoded.value.savedAt <= SECRET_REF_TTL_MS) continue;
261 if (yield* removeLogged(name)) removed += 1;
262 }
263 yield* Effect.annotateCurrentSpan({ "secret_request.expired_removed": removed });
264 }).pipe(
265 Effect.catchCause((cause) => Effect.logWarning("Could not sweep expired secret refs", cause)),
266 Effect.withSpan("SecretRequests.sweepExpired"),
267 );
268 // Once at startup, then hourly; a value lingers at most an hour past expiry.
269 yield* sweepExpired;
270 yield* sweepExpired.pipe(
271 Effect.delay("1 hour"),
272 Effect.repeat(Schedule.spaced("1 hour")),
273 Effect.forkScoped,
274 );
275
276 return SecretRequests.of({ answer, savedRef, consume });
277});
278
279export const layer = Layer.effect(SecretRequests, make);
apps/server/src/ws.ts+7
...@@ -121,6 +121,7 @@ import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"...@@ -121,6 +121,7 @@ import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"
121import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts";121import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts";
122import * as IdAllocator from "./orchestration-v2/IdAllocator.ts";122import * as IdAllocator from "./orchestration-v2/IdAllocator.ts";
123import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts";123import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts";
124import * as SecretRequests from "./secrets/SecretRequests.ts";
124import {125import {
125 archivedShellStreamItemFromThreadShell,126 archivedShellStreamItemFromThreadShell,
126 buildActiveShellSnapshot,127 buildActiveShellSnapshot,
...@@ -1220,6 +1221,7 @@ const makeWsRpcLayer = (...@@ -1220,6 +1221,7 @@ const makeWsRpcLayer = (
1220 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;1221 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
1221 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;1222 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;
1222 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;1223 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;
1224 const secretRequests = yield* SecretRequests.SecretRequests;
1223 const pullRequests = yield* PullRequestService.PullRequestService;1225 const pullRequests = yield* PullRequestService.PullRequestService;
1224 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;1226 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
1225 const deviceService = yield* DeviceService.DeviceService;1227 const deviceService = yield* DeviceService.DeviceService;
...@@ -2102,6 +2104,11 @@ const makeWsRpcLayer = (...@@ -2102,6 +2104,11 @@ const makeWsRpcLayer = (
2102 scheduledTasks.rotateWebhookToken(input),2104 scheduledTasks.rotateWebhookToken(input),
2103 { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id },2105 { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id },
2104 ),2106 ),
2107 [WS_METHODS.secretsAnswerRequest]: (input) =>
2108 observeRpcEffect(WS_METHODS.secretsAnswerRequest, secretRequests.answer(input), {
2109 "rpc.aggregate": "secrets",
2110 "orchestration_v2.thread_id": input.threadId,
2111 }),
2105 [WS_METHODS.scheduledTasksListWebhookDeliveries]: (input) =>2112 [WS_METHODS.scheduledTasksListWebhookDeliveries]: (input) =>
2106 observeRpcEffect(2113 observeRpcEffect(
2107 WS_METHODS.scheduledTasksListWebhookDeliveries,2114 WS_METHODS.scheduledTasksListWebhookDeliveries,
apps/web/src/components/ChatView.tsx+5
...@@ -740,6 +740,8 @@ function eventPathContainsSelector(event: Event, selector: string): boolean {...@@ -740,6 +740,8 @@ function eventPathContainsSelector(event: Event, selector: string): boolean {
740 return path.some((target) => target instanceof Element && target.closest(selector));740 return path.some((target) => target instanceof Element && target.closest(selector));
741}741}
742742
743const SECRET_REQUEST_SELECTOR = '[data-v2-item-type="secret_request"]';
744
743/**745/**
744 * Whether input that landed outside any editable or interactive element746 * Whether input that landed outside any editable or interactive element
745 * should be redirected into the composer. Shared by type-to-focus and747 * should be redirected into the composer. Shared by type-to-focus and
...@@ -747,6 +749,9 @@ function eventPathContainsSelector(event: Event, selector: string): boolean {...@@ -747,6 +749,9 @@ function eventPathContainsSelector(event: Event, selector: string): boolean {
747 */749 */
748function shouldRedirectInputToComposer(event: Event): boolean {750function shouldRedirectInputToComposer(event: Event): boolean {
749 if (event.defaultPrevented) return false;751 if (event.defaultPrevented) return false;
752 // Near a pending secret request, input is meant for its private field: it
753 // must never land in the composer draft, which is persisted and sent.
754 if (eventPathContainsSelector(event, SECRET_REQUEST_SELECTOR)) return false;
750 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false;755 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false;
751 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;756 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
752 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;757 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
apps/web/src/components/chat/MessagesTimeline.tsx+10
...@@ -276,6 +276,7 @@ import {...@@ -276,6 +276,7 @@ import {
276 V2LifecycleRow,276 V2LifecycleRow,
277 type HandoffTimelineRun,277 type HandoffTimelineRun,
278} from "./V2LifecycleRow";278} from "./V2LifecycleRow";
279import { SecretRequestCard } from "./SecretRequestCard";
279import { TimelineSystemDivider } from "./TimelineSystemDivider";280import { TimelineSystemDivider } from "./TimelineSystemDivider";
280281
281import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";282import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";
...@@ -2808,6 +2809,15 @@ function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event"...@@ -2808,6 +2809,15 @@ function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event"
2808 if (item.type === "subagent" && (row.subagents?.length ?? 1) > 1) {2809 if (item.type === "subagent" && (row.subagents?.length ?? 1) > 1) {
2809 return <V2SubagentGroup key={row.id} row={row} />;2810 return <V2SubagentGroup key={row.id} row={row} />;
2810 }2811 }
2812 if (item.type === "secret_request") {
2813 return (
2814 <SecretRequestCard
2815 environmentId={ctx.activeThreadEnvironmentId}
2816 item={item}
2817 visibility={visibility}
2818 />
2819 );
2820 }
2811 if (isV2LifecycleItem(item)) {2821 if (isV2LifecycleItem(item)) {
2812 return (2822 return (
2813 <V2LifecycleRow2823 <V2LifecycleRow
apps/web/src/components/chat/SecretRequestCard.tsx created+179
...@@ -0,0 +1,179 @@
1import {
2 SECRET_REQUEST_DEFAULT_PLACEHOLDER,
3 SECRET_REQUEST_PRIVACY_NOTE,
4 secretRequestAnswerInput,
5 secretRequestDisplay,
6 secretRequestFailureMessage,
7 type SecretRequestItem,
8} from "@t3tools/client-runtime/secret-request";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
13import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
14import { CheckIcon, LockIcon, MinusIcon, ShieldCheckIcon } from "lucide-react";
15import { useId, useRef, useState, type FormEvent } from "react";
16
17import { serverEnvironment } from "../../state/server";
18import { useAtomCommand } from "../../state/use-atom-command";
19import { Button } from "../ui/button";
20import { Input } from "../ui/input";
21import { WorkLogRow } from "./WorkLog";
22
23/**
24 * Inline card for a secret an agent asked the user for. The typed value lives
25 * only in this component's state and the RPC payload: it is never logged,
26 * toasted, or persisted, and the field clears once the answer is sent.
27 */
28export function SecretRequestCard(props: {
29 readonly environmentId: EnvironmentId;
30 readonly item: SecretRequestItem;
31 readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
32}) {
33 const { item } = props;
34 const display = secretRequestDisplay(item, props.visibility);
35 if (display.kind === "answered" || display.kind === "pending-elsewhere") {
36 const Icon =
37 display.kind === "pending-elsewhere"
38 ? LockIcon
39 : display.outcome === "saved"
40 ? CheckIcon
41 : MinusIcon;
42 return (
43 <WorkLogRow
44 data-v2-item-type={item.type}
45 icon={<Icon className="size-3.5 text-icon-muted" aria-hidden />}
46 label={
47 <>
48 {item.label} · {display.label}
49 </>
50 }
51 />
52 );
53 }
54 return <PendingSecretRequestForm environmentId={props.environmentId} item={item} />;
55}
56
57function PendingSecretRequestForm(props: {
58 readonly environmentId: EnvironmentId;
59 readonly item: SecretRequestItem;
60}) {
61 const { item } = props;
62 const inputId = useId();
63 const errorId = useId();
64 const privacyId = useId();
65 const answer = useAtomCommand(serverEnvironment.answerSecretRequest, {
66 label: "answer secret request",
67 // The failure cause holds the request; keep it out of the console.
68 reportFailure: false,
69 reportDefect: false,
70 });
71 const [secret, setSecret] = useState("");
72 const [submitting, setSubmitting] = useState(false);
73 const [error, setError] = useState<string | null>(null);
74 // Enter then a click can both run before a re-render; this guard is synchronous.
75 const inFlight = useRef(false);
76
77 const send = async (
78 reply: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
79 ) => {
80 const input = secretRequestAnswerInput(item, reply);
81 if (input === null || inFlight.current) return;
82 inFlight.current = true;
83 setSubmitting(true);
84 setError(null);
85 const result = await answer({ environmentId: props.environmentId, input }).finally(() => {
86 inFlight.current = false;
87 setSubmitting(false);
88 });
89 if (result._tag === "Success") {
90 // The card switches to its answered row once the item updates.
91 setSecret("");
92 return;
93 }
94 if (!isAtomCommandInterrupted(result)) {
95 setError(secretRequestFailureMessage(squashAtomCommandFailure(result)));
96 }
97 };
98
99 const onSubmit = (event: FormEvent<HTMLFormElement>) => {
100 event.preventDefault();
101 void send({ type: "save", secret });
102 };
103
104 // Same hierarchy as a chat card: what is asked, why, the field, then the
105 // promise about where the value goes.
106 return (
107 <form
108 data-v2-item-type={item.type}
109 className="flex min-w-0 flex-col gap-3 rounded-xl border border-border/60 bg-card p-4"
110 onSubmit={onSubmit}
111 autoComplete="off"
112 // A click anywhere on the card targets its field, so a paste that
113 // follows lands here rather than with the composer.
114 onPointerDown={(event) => {
115 if (!(event.target instanceof HTMLElement)) return;
116 if (event.target.closest("input, button, a")) return;
117 document.getElementById(inputId)?.focus();
118 }}
119 >
120 <div className="flex min-w-0 flex-col gap-1">
121 <label htmlFor={inputId} className="text-sm font-medium text-foreground">
122 {item.label}
123 </label>
124 {item.reason.trim() ? <p className="text-sm text-muted-foreground">{item.reason}</p> : null}
125 </div>
126 <div className="flex min-w-0 items-center gap-2">
127 <div className="min-w-0 flex-1">
128 <Input
129 id={inputId}
130 // Masked text rather than a password field: browsers offer to
131 // save any submitted password, and this is not a login.
132 type="text"
133 className="[-webkit-text-security:disc]"
134 autoComplete="off"
135 autoCorrect="off"
136 autoCapitalize="off"
137 spellCheck={false}
138 // Password managers otherwise offer to save or fill this field.
139 data-1p-ignore
140 data-lpignore="true"
141 data-bwignore
142 placeholder={item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
143 value={secret}
144 disabled={submitting}
145 aria-invalid={error !== null || undefined}
146 aria-describedby={error !== null ? `${privacyId} ${errorId}` : privacyId}
147 onChange={(event) => setSecret(event.currentTarget.value)}
148 />
149 </div>
150 <Button type="submit" disabled={submitting || secret.trim().length === 0}>
151 Save securely
152 </Button>
153 </div>
154 {error !== null ? (
155 <p id={errorId} role="alert" className="text-xs text-destructive">
156 {error}
157 </p>
158 ) : null}
159 <div className="flex min-w-0 items-center justify-between gap-2">
160 <p
161 id={privacyId}
162 className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
163 >
164 <ShieldCheckIcon className="size-3.5 shrink-0" aria-hidden />
165 {SECRET_REQUEST_PRIVACY_NOTE}
166 </p>
167 <Button
168 type="button"
169 size="xs"
170 variant="ghost-muted"
171 disabled={submitting}
172 onClick={() => void send({ type: "decline" })}
173 >
174 Decline
175 </Button>
176 </div>
177 </form>
178 );
179}
apps/web/src/session-logic.ts+3
...@@ -315,12 +315,15 @@ const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"...@@ -315,12 +315,15 @@ const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"
315 "handoff",315 "handoff",
316 "run_interrupt_request",316 "run_interrupt_request",
317 "run_interrupt_result",317 "run_interrupt_result",
318 "secret_request",
318 "subagent",319 "subagent",
319]);320]);
320321
321const PERSISTENT_RESOURCE_V2_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([322const PERSISTENT_RESOURCE_V2_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
322 "fork",323 "fork",
323 "thread_created",324 "thread_created",
325 // Still answerable after a steer supersedes the attempt that asked.
326 "secret_request",
324]);327]);
325328
326export function timelineEntryIsPersistentResourceCard(entry: TimelineEntry): boolean {329export function timelineEntryIsPersistentResourceCard(entry: TimelineEntry): boolean {
docs/operations/observability.md+4
...@@ -396,6 +396,10 @@ Webhooks have their own families:...@@ -396,6 +396,10 @@ Webhooks have their own families:
396 deliveries start, which happen after the sender has its answer.396 deliveries start, which happen after the sender has its answer.
397- `t3_webhook_held_delay` for how long requests the relay held waited before arriving.397- `t3_webhook_held_delay` for how long requests the relay held waited before arriving.
398398
399- `t3_secret_requests_total` by `status` (`saved`, `declined`, `cancelled`, `timed_out`) for secrets
400 agents asked users for, and `t3_secret_refs_consumed_total` by `result` (`used`, `rejected`) for
401 tools redeeming them. Neither ever carries a value.
402
399`ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run403`ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run
400started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request404started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request
401the relay forwarded, the span also goes to the T3 Connect trace export as a child of the relay's405the relay forwarded, the span also goes to the T3 Connect trace export as a child of the relay's
packages/client-runtime/package.json+4
...@@ -35,6 +35,10 @@...@@ -35,6 +35,10 @@
35 "types": "./src/userMessage.ts",35 "types": "./src/userMessage.ts",
36 "default": "./src/userMessage.ts"36 "default": "./src/userMessage.ts"
37 },37 },
38 "./secret-request": {
39 "types": "./src/secretRequest.ts",
40 "default": "./src/secretRequest.ts"
41 },
38 "./scheduled-task-webhook": {42 "./scheduled-task-webhook": {
39 "types": "./src/scheduledTaskWebhook.ts",43 "types": "./src/scheduledTaskWebhook.ts",
40 "default": "./src/scheduledTaskWebhook.ts"44 "default": "./src/scheduledTaskWebhook.ts"
packages/client-runtime/src/secretRequest.test.ts created+75
...@@ -0,0 +1,75 @@
1import { SecretRequestError, ThreadId, TurnItemId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 secretRequestAnswerInput,
6 secretRequestDisplay,
7 secretRequestFailureMessage,
8 type SecretRequestItem,
9} from "./secretRequest.ts";
10
11const item = {
12 id: TurnItemId.make("turn-item:secret-request:1"),
13 threadId: ThreadId.make("thread-1"),
14};
15
16describe("secretRequestDisplay", () => {
17 it("shows the form only while pending and maps every answer to its outcome copy", () => {
18 const display = (secretStatus: SecretRequestItem["secretStatus"]) =>
19 secretRequestDisplay({ secretStatus }, "local");
20 expect(display("pending")).toEqual({ kind: "pending" });
21 expect(display("saved")).toEqual({
22 kind: "answered",
23 outcome: "saved",
24 label: "Saved securely and kept private",
25 });
26 expect(display("declined")).toEqual({
27 kind: "answered",
28 outcome: "declined",
29 label: "Declined",
30 });
31 expect(display("cancelled")).toEqual({
32 kind: "answered",
33 outcome: "ended",
34 label: "Request ended",
35 });
36 });
37
38 it("never offers the form for a request inherited from another thread", () => {
39 expect(secretRequestDisplay({ secretStatus: "pending" }, "inherited")).toEqual({
40 kind: "pending-elsewhere",
41 label: "Waiting for an answer in the original thread",
42 });
43 expect(secretRequestDisplay({ secretStatus: "saved" }, "inherited")).toMatchObject({
44 outcome: "saved",
45 });
46 });
47});
48
49describe("secretRequestAnswerInput", () => {
50 it("refuses a blank save and trims the value the server will store", () => {
51 expect(secretRequestAnswerInput(item, { type: "save", secret: " " })).toBeNull();
52 expect(secretRequestAnswerInput(item, { type: "save", secret: " whsec_1 " })).toEqual({
53 threadId: item.threadId,
54 turnItemId: item.id,
55 answer: { type: "save", secret: "whsec_1" },
56 });
57 expect(secretRequestAnswerInput(item, { type: "decline" })).toEqual({
58 threadId: item.threadId,
59 turnItemId: item.id,
60 answer: { type: "decline" },
61 });
62 });
63});
64
65describe("secretRequestFailureMessage", () => {
66 it("passes through server errors but hides anything that could echo the payload", () => {
67 expect(
68 secretRequestFailureMessage(new SecretRequestError({ reason: "already_answered" })),
69 ).toBe("This secret request was already answered.");
70 expect(secretRequestFailureMessage(new Error('Expected string, got "whsec_1"'))).toBe(
71 "Could not answer the request. Try again.",
72 );
73 expect(secretRequestFailureMessage(undefined)).toBe("Could not answer the request. Try again.");
74 });
75});
packages/client-runtime/src/secretRequest.ts created+101
...@@ -0,0 +1,101 @@
1import type { OrchestrationV2TurnItem, SecretRequestAnswerInput } from "@t3tools/contracts";
2
3export type SecretRequestItem = Extract<
4 OrchestrationV2TurnItem,
5 { readonly type: "secret_request" }
6>;
7
8/** Shown under the field: the one promise the card makes about the value. */
9export const SECRET_REQUEST_PRIVACY_NOTE = "Stored securely, never shown to the agent";
10export const SECRET_REQUEST_DEFAULT_PLACEHOLDER = "Paste the secret";
11
12/** What a secret request card shows: the form while pending, otherwise a one-line outcome. */
13export type SecretRequestDisplay =
14 | { readonly kind: "pending" }
15 | { readonly kind: "pending-elsewhere"; readonly label: string }
16 | {
17 readonly kind: "answered";
18 readonly outcome: "saved" | "declined" | "ended";
19 readonly label: string;
20 };
21
22const SAVED_DISPLAY: SecretRequestDisplay = {
23 kind: "answered",
24 outcome: "saved",
25 label: "Saved securely and kept private",
26};
27const DECLINED_DISPLAY: SecretRequestDisplay = {
28 kind: "answered",
29 outcome: "declined",
30 label: "Declined",
31};
32const ENDED_DISPLAY: SecretRequestDisplay = {
33 kind: "answered",
34 outcome: "ended",
35 label: "Request ended",
36};
37const PENDING_DISPLAY: SecretRequestDisplay = { kind: "pending" };
38const PENDING_ELSEWHERE_DISPLAY: SecretRequestDisplay = {
39 kind: "pending-elsewhere",
40 label: "Waiting for an answer in the original thread",
41};
42
43/**
44 * `visibility` is the projected row's: a request inherited from another
45 * thread (a fork) can only be answered where it was asked.
46 */
47export function secretRequestDisplay(
48 item: Pick<SecretRequestItem, "secretStatus">,
49 visibility: "local" | "inherited" | "synthetic",
50): SecretRequestDisplay {
51 switch (item.secretStatus) {
52 case "pending":
53 return visibility === "local" ? PENDING_DISPLAY : PENDING_ELSEWHERE_DISPLAY;
54 case "saved":
55 return SAVED_DISPLAY;
56 case "declined":
57 return DECLINED_DISPLAY;
58 case "cancelled":
59 return ENDED_DISPLAY;
60 }
61}
62
63/**
64 * Builds the RPC payload for an answer. A save with a blank value returns null,
65 * since the server rejects it; callers keep Save disabled instead.
66 */
67export function secretRequestAnswerInput(
68 item: Pick<SecretRequestItem, "id" | "threadId">,
69 answer: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
70): SecretRequestAnswerInput | null {
71 if (answer.type === "decline") {
72 return { threadId: item.threadId, turnItemId: item.id, answer: { type: "decline" } };
73 }
74 const secret = answer.secret.trim();
75 if (secret.length === 0) return null;
76 return { threadId: item.threadId, turnItemId: item.id, answer: { type: "save", secret } };
77}
78
79/** Failures whose message is written for the user and never echoes the request payload. */
80const USER_FACING_FAILURE_TAGS = new Set(["SecretRequestError", "EnvironmentAuthorizationError"]);
81
82/**
83 * Inline error copy for a failed answer. Only known server errors pass their
84 * message through: anything else (transport or encoding failures) gets the
85 * generic copy, so the typed value can never surface in the UI.
86 */
87export function secretRequestFailureMessage(failure: unknown): string {
88 if (
89 typeof failure === "object" &&
90 failure !== null &&
91 "_tag" in failure &&
92 typeof failure._tag === "string" &&
93 USER_FACING_FAILURE_TAGS.has(failure._tag) &&
94 "message" in failure &&
95 typeof failure.message === "string" &&
96 failure.message.trim().length > 0
97 ) {
98 return failure.message;
99 }
100 return "Could not answer the request. Try again.";
101}
packages/client-runtime/src/state/server.ts+11
...@@ -1299,6 +1299,17 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -1299,6 +1299,17 @@ export function createServerEnvironmentAtoms<R, E>(
1299 scheduler: configScheduler,1299 scheduler: configScheduler,
1300 concurrency: configConcurrency,1300 concurrency: configConcurrency,
1301 }),1301 }),
1302 // Off the config lane: answering a card must not queue behind settings
1303 // edits. One answer per card at a time.
1304 answerSecretRequest: createEnvironmentRpcCommand(runtime, {
1305 label: "environment-data:server:secrets:answer-request",
1306 tag: WS_METHODS.secretsAnswerRequest,
1307 concurrency: {
1308 mode: "singleFlight",
1309 key: ({ environmentId, input }) =>
1310 JSON.stringify([environmentId, input.threadId, input.turnItemId]),
1311 },
1312 }),
1302 refreshUsageRates: createEnvironmentRpcCommand(runtime, {1313 refreshUsageRates: createEnvironmentRpcCommand(runtime, {
1303 label: "environment-data:server:refresh-usage-rates",1314 label: "environment-data:server:refresh-usage-rates",
1304 tag: WS_METHODS.serverRefreshUsageRates,1315 tag: WS_METHODS.serverRefreshUsageRates,
packages/client-runtime/src/t3ToolSummary.ts+3
...@@ -285,6 +285,9 @@ export function summarizeT3ToolCalls(...@@ -285,6 +285,9 @@ export function summarizeT3ToolCalls(
285 quantity(countEntities(entityIds("requestId")), "pending question request"),285 quantity(countEntities(entityIds("requestId")), "pending question request"),
286 );286 );
287 break;287 break;
288 case "secret-request":
289 label = phrase("Asked for", "ask for", quantity(selected.length, "secret"));
290 break;
288 case "worktree-handoff":291 case "worktree-handoff":
289 label = phrase(292 label = phrase(
290 "Handed off to",293 "Handed off to",
packages/contracts/src/baseSchemas.ts+3
...@@ -349,6 +349,9 @@ export type RuntimeRequestId = typeof RuntimeRequestId.Type;...@@ -349,6 +349,9 @@ export type RuntimeRequestId = typeof RuntimeRequestId.Type;
349export const RuntimeTaskId = makeEntityId("RuntimeTaskId");349export const RuntimeTaskId = makeEntityId("RuntimeTaskId");
350export type RuntimeTaskId = typeof RuntimeTaskId.Type;350export type RuntimeTaskId = typeof RuntimeTaskId.Type;
351export const ScheduledTaskId = makeEntityId("ScheduledTaskId");351export const ScheduledTaskId = makeEntityId("ScheduledTaskId");
352/** A one-use handle to a secret the user entered for an agent; the agent never sees the value. */
353export const SecretRef = makeEntityId("SecretRef");
354export type SecretRef = typeof SecretRef.Type;
352export type ScheduledTaskId = typeof ScheduledTaskId.Type;355export type ScheduledTaskId = typeof ScheduledTaskId.Type;
353export const ApprovalRequestId = makeEntityId("ApprovalRequestId");356export const ApprovalRequestId = makeEntityId("ApprovalRequestId");
354export type ApprovalRequestId = typeof ApprovalRequestId.Type;357export type ApprovalRequestId = typeof ApprovalRequestId.Type;
packages/contracts/src/index.ts+1
...@@ -60,3 +60,4 @@ export * from "./worktreeMcp.ts";...@@ -60,3 +60,4 @@ export * from "./worktreeMcp.ts";
60export * from "./resourceTelemetry.ts";60export * from "./resourceTelemetry.ts";
61export * from "./rpc.ts";61export * from "./rpc.ts";
62export * from "./worktreeSetup.ts";62export * from "./worktreeSetup.ts";
63export * from "./secretRequest.ts";
packages/contracts/src/orchestrationV2.test.ts+1-1
...@@ -205,7 +205,7 @@ describe("orchestration V2 contracts", () => {...@@ -205,7 +205,7 @@ describe("orchestration V2 contracts", () => {
205 });205 });
206 const known = item("item-known", "system_notice", { message: "Hello" });206 const known = item("item-known", "system_notice", { message: "Hello" });
207 // A type no build of this client knows, standing in for a newer server's item.207 // A type no build of this client knows, standing in for a newer server's item.
208 const future = item("item-future", "secret_request", { secretRef: "ref-1" });208 const future = item("item-future", "hologram", { beam: "ref-1" });
209 const projected = (position: number, turnItem: { readonly id: string }) => ({209 const projected = (position: number, turnItem: { readonly id: string }) => ({
210 position,210 position,
211 visibility: "local",211 visibility: "local",
packages/contracts/src/orchestrationV2.ts+45
...@@ -1346,6 +1346,26 @@ export const OrchestrationV2WebSearchResult = Schema.Struct({...@@ -1346,6 +1346,26 @@ export const OrchestrationV2WebSearchResult = Schema.Struct({
1346});1346});
1347export type OrchestrationV2WebSearchResult = typeof OrchestrationV2WebSearchResult.Type;1347export type OrchestrationV2WebSearchResult = typeof OrchestrationV2WebSearchResult.Type;
13481348
1349export const OrchestrationV2SecretRequestStatus = Schema.Literals([
1350 "pending",
1351 "saved",
1352 "declined",
1353 "cancelled",
1354]);
1355export type OrchestrationV2SecretRequestStatus = typeof OrchestrationV2SecretRequestStatus.Type;
1356
1357/**
1358 * A secret an agent asked the user for. The value never passes through
1359 * orchestration: the item carries only what was asked and how it was answered.
1360 */
1361const OrchestrationV2SecretRequestFields = {
1362 type: Schema.Literal("secret_request"),
1363 label: TrimmedNonEmptyString,
1364 reason: Schema.String,
1365 placeholder: Schema.optional(Schema.String),
1366 secretStatus: OrchestrationV2SecretRequestStatus,
1367} as const;
1368
1349export const OrchestrationV2TurnItem = Schema.Union([1369export const OrchestrationV2TurnItem = Schema.Union([
1350 Schema.Struct({1370 Schema.Struct({
1351 ...OrchestrationV2TurnItemBaseFields,1371 ...OrchestrationV2TurnItemBaseFields,
...@@ -1525,6 +1545,10 @@ export const OrchestrationV2TurnItem = Schema.Union([...@@ -1525,6 +1545,10 @@ export const OrchestrationV2TurnItem = Schema.Union([
1525 targetProviderInstanceId: ProviderInstanceId,1545 targetProviderInstanceId: ProviderInstanceId,
1526 targetModel: TrimmedNonEmptyString,1546 targetModel: TrimmedNonEmptyString,
1527 }),1547 }),
1548 Schema.Struct({
1549 ...OrchestrationV2TurnItemBaseFields,
1550 ...OrchestrationV2SecretRequestFields,
1551 }),
1528 Schema.Struct({1552 Schema.Struct({
1529 ...OrchestrationV2TurnItemBaseFields,1553 ...OrchestrationV2TurnItemBaseFields,
1530 type: Schema.Literal("subagent"),1554 type: Schema.Literal("subagent"),
...@@ -2296,6 +2320,10 @@ export const OrchestrationV2TurnItemJson = Schema.Union([...@@ -2296,6 +2320,10 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
2296 targetProviderInstanceId: ProviderInstanceId,2320 targetProviderInstanceId: ProviderInstanceId,
2297 targetModel: TrimmedNonEmptyString,2321 targetModel: TrimmedNonEmptyString,
2298 }),2322 }),
2323 Schema.Struct({
2324 ...OrchestrationV2TurnItemJsonBaseFields,
2325 ...OrchestrationV2SecretRequestFields,
2326 }),
2299 Schema.Struct({2327 Schema.Struct({
2300 ...OrchestrationV2TurnItemJsonBaseFields,2328 ...OrchestrationV2TurnItemJsonBaseFields,
2301 type: Schema.Literal("subagent"),2329 type: Schema.Literal("subagent"),
...@@ -3013,6 +3041,7 @@ export const OrchestrationV2Command = Schema.Union([...@@ -3013,6 +3041,7 @@ export const OrchestrationV2Command = Schema.Union([
3013 targetThreadId: ThreadId,3041 targetThreadId: ThreadId,
3014 targetRunId: Schema.NullOr(RunId),3042 targetRunId: Schema.NullOr(RunId),
3015 }),3043 }),
3044
3016 Schema.Struct({3045 Schema.Struct({
3017 type: Schema.Literal("provider.switch"),3046 type: Schema.Literal("provider.switch"),
3018 commandId: CommandId,3047 commandId: CommandId,
...@@ -3081,6 +3110,22 @@ const OrchestrationV2InternalCommand = Schema.Union([...@@ -3081,6 +3110,22 @@ const OrchestrationV2InternalCommand = Schema.Union([
3081 threadId: ThreadId,3110 threadId: ThreadId,
3082 reason: Schema.optional(Schema.String),3111 reason: Schema.optional(Schema.String),
3083 }),3112 }),
3113 /**
3114 * Records or updates a secret an agent asked the user for. Internal so no
3115 * client can mark a request saved without the value being stored.
3116 */
3117 Schema.Struct({
3118 type: Schema.Literal("secret_request.record"),
3119 commandId: CommandId,
3120 threadId: ThreadId,
3121 runId: RunId,
3122 nodeId: NodeId,
3123 turnItemId: TurnItemId,
3124 label: TrimmedNonEmptyString,
3125 reason: Schema.String,
3126 placeholder: Schema.optional(Schema.String),
3127 secretStatus: OrchestrationV2SecretRequestStatus,
3128 }),
3084]);3129]);
3085export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalCommand.Type;3130export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalCommand.Type;
30863131
packages/contracts/src/orchestratorMcp.ts+47-2
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 ProjectId,12 ProjectId,
13 RunId,13 RunId,
14 ScheduledTaskId,14 ScheduledTaskId,
15 SecretRef,
15 ThreadId,16 ThreadId,
16 TrimmedNonEmptyString,17 TrimmedNonEmptyString,
17 TurnItemId,18 TurnItemId,
...@@ -541,8 +542,14 @@ export const OrchestratorMcpScheduledTask = Schema.Struct({...@@ -541,8 +542,14 @@ export const OrchestratorMcpScheduledTask = Schema.Struct({
541 schedule: ScheduledTaskSchedule,542 schedule: ScheduledTaskSchedule,
542 nextRunAt: Schema.NullOr(IsoDateTime),543 nextRunAt: Schema.NullOr(IsoDateTime),
543 lastRunStatus: ScheduledTaskRunStatus,544 lastRunStatus: ScheduledTaskRunStatus,
544 /** For webhook tasks: the public T3 Connect URL, or the environment-relative path when the environment is not linked. */545 /** For webhook tasks: the public T3 Connect URL. Absent when this environment has no managed tunnel. */
545 webhookUrl: Schema.optional(Schema.String),546 webhookUrl: Schema.optional(Schema.String).annotate({
547 description:
548 "Public URL to give the sender. Absent when this environment has no T3 Connect managed tunnel; the user must enable T3 Connect remote access first.",
549 }),
550 webhookSignature: Schema.optional(Schema.Literals(["none", "set"])).annotate({
551 description: "Whether requests must carry a valid signature.",
552 }),
546});553});
547export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.Type;554export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.Type;
548555
...@@ -577,6 +584,44 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({...@@ -577,6 +584,44 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({
577export type OrchestratorMcpUpdateScheduledTaskInput =584export type OrchestratorMcpUpdateScheduledTaskInput =
578 typeof OrchestratorMcpUpdateScheduledTaskInput.Type;585 typeof OrchestratorMcpUpdateScheduledTaskInput.Type;
579586
587export const OrchestratorMcpRequestSecretInput = Schema.Struct({
588 label: TrimmedNonEmptyString.annotate({
589 description: "What you need, shown as the card's title, e.g. 'GitHub webhook secret'.",
590 }),
591 reason: TrimmedNonEmptyString.annotate({
592 description:
593 "One or two sentences on what it is for and where the user gets or also enters it.",
594 }),
595 placeholder: Schema.optional(TrimmedNonEmptyString).annotate({
596 description: "Hint inside the input, e.g. 'Paste your GitHub token'.",
597 }),
598 timeoutMs: Schema.optional(
599 Schema.Int.check(Schema.isBetween({ minimum: 1_000, maximum: 60 * 60 * 1_000 })),
600 ).annotate({ description: "How long to wait for the user. Default 10 minutes." }),
601 clientRequestId: Schema.optional(OrchestratorMcpClientRequestId).annotate({
602 description:
603 "Reuse when retrying a call that lost its result, so the user sees one card and its answer is returned again. Use a new id to ask again after timed_out or cancelled.",
604 }),
605});
606export type OrchestratorMcpRequestSecretInput = typeof OrchestratorMcpRequestSecretInput.Type;
607
608export const OrchestratorMcpRequestSecretResult = Schema.Union([
609 Schema.Struct({
610 status: Schema.Literal("saved").annotate({ description: "secretRef holds the value." }),
611 secretRef: SecretRef.annotate({
612 description:
613 "Pass it to a tool that accepts a secretRef; it works once, and you never see the value.",
614 }),
615 }),
616 Schema.Struct({
617 status: Schema.Literals(["declined", "cancelled", "timed_out"]).annotate({
618 description:
619 "declined: the user chose not to. cancelled: the request ended with the run. timed_out: the user did not answer in time; the card is closed, so ask again with a new clientRequestId if still needed.",
620 }),
621 }),
622]);
623export type OrchestratorMcpRequestSecretResult = typeof OrchestratorMcpRequestSecretResult.Type;
624
580export const OrchestratorMcpDeleteScheduledTaskInput = Schema.Struct({625export const OrchestratorMcpDeleteScheduledTaskInput = Schema.Struct({
581 scheduledTaskId: ScheduledTaskId,626 scheduledTaskId: ScheduledTaskId,
582});627});
packages/contracts/src/rpc.ts+8
...@@ -320,6 +320,7 @@ import {...@@ -320,6 +320,7 @@ import {
320 ScheduledTaskUpsertInput,320 ScheduledTaskUpsertInput,
321 ScheduledTaskMutationResult,321 ScheduledTaskMutationResult,
322} from "./scheduledTask.ts";322} from "./scheduledTask.ts";
323import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts";
323import {324import {
324 ProjectCloneActionInput,325 ProjectCloneActionInput,
325 ProjectCloneActionResult,326 ProjectCloneActionResult,
...@@ -480,6 +481,7 @@ export const WS_METHODS = {...@@ -480,6 +481,7 @@ export const WS_METHODS = {
480 scheduledTasksDelete: "scheduledTasks.delete",481 scheduledTasksDelete: "scheduledTasks.delete",
481 scheduledTasksRunNow: "scheduledTasks.runNow",482 scheduledTasksRunNow: "scheduledTasks.runNow",
482 scheduledTasksRotateWebhookToken: "scheduledTasks.rotateWebhookToken",483 scheduledTasksRotateWebhookToken: "scheduledTasks.rotateWebhookToken",
484 secretsAnswerRequest: "secrets.answerRequest",
483 scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries",485 scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries",
484 scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery",486 scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery",
485487
...@@ -1685,6 +1687,11 @@ const WsScheduledTasksRotateWebhookTokenRpc = Rpc.make(...@@ -1685,6 +1687,11 @@ const WsScheduledTasksRotateWebhookTokenRpc = Rpc.make(
1685 },1687 },
1686);1688);
16871689
1690const WsSecretsAnswerRequestRpc = Rpc.make(WS_METHODS.secretsAnswerRequest, {
1691 payload: SecretRequestAnswerInput,
1692 error: Schema.Union([SecretRequestError, EnvironmentAuthorizationError]),
1693});
1694
1688const WsScheduledTasksListWebhookDeliveriesRpc = Rpc.make(1695const WsScheduledTasksListWebhookDeliveriesRpc = Rpc.make(
1689 WS_METHODS.scheduledTasksListWebhookDeliveries,1696 WS_METHODS.scheduledTasksListWebhookDeliveries,
1690 {1697 {
...@@ -1789,6 +1796,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1789,6 +1796,7 @@ export const WsRpcGroup = RpcGroup.make(
1789 WsScheduledTasksDeleteRpc,1796 WsScheduledTasksDeleteRpc,
1790 WsScheduledTasksRunNowRpc,1797 WsScheduledTasksRunNowRpc,
1791 WsScheduledTasksRotateWebhookTokenRpc,1798 WsScheduledTasksRotateWebhookTokenRpc,
1799 WsSecretsAnswerRequestRpc,
1792 WsScheduledTasksListWebhookDeliveriesRpc,1800 WsScheduledTasksListWebhookDeliveriesRpc,
1793 WsScheduledTasksGetWebhookDeliveryRpc,1801 WsScheduledTasksGetWebhookDeliveryRpc,
1794 WsServerReportClientActivityRpc,1802 WsServerReportClientActivityRpc,
packages/contracts/src/scheduledTask.ts+5
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 IsoDateTime,6 IsoDateTime,
7 ProjectId,7 ProjectId,
8 ScheduledTaskId,8 ScheduledTaskId,
9 SecretRef,
9 ThreadId,10 ThreadId,
10 TrimmedNonEmptyString,11 TrimmedNonEmptyString,
11} from "./baseSchemas.ts";12} from "./baseSchemas.ts";
...@@ -106,6 +107,10 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({...@@ -106,6 +107,10 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({
106 secret: Schema.optional(TrimmedNonEmptyString).annotate({107 secret: Schema.optional(TrimmedNonEmptyString).annotate({
107 description: "Shared signing secret. Omit to keep the stored secret.",108 description: "Shared signing secret. Omit to keep the stored secret.",
108 }),109 }),
110 secretRef: Schema.optional(SecretRef).annotate({
111 description:
112 "A secret the user entered through request_secret, used instead of secret. It is consumed by this save.",
113 }),
109 }),114 }),
110 ),115 ),
111 ).annotate({116 ).annotate({
packages/contracts/src/secretRequest.ts created+52
...@@ -0,0 +1,52 @@
1import * as Schema from "effect/Schema";
2
3import { ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts";
4
5/**
6 * The user's answer to an agent's request for a secret. A saved value is kept
7 * by the server under a one-use SecretRef; the thread learns only the status.
8 */
9export const SecretRequestAnswerInput = Schema.Struct({
10 threadId: ThreadId,
11 turnItemId: TurnItemId,
12 answer: Schema.Union([
13 Schema.Struct({ type: Schema.Literal("save"), secret: TrimmedNonEmptyString }),
14 Schema.Struct({ type: Schema.Literal("decline") }),
15 ]),
16});
17export type SecretRequestAnswerInput = typeof SecretRequestAnswerInput.Type;
18
19const SECRET_REQUEST_FAILURE_MESSAGES = {
20 load_failed: "Could not load the secret request.",
21 not_found: "This secret request no longer exists.",
22 already_answered: "This secret request was already answered.",
23 agent_stopped: "The agent that asked has stopped, so this secret can't be used.",
24 store_failed: "Could not store the secret.",
25 record_failed: "Saved the secret, but could not update the request.",
26 invalid_ref: "That secretRef is not valid.",
27 read_failed: "Could not read the secret.",
28 ref_unavailable:
29 "That secretRef was already used or does not exist. Ask the user again with request_secret.",
30 ref_expired: "That secretRef expired. Ask the user again with request_secret.",
31 consume_failed: "Could not use that secretRef. Try again.",
32} as const;
33
34export const SecretRequestFailureReason = Schema.Literals(
35 Object.keys(SECRET_REQUEST_FAILURE_MESSAGES) as Array<
36 keyof typeof SECRET_REQUEST_FAILURE_MESSAGES
37 >,
38);
39export type SecretRequestFailureReason = typeof SecretRequestFailureReason.Type;
40
41/** Answering a request or using its ref failed; the message is shown to users and agents. */
42export class SecretRequestError extends Schema.TaggedError<SecretRequestError>()(
43 "SecretRequestError",
44 {
45 reason: SecretRequestFailureReason,
46 cause: Schema.optionalKey(Schema.Defect()),
47 },
48) {
49 override get message(): string {
50 return SECRET_REQUEST_FAILURE_MESSAGES[this.reason];
51 }
52}
packages/shared/src/t3McpToolPresentation.ts+2
...@@ -38,6 +38,7 @@ export type T3McpToolSummaryAction =...@@ -38,6 +38,7 @@ export type T3McpToolSummaryAction =
38 | "question-list"38 | "question-list"
39 | "question-read"39 | "question-read"
40 | "question-respond"40 | "question-respond"
41 | "secret-request"
41 | "worktree-handoff"42 | "worktree-handoff"
42 | "worktree-list"43 | "worktree-list"
43 | "worktree-status"44 | "worktree-status"
...@@ -130,6 +131,7 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {...@@ -130,6 +131,7 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
130 ["Delete", "Deleting", "Requested deletion of", "a scheduled task"],131 ["Delete", "Deleting", "Requested deletion of", "a scheduled task"],
131 "schedule-delete",132 "schedule-delete",
132 ),133 ),
134 request_secret: tool(["Ask for", "Asking for", "Asked for", "a secret"], "secret-request"),
133 create_threads: tool(["Create", "Creating", "Created", "T3 threads"], "thread-create"),135 create_threads: tool(["Create", "Creating", "Created", "T3 threads"], "thread-create"),
134 t3_thread_start: tool(["Start", "Starting", "Started", "a T3 thread"], "thread-create"),136 t3_thread_start: tool(["Start", "Starting", "Started", "a T3 thread"], "thread-create"),
135 t3_thread_list: tool(["List", "Listing", "Listed", "T3 threads"], "thread-list"),137 t3_thread_list: tool(["List", "Listing", "Listed", "T3 threads"], "thread-list"),