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 @@
11import { ThreadContextDivider } from "./thread-context-divider";
22import { ThreadHandoffRow } from "./thread-handoff-row";
3import { SecretRequestCard } from "./SecretRequestCard";
34import {
45 WorktreeWorkingHeader,
56 WorktreeSetupCard,
......@@ -162,6 +163,7 @@ import {
162163 threadFeedRunIsUnsettled,
163164 isContextCompactionActivityGroup,
164165 isContextHandoffActivityGroup,
166 isSecretRequestActivityGroup,
165167 type ThreadFeedEntry,
166168 type ThreadFeedLatestRun,
167169} from "../../lib/threadActivity";
......@@ -1621,6 +1623,16 @@ function renderFeedEntry(
16211623 );
16221624 }
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
16241636 if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
16251637 const label = entry.activities[0]!.summary;
16261638 const active =
apps/mobile/src/lib/threadActivity.ts+20-1
......@@ -321,6 +321,13 @@ export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): b
321321 );
322322}
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
324331function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
325332 return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
326333}
......@@ -420,7 +427,13 @@ function itemIsToolLike(item: OrchestrationV2TurnItem): boolean {
420427}
421428
422429function 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 );
424437}
425438
426439function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
......@@ -537,6 +550,8 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
537550 case "fork":
538551 case "thread_created":
539552 return "zap";
553 case "secret_request":
554 return "lock";
540555 }
541556}
542557
......@@ -590,6 +605,8 @@ function itemSummary(
590605 return "Thread forked";
591606 case "thread_created":
592607 return "Thread created";
608 case "secret_request":
609 return item.label;
593610 case "dynamic_tool": {
594611 const classified = classifyToolActivity({
595612 itemType: "dynamic_tool_call",
......@@ -647,6 +664,8 @@ function itemPreview(item: OrchestrationV2TurnItem): string | null {
647664 case "fork":
648665 case "thread_created":
649666 return item.targetThreadId;
667 case "secret_request":
668 return item.reason || null;
650669 case "subagent":
651670 return item.result ?? item.progress ?? item.prompt;
652671 case "dynamic_tool":
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -96,6 +96,7 @@ export const RPC_REQUIRED_SCOPES = {
9696 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
9797 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
9898 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
99 [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,
99100 // Delivery logs hold request bodies, so they need the same scope as the URL.
100101 [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
101102 [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+5
......@@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
2020import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
2121import * as ProjectService from "../project/ProjectService.ts";
2222import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
23import * as SecretRequests from "../secrets/SecretRequests.ts";
2324import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
2425import type * as McpInvocationContext from "./McpInvocationContext.ts";
2526import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
......@@ -149,6 +150,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
149150 getProviders: Effect.succeed([]),
150151 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
151152 Layer.mock(ProjectService.ProjectService)({}),
153 Layer.mock(SecretRequests.SecretRequests)({}),
152154 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
153155 list: () => Effect.succeed({ tasks: [] }),
154156 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
......@@ -213,6 +215,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
213215 getProviders: Effect.succeed([]),
214216 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
215217 Layer.mock(ProjectService.ProjectService)({}),
218 Layer.mock(SecretRequests.SecretRequests)({}),
216219 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
217220 list: () => Effect.succeed({ tasks: [] }),
218221 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
......@@ -325,6 +328,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
325328 getProviders: Effect.succeed([]),
326329 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
327330 Layer.mock(ProjectService.ProjectService)({}),
331 Layer.mock(SecretRequests.SecretRequests)({}),
328332 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
329333 list: () => Effect.succeed({ tasks: [] }),
330334 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
......@@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {
447451 getProviders: Effect.succeed([]),
448452 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
449453 Layer.mock(ProjectService.ProjectService)({}),
454 Layer.mock(SecretRequests.SecretRequests)({}),
450455 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
451456 list: () =>
452457 Effect.succeed({
apps/server/src/mcp/OrchestratorMcpService.test.ts+12
......@@ -24,6 +24,7 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
2424import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
2525import * as ProjectService from "../project/ProjectService.ts";
2626import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";
2728import type { McpInvocationScope } from "./McpInvocationContext.ts";
2829import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
2930
......@@ -120,6 +121,7 @@ describe("OrchestratorMcpService", () => {
120121 list: () => Effect.succeed([]),
121122 }),
122123 Layer.mock(ProjectService.ProjectService)({}),
124 Layer.mock(SecretRequests.SecretRequests)({}),
123125 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
124126 );
125127 const scope: McpInvocationScope = {
......@@ -209,6 +211,7 @@ describe("OrchestratorMcpService", () => {
209211 list: () => Effect.succeed([]),
210212 }),
211213 Layer.mock(ProjectService.ProjectService)({}),
214 Layer.mock(SecretRequests.SecretRequests)({}),
212215 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
213216 );
214217 const scope: McpInvocationScope = {
......@@ -290,6 +293,7 @@ describe("OrchestratorMcpService", () => {
290293 list: () => Effect.succeed([]),
291294 }),
292295 Layer.mock(ProjectService.ProjectService)({}),
296 Layer.mock(SecretRequests.SecretRequests)({}),
293297 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
294298 );
295299 const scope: McpInvocationScope = {
......@@ -363,6 +367,7 @@ describe("OrchestratorMcpService", () => {
363367 list: () => Effect.succeed([]),
364368 }),
365369 Layer.mock(ProjectService.ProjectService)({}),
370 Layer.mock(SecretRequests.SecretRequests)({}),
366371 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
367372 );
368373 const scope: McpInvocationScope = {
......@@ -444,6 +449,7 @@ describe("OrchestratorMcpService", () => {
444449 list: () => Effect.succeed([]),
445450 }),
446451 Layer.mock(ProjectService.ProjectService)({}),
452 Layer.mock(SecretRequests.SecretRequests)({}),
447453 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
448454 );
449455 const scope: McpInvocationScope = {
......@@ -652,6 +658,7 @@ describe("OrchestratorMcpService provider resolution", () => {
652658 disabledAntigravityInstanceId,
653659 ]),
654660 Layer.mock(ProjectService.ProjectService)({}),
661 Layer.mock(SecretRequests.SecretRequests)({}),
655662 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
656663 );
657664
......@@ -796,6 +803,7 @@ describe("OrchestratorMcpService provider resolution", () => {
796803 }),
797804 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
798805 Layer.mock(ProjectService.ProjectService)({}),
806 Layer.mock(SecretRequests.SecretRequests)({}),
799807 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
800808 );
801809
......@@ -890,6 +898,7 @@ describe("OrchestratorMcpService provider resolution", () => {
890898 }),
891899 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
892900 Layer.mock(ProjectService.ProjectService)({}),
901 Layer.mock(SecretRequests.SecretRequests)({}),
893902 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
894903 );
895904
......@@ -937,6 +946,7 @@ describe("OrchestratorMcpService provider resolution", () => {
937946 ]),
938947 adapterRegistryLayer([codexInstanceId]),
939948 Layer.mock(ProjectService.ProjectService)({}),
949 Layer.mock(SecretRequests.SecretRequests)({}),
940950 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
941951 );
942952
......@@ -1044,6 +1054,7 @@ describe("OrchestratorMcpService provider resolution", () => {
10441054 adapterRegistryLayer([codexInstanceId, claudeInstanceId]),
10451055 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
10461056 Layer.mock(ProjectService.ProjectService)({}),
1057 Layer.mock(SecretRequests.SecretRequests)({}),
10471058 );
10481059
10491060 yield* Effect.gen(function* () {
......@@ -1200,6 +1211,7 @@ describe("OrchestratorMcpService provider resolution", () => {
12001211 ]),
12011212 adapterRegistryLayer([codexInstanceId, codexAltInstanceId]),
12021213 Layer.mock(ProjectService.ProjectService)({}),
1214 Layer.mock(SecretRequests.SecretRequests)({}),
12031215 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
12041216 );
12051217
apps/server/src/mcp/OrchestratorMcpService.ts+159-1
......@@ -5,6 +5,7 @@ import {
55 MessageId,
66 type ModelSelection,
77 NodeId,
8 TurnItemId,
89 type OrchestrationV2Run,
910 type OrchestrationV2Subagent,
1011 type OrchestrationV2ThreadProjection,
......@@ -19,6 +20,8 @@ import {
1920 type OrchestratorMcpDelegateTaskResult,
2021 type OrchestratorMcpInteractionMode,
2122 type OrchestratorMcpDeleteScheduledTaskInput,
23 type OrchestratorMcpRequestSecretInput,
24 type OrchestratorMcpRequestSecretResult,
2225 type OrchestratorMcpDeleteScheduledTaskResult,
2326 type OrchestratorMcpListScheduledTasksResult,
2427 type OrchestratorMcpRuntimeMode,
......@@ -60,6 +63,7 @@ import * as Crypto from "effect/Crypto";
6063import * as DateTime from "effect/DateTime";
6164import * as Duration from "effect/Duration";
6265import * as Effect from "effect/Effect";
66import * as Exit from "effect/Exit";
6367import * as Layer from "effect/Layer";
6468import * as Option from "effect/Option";
6569import * as Schema from "effect/Schema";
......@@ -79,6 +83,8 @@ import {
7983 type McpThreadInvocationScope,
8084 requireThreadScope,
8185} from "./McpInvocationContext.ts";
86import * as Metrics from "../observability/Metrics.ts";
87import * as SecretRequests from "../secrets/SecretRequests.ts";
8288
8389const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
8490const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
......@@ -90,6 +96,8 @@ const TASK_WAKE_EVENTS = [
9096 { thread: "child", eventType: "subagent.updated" },
9197 { thread: "child", eventType: "provider-thread.updated" },
9298] as const;
99/** A person answers the card, so a slower poll is plenty. */
100const SECRET_REQUEST_POLL_INTERVAL_MS = 500;
93101const DEFAULT_THREAD_LIST_LIMIT = 50;
94102const DEFAULT_THREAD_READ_LIMIT = 50;
95103const DEFAULT_THREAD_RUN_LIMIT = 10;
......@@ -140,6 +148,14 @@ export interface OrchestratorMcpServiceShape {
140148 scope: McpInvocationScope,
141149 input: OrchestratorMcpDeleteScheduledTaskInput,
142150 ) => 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>;
143159 readonly listThreads: (
144160 scope: McpInvocationScope,
145161 input: OrchestratorMcpThreadListInput,
......@@ -220,7 +236,11 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask
220236 schedule: task.schedule,
221237 nextRunAt: task.nextRunAt,
222238 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" }),
224244 };
225245}
226246
......@@ -725,6 +745,8 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null {
725745 return `Forked to thread ${item.targetThreadId}.`;
726746 case "thread_created":
727747 return `Created thread ${item.targetThreadId} with ${item.targetProviderInstanceId} (${item.targetModel}).`;
748 case "secret_request":
749 return `Asked the user for ${item.label}: ${item.secretStatus}.`;
728750 case "subagent":
729751 return item.result ?? item.progress ?? item.prompt;
730752 case "dynamic_tool":
......@@ -806,6 +828,7 @@ const make = Effect.gen(function* () {
806828 ),
807829 )
808830 : Effect.succeed(project.defaultModelSelection);
831 const secretRequests = yield* SecretRequests.SecretRequests;
809832
810833 const requireCapability = (scope: McpInvocationScope) =>
811834 scope.capabilities.has("orchestration")
......@@ -1514,6 +1537,140 @@ const make = Effect.gen(function* () {
15141537 );
15151538 return { scheduledTaskId: existing.id, deleted: true };
15161539 }),
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
15171674 capabilities: (scope) =>
15181675 Effect.gen(function* () {
15191676 const { parent, limits } = yield* loadCaller(scope);
......@@ -2192,4 +2349,5 @@ export const layer: Layer.Layer<
21922349 | ProviderAdapterRegistry.ProviderAdapterRegistryV2
21932350 | ScheduledTaskService.ScheduledTaskService
21942351 | ProjectService.ProjectService
2352 | SecretRequests.SecretRequests
21952353> = Layer.effect(OrchestratorMcpService, make);
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+156-1
......@@ -44,12 +44,14 @@ import * as Option from "effect/Option";
4444import * as PubSub from "effect/PubSub";
4545import * as Ref from "effect/Ref";
4646import * as Schema from "effect/Schema";
47import * as Fiber from "effect/Fiber";
4748import * as Stream from "effect/Stream";
4849import { McpSchema, McpServer } from "effect/ai";
4950
5051import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
5152import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
5253import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts";
54import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts";
5355import * as EventSink from "../orchestration-v2/EventSink.ts";
5456import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
5557import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
......@@ -73,6 +75,8 @@ import {
7375import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
7476import * as ProjectService from "../project/ProjectService.ts";
7577import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
78import * as SecretRequests from "../secrets/SecretRequests.ts";
79import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7680import * as McpHttpServer from "./McpHttpServer.ts";
7781import * as McpInvocationContext from "./McpInvocationContext.ts";
7882import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts";
......@@ -444,7 +448,19 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask
444448 prompt: input.prompt,
445449 enabled: input.enabled,
446450 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,
448464 projectId: input.projectId,
449465 threadId: input.threadId ?? null,
450466 workspaceStrategy: input.workspaceStrategy,
......@@ -463,6 +479,25 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask
463479 };
464480}
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
466501const unusedScheduledTaskStubLayer = Layer.succeed(
467502 ScheduledTaskService.ScheduledTaskService,
468503 ScheduledTaskService.ScheduledTaskService.of({
......@@ -658,6 +693,12 @@ describe("orchestrator MCP toolkit", () => {
658693 ),
659694 }),
660695 ),
696 Layer.provideMerge(
697 SecretRequests.layer.pipe(
698 Layer.provide(memorySecretStoreLayer),
699 Layer.provide(orchestrationLayer),
700 ),
701 ),
661702 Layer.provide(NodeServices.layer),
662703 );
663704
......@@ -1472,6 +1513,114 @@ describe("orchestrator MCP toolkit", () => {
14721513 });
14731514 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
14751624 const delegatedCall = yield* invoke("delegate_task", {
14761625 task: delegatedPrompt,
14771626 target: {
......@@ -3570,6 +3719,12 @@ describe("orchestrator MCP toolkit", () => {
35703719 Layer.provide(providerRegistryLayer),
35713720 Layer.provide(unusedScheduledTaskStubLayer),
35723721 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
3722 Layer.provideMerge(
3723 SecretRequests.layer.pipe(
3724 Layer.provide(memorySecretStoreLayer),
3725 Layer.provide(orchestrationLayer),
3726 ),
3727 ),
35733728 Layer.provide(NodeServices.layer),
35743729 );
35753730
apps/server/src/mcp/toolkits/core.test.ts+4
......@@ -23,6 +23,7 @@ import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapter
2323import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
2424import * as ProjectService from "../../project/ProjectService.ts";
2525import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts";
26import * as SecretRequests from "../../secrets/SecretRequests.ts";
2627import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
2728import * as McpHttpServer from "../McpHttpServer.ts";
2829import * as McpInvocationContext from "../McpInvocationContext.ts";
......@@ -386,6 +387,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>
386387 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
387388 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
388389 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
390 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
389391 ),
390392 ),
391393 ),
......@@ -437,6 +439,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode
437439 }),
438440 ),
439441 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
442 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
440443 ),
441444 ),
442445 ),
......@@ -505,6 +508,7 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()
505508 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
506509 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
507510 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
511 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
508512 ),
509513 ),
510514 ),
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+6
......@@ -54,6 +54,12 @@ const handlers = {
5454 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
5555 return yield* service.deleteScheduledTask(scope, input);
5656 }),
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 }),
5763 create_threads: (input) =>
5864 Effect.gen(function* () {
5965 const scope = yield* McpInvocationContext.McpInvocationContext;
apps/server/src/mcp/toolkits/orchestrator/tools.ts+16-1
......@@ -6,6 +6,8 @@ import {
66 OrchestratorMcpDelegateTaskResult,
77 OrchestratorMcpDeleteScheduledTaskInput,
88 OrchestratorMcpDeleteScheduledTaskResult,
9 OrchestratorMcpRequestSecretInput,
10 OrchestratorMcpRequestSecretResult,
911 OrchestratorMcpFailure,
1012 OrchestratorMcpListScheduledTasksInput,
1113 OrchestratorMcpListScheduledTasksResult,
......@@ -97,7 +99,7 @@ const TaskCancelTool = Tool.make("task_cancel", {
9799
98100export const ScheduleTaskTool = Tool.make("schedule_task", {
99101 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.",
101103 parameters: OrchestratorMcpScheduleTaskInput,
102104 success: OrchestratorMcpScheduleTaskResult,
103105 failure: OrchestratorMcpFailure,
......@@ -146,6 +148,18 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", {
146148 .annotate(Tool.Title, "Delete a scheduled task")
147149 .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
149163export const CreateThreadsTool = Tool.make("create_threads", {
150164 description:
151165 "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(
248262 ListScheduledTasksTool,
249263 UpdateScheduledTaskTool,
250264 DeleteScheduledTaskTool,
265 RequestSecretTool,
251266 CreateThreadsTool,
252267 ThreadListTool,
253268 ThreadReadTool,
apps/server/src/mcp/toolkits/worktree/registration.test.ts+2
......@@ -19,6 +19,7 @@ import * as ProjectService from "../../../project/ProjectService.ts";
1919import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
2020import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts";
2121import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";
22import * as SecretRequests from "../../../secrets/SecretRequests.ts";
2223import * as ServerSettings from "../../../serverSettings.ts";
2324import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";
2425import * as McpHttpServer from "../../McpHttpServer.ts";
......@@ -33,6 +34,7 @@ const StubServicesLive = Layer.mergeAll(
3334 Layer.mock(ProviderRegistry.ProviderRegistry)({}),
3435 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
3536 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
37 Layer.mock(SecretRequests.SecretRequests)({}),
3638 Layer.mock(ProjectService.ProjectService)({}),
3739 ServerSettings.layerTest({}),
3840 Layer.mock(GitWorkflowService.GitWorkflowService)({}),
apps/server/src/observability/Metrics.ts+10
......@@ -94,6 +94,16 @@ export const webhookRunsTotal = Metric.counter("t3_webhook_runs_total", {
9494 description: "Runs started from webhook deliveries, by outcome.",
9595});
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
97107export const metricAttributes = (
98108 attributes: Readonly<Record<string, unknown>>,
99109): 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";
2424import * as Schema from "effect/Schema";
2525import * as Stream from "effect/Stream";
2626import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";
2728import * as Scheduler from "../scheduling/Scheduler.ts";
2829import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
2930import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";
......@@ -315,6 +316,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
315316 }),
316317 ),
317318 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
319 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
318320 Layer.provide(
319321 Layer.mergeAll(NodeCrypto.layer, Scheduler.layer, SqlitePersistenceMemory),
320322 ),
apps/server/src/orchestration-v2/Orchestrator.ts+98
......@@ -439,6 +439,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
439439 case "delegated_task.completion-delivery.dispose":
440440 case "thread.created.record":
441441 return command.parentThreadId;
442 case "secret_request.record":
443 return command.threadId;
442444 case "thread.fork":
443445 case "thread.merge_back":
444446 return command.targetThreadId;
......@@ -6889,6 +6891,99 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
68896891 },
68906892 );
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
68926987 const dispatchRuntimeRequestRespond = (
68936988 command: Extract<OrchestrationV2Command, { readonly type: "runtime-request.respond" }>,
68946989 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
......@@ -10008,6 +10103,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1000810103 case "thread.created.record":
1000910104 yield* dispatchCreatedThreadRecord(command, events);
1001010105 break;
10106 case "secret_request.record":
10107 yield* dispatchSecretRequestRecord(command, events);
10108 break;
1001110109 default:
1001210110 return yield* dispatchUnsupported(command);
1001310111 }
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", () =>
521521 const itemLookups = plan.filter((row) => row.detail.startsWith("SEARCH item "));
522522 assert.lengthOf(itemLookups, 2);
523523 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");
524528 }).pipe(Effect.provide(SqlLayer)),
525529);
apps/server/src/orchestration-v2/ProjectionStore.ts+59-5
......@@ -28,7 +28,6 @@ import type {
2828 ProviderThreadId,
2929 ProviderTurnId,
3030 RunAttemptId,
31 RuntimeRequestId,
3231 MessageId,
3332} from "@t3tools/contracts";
3433import {
......@@ -50,6 +49,7 @@ import {
5049 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,
5150 orchestrationV2RunWorkStartedAt,
5251 RunId,
52 RuntimeRequestId,
5353 CheckpointScopeId,
5454 ThreadId,
5555 TurnItemId,
......@@ -919,6 +919,7 @@ type ShellThreadRow = {
919919 readonly blocking_run_completed_at: string | null;
920920 readonly blocking_failure_payload_json: string | null;
921921 readonly pending_request_payload_json: string | null;
922 readonly pending_secret_request_payload_json: string | null;
922923 readonly latest_user_message_at: string | null;
923924 readonly latest_user_authored_message_at: string | null;
924925 readonly has_actionable_proposed_plan: number;
......@@ -1303,6 +1304,29 @@ function buildVisibleTurnItems(input: {
13031304 ]);
13041305}
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
13061330export function threadShellFromProjection(
13071331 projection: OrchestrationV2ThreadProjection,
13081332): OrchestrationV2ThreadShell {
......@@ -1327,13 +1351,28 @@ export function threadShellFromProjection(
13271351 projection.runs
13281352 .filter(isActivityRunForShell)
13291353 .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null;
1354 const liveRunIds = new Set(projection.runs.filter(isActivityRunForShell).map((run) => run.id));
13301355 const pendingRuntimeRequest =
13311356 projection.runtimeRequests
13321357 .filter((request) => request.status === "pending")
13331358 .toSorted(
13341359 (left, right) =>
13351360 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 );
13371376 const userMessages = projection.messages
13381377 .filter((message) => message.role === "user")
13391378 .toSorted(
......@@ -4958,6 +4997,17 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
49584997 ORDER BY request.created_at DESC, request.runtime_request_id DESC
49594998 LIMIT 1
49604999 ) 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,
49615011 (
49625012 SELECT message.updated_at
49635013 FROM orchestration_v2_projection_messages message
......@@ -5329,9 +5379,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
53295379 } = input;
53305380 const thread = yield* decodeThreadPayload(row.payload_json);
53315381 const pendingRuntimeRequest =
5332 row.pending_request_payload_json === null
5333 ? null
5334 : yield* decodeRuntimeRequestPayload(row.pending_request_payload_json);
5382 row.pending_request_payload_json !== null
5383 ? 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;
53355389 let terminalFailureItem =
53365390 row.terminal_failure_payload_json === null
53375391 ? 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"
578578 }).pipe(Effect.provide(layer));
579579});
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
581646it.effect("holds accepted queued work without cancelling its execution state after restart", () => {
582647 const threadId = ThreadId.make("thread_queued_restart");
583648 const runId = RunId.make("run_queued_restart");
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts+11-2
......@@ -149,6 +149,15 @@ function resolveStaleBackgroundItemProviderInstanceId(
149149 return projection.providerThreads[0]?.providerInstanceId ?? projection.thread.providerInstanceId;
150150}
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
152161/**
153162 * A provider thread's latest started run: the last turn that provider saw.
154163 * Restart recovery records the thread's cancelled background work on it, and
......@@ -426,7 +435,7 @@ export const make = Effect.gen(function* () {
426435 ...(item.nodeId === null ? {} : { nodeId: item.nodeId }),
427436 providerInstanceId: run.providerInstanceId,
428437 occurredAt: now,
429 payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now },
438 payload: cancelledItem(item, now),
430439 });
431440 }
432441 }
......@@ -456,7 +465,7 @@ export const make = Effect.gen(function* () {
456465 ...(item.nodeId === null || item.nodeId === undefined ? {} : { nodeId: item.nodeId }),
457466 providerInstanceId,
458467 occurredAt: now,
459 payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now },
468 payload: cancelledItem(item, now),
460469 });
461470 if (item.nodeId !== null && item.nodeId !== undefined) {
462471 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";
4848import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
4949import * as ServerSettings from "../serverSettings.ts";
5050import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
51import * as SecretRequests from "../secrets/SecretRequests.ts";
5152import * as TextGeneration from "../textGeneration/TextGeneration.ts";
5253import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
5354import * as CommandReceiptStore from "./CommandReceiptStore.ts";
......@@ -288,7 +289,14 @@ it.effect.each(
288289 ({ target, createdBy }) => {
289290 const harness = makeHarness();
290291 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 ),
292300 );
293301 return Effect.gen(function* () {
294302 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
5151import { layer as threadForkServiceLayer } from "./ThreadForkService.ts";
5252import { layer as turnItemPositionStoreLayer } from "./TurnItemPositionStore.ts";
5353import { layer as scheduledTaskServiceLayer } from "../scheduledTasks/ScheduledTaskService.ts";
54import * as SecretRequests from "../secrets/SecretRequests.ts";
5455
5556/** The shared application event log and its command receipts. */
5657export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
......@@ -256,8 +257,11 @@ const threadLaunchProvided = threadLaunchServiceLayer.pipe(
256257const threadLifecycleProvided = threadLifecycleServiceLayer.pipe(
257258 Layer.provide(threadManagementProvided),
258259);
260const secretRequestsProvided = SecretRequests.layer.pipe(Layer.provide(threadManagementProvided));
259261const scheduledTaskProvided = scheduledTaskServiceLayer.pipe(
260 Layer.provide(Layer.mergeAll(threadLaunchProvided, threadManagementProvided)),
262 Layer.provide(
263 Layer.mergeAll(threadLaunchProvided, threadManagementProvided, secretRequestsProvided),
264 ),
261265);
262266const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe(
263267 Layer.provide(
......@@ -314,6 +318,7 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll(
314318 threadLaunchProvided,
315319 threadLifecycleProvided,
316320 scheduledTaskProvided,
321 secretRequestsProvided,
317322 UsageLimitRecoveryWorker.workerLive.pipe(
318323 Layer.provide(Layer.mergeAll(projectionStoreLayer, threadManagementProvided)),
319324 ),
apps/server/src/provider/T3OrchestrationInstructions.ts+2-1
......@@ -9,7 +9,8 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept
99- 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.
1010- \`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.
1111- 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
1415### 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";
1010
1111import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
1212import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
13import * as SecretRequests from "../secrets/SecretRequests.ts";
1314import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
1415import * as ScheduledTaskService from "./ScheduledTaskService.ts";
1516
......@@ -22,6 +23,7 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id
2223 Scheduler.layer,
2324 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
2425 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
26 Layer.mock(SecretRequests.SecretRequests)({}),
2527 );
2628 yield* Effect.gen(function* () {
2729 const service = yield* ScheduledTaskService.ScheduledTaskService;
......@@ -64,6 +66,7 @@ it.effect("preserves a due run when a save only pads the scheduled hour", () =>
6466 Scheduler.layer,
6567 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
6668 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
69 Layer.mock(SecretRequests.SecretRequests)({}),
6770 );
6871 yield* Effect.gen(function* () {
6972 const service = yield* ScheduledTaskService.ScheduledTaskService;
apps/server/src/scheduledTasks/ScheduledTaskService.test.ts+3
......@@ -17,6 +17,7 @@ import * as TestClock from "effect/testing/TestClock";
1717
1818import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
1919import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
20import * as SecretRequests from "../secrets/SecretRequests.ts";
2021import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
2122import * as ScheduledTaskService from "./ScheduledTaskService.ts";
2223
......@@ -211,6 +212,7 @@ it.effect(
211212 ),
212213 }),
213214 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
215 Layer.mock(SecretRequests.SecretRequests)({}),
214216 NodeCrypto.layer,
215217 Scheduler.layer,
216218 ),
......@@ -316,6 +318,7 @@ it.effect(
316318 ),
317319 }),
318320 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
321 Layer.mock(SecretRequests.SecretRequests)({}),
319322 NodeCrypto.layer,
320323 Scheduler.layer,
321324 ),
apps/server/src/scheduledTasks/ScheduledTaskService.ts+29-8
......@@ -43,6 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient";
4343import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
4444import * as Metrics from "../observability/Metrics.ts";
4545import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
46import * as SecretRequests from "../secrets/SecretRequests.ts";
4647import * as Scheduler from "../scheduling/Scheduler.ts";
4748import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
4849import {
......@@ -393,6 +394,7 @@ export const layer = Layer.effect(
393394 const crypto = yield* Crypto.Crypto;
394395 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
395396 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
397 const secretRequests = yield* SecretRequests.SecretRequests;
396398 const scheduler = yield* Scheduler.Scheduler;
397399 const readWebhookOrigin = yield* ScheduledTaskWebhookOrigin;
398400 // Webhook deliveries for one task dispatch in arrival order rather than
......@@ -1042,10 +1044,28 @@ export const layer = Layer.effect(
10421044 const token = existing?.token ?? (yield* newWebhookToken);
10431045 const signature =
10441046 input.schedule.type === "webhook" ? input.schedule.signature : null;
1045 const secret =
1046 signature == null ? null : (signature.secret ?? existing?.secret ?? null);
1047 // A secretRef is a value the user entered for an agent; this
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);
10471067 const secretChanged =
1048 signature == null || signature.secret !== undefined || existing === null;
1068 signature == null || provided !== undefined || existing === null;
10491069 if (signature != null && secret === null) {
10501070 return yield* taskError("A webhook signature check needs a signing secret.", {
10511071 taskId: id,
......@@ -1608,11 +1628,12 @@ export const layer = Layer.effect(
16081628 )
16091629 : runOutcome("started"),
16101630 ),
1611 Effect.catchTag("WebhookDeliverySkipped", (skipped) =>
1612 runOutcome("skipped").pipe(
1613 Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)),
1614 ),
1615 ),
1631 Effect.catchTags({
1632 WebhookDeliverySkipped: (skipped) =>
1633 runOutcome("skipped").pipe(
1634 Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)),
1635 ),
1636 }),
16161637 // The log is readable over RPC, so it gets a fixed reason; the
16171638 // cause, which can carry request data, stays in the server log.
16181639 Effect.catchCause((cause) =>
apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts+126-2
......@@ -2,7 +2,7 @@ import * as NodeCrypto from "node:crypto";
22
33import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";
44import { assert, it } from "@effect/vitest";
5import { ScheduledTaskUpsertInput } from "@t3tools/contracts";
5import { ScheduledTaskUpsertInput, SecretRequestError } from "@t3tools/contracts";
66import * as DateTime from "effect/DateTime";
77import * as Deferred from "effect/Deferred";
88import * as Effect from "effect/Effect";
......@@ -16,6 +16,7 @@ import * as TestClock from "effect/testing/TestClock";
1616
1717import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
1818import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
19import * as SecretRequests from "../secrets/SecretRequests.ts";
1920import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
2021import * as Scheduler from "../scheduling/Scheduler.ts";
2122import * as ScheduledTaskService from "./ScheduledTaskService.ts";
......@@ -66,11 +67,14 @@ const withService = <A, E>(
6667 body: (input: {
6768 readonly service: ScheduledTaskService.ScheduledTaskService["Service"];
6869 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>;
6972 }) => Effect.Effect<A, E, never>,
7073 options: { readonly gate?: Deferred.Deferred<void>; readonly relayHookBaseUrl?: string } = {},
7174) =>
7275 Effect.gen(function* () {
7376 const launches = yield* Queue.unbounded<LaunchInput>();
77 const secretsByRef = new Map<string, string>();
7478 const dependencies = Layer.mergeAll(
7579 NodePlatformCrypto.layer,
7680 Scheduler.layer,
......@@ -82,6 +86,15 @@ const withService = <A, E>(
8286 ),
8387 }),
8488 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 }),
8598 Layer.succeed(
8699 ScheduledTaskService.ScheduledTaskWebhookOrigin,
87100 Effect.succeed({ relayHookBaseUrl: options.relayHookBaseUrl ?? null }),
......@@ -89,7 +102,7 @@ const withService = <A, E>(
89102 );
90103 return yield* Effect.gen(function* () {
91104 const service = yield* ScheduledTaskService.ScheduledTaskService;
92 return yield* body({ service, launches });
105 return yield* body({ service, launches, secretsByRef });
93106 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(dependencies))));
94107 }).pipe(Effect.provide(SqlitePersistenceMemory));
95108
......@@ -708,6 +721,117 @@ it.effect("deleting a task removes its delivery log", () =>
708721 ),
709722);
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
711835const signatureFor = (secret: string) =>
712836 `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
2424import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts";
2525import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
2626import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts";
27import * as SecretRequests from "../secrets/SecretRequests.ts";
2728import * as ServerSettings from "../serverSettings.ts";
2829import * as Scheduler from "./Scheduler.ts";
2930
......@@ -112,6 +113,7 @@ it.effect.each(["on time", "after restart"])(
112113 Layer.mock(ServerSettings.ServerSettingsService)({
113114 getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),
114115 }),
116 Layer.mock(SecretRequests.SecretRequests)({}),
115117 );
116118 const workers = Layer.mergeAll(
117119 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"
121121import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts";
122122import * as IdAllocator from "./orchestration-v2/IdAllocator.ts";
123123import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts";
124import * as SecretRequests from "./secrets/SecretRequests.ts";
124125import {
125126 archivedShellStreamItemFromThreadShell,
126127 buildActiveShellSnapshot,
......@@ -1220,6 +1221,7 @@ const makeWsRpcLayer = (
12201221 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
12211222 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;
12221223 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;
1224 const secretRequests = yield* SecretRequests.SecretRequests;
12231225 const pullRequests = yield* PullRequestService.PullRequestService;
12241226 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
12251227 const deviceService = yield* DeviceService.DeviceService;
......@@ -2102,6 +2104,11 @@ const makeWsRpcLayer = (
21022104 scheduledTasks.rotateWebhookToken(input),
21032105 { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id },
21042106 ),
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 }),
21052112 [WS_METHODS.scheduledTasksListWebhookDeliveries]: (input) =>
21062113 observeRpcEffect(
21072114 WS_METHODS.scheduledTasksListWebhookDeliveries,
apps/web/src/components/ChatView.tsx+5
......@@ -740,6 +740,8 @@ function eventPathContainsSelector(event: Event, selector: string): boolean {
740740 return path.some((target) => target instanceof Element && target.closest(selector));
741741}
742742
743const SECRET_REQUEST_SELECTOR = '[data-v2-item-type="secret_request"]';
744
743745/**
744746 * Whether input that landed outside any editable or interactive element
745747 * should be redirected into the composer. Shared by type-to-focus and
......@@ -747,6 +749,9 @@ function eventPathContainsSelector(event: Event, selector: string): boolean {
747749 */
748750function shouldRedirectInputToComposer(event: Event): boolean {
749751 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;
750755 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false;
751756 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
752757 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
apps/web/src/components/chat/MessagesTimeline.tsx+10
......@@ -276,6 +276,7 @@ import {
276276 V2LifecycleRow,
277277 type HandoffTimelineRun,
278278} from "./V2LifecycleRow";
279import { SecretRequestCard } from "./SecretRequestCard";
279280import { TimelineSystemDivider } from "./TimelineSystemDivider";
280281
281282import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";
......@@ -2808,6 +2809,15 @@ function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event"
28082809 if (item.type === "subagent" && (row.subagents?.length ?? 1) > 1) {
28092810 return <V2SubagentGroup key={row.id} row={row} />;
28102811 }
2812 if (item.type === "secret_request") {
2813 return (
2814 <SecretRequestCard
2815 environmentId={ctx.activeThreadEnvironmentId}
2816 item={item}
2817 visibility={visibility}
2818 />
2819 );
2820 }
28112821 if (isV2LifecycleItem(item)) {
28122822 return (
28132823 <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"
315315 "handoff",
316316 "run_interrupt_request",
317317 "run_interrupt_result",
318 "secret_request",
318319 "subagent",
319320]);
320321
321322const PERSISTENT_RESOURCE_V2_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
322323 "fork",
323324 "thread_created",
325 // Still answerable after a steer supersedes the attempt that asked.
326 "secret_request",
324327]);
325328
326329export function timelineEntryIsPersistentResourceCard(entry: TimelineEntry): boolean {
docs/operations/observability.md+4
......@@ -396,6 +396,10 @@ Webhooks have their own families:
396396 deliveries start, which happen after the sender has its answer.
397397- `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
399403`ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run
400404started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request
401405the 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 @@
3535 "types": "./src/userMessage.ts",
3636 "default": "./src/userMessage.ts"
3737 },
38 "./secret-request": {
39 "types": "./src/secretRequest.ts",
40 "default": "./src/secretRequest.ts"
41 },
3842 "./scheduled-task-webhook": {
3943 "types": "./src/scheduledTaskWebhook.ts",
4044 "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>(
12991299 scheduler: configScheduler,
13001300 concurrency: configConcurrency,
13011301 }),
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 }),
13021313 refreshUsageRates: createEnvironmentRpcCommand(runtime, {
13031314 label: "environment-data:server:refresh-usage-rates",
13041315 tag: WS_METHODS.serverRefreshUsageRates,
packages/client-runtime/src/t3ToolSummary.ts+3
......@@ -285,6 +285,9 @@ export function summarizeT3ToolCalls(
285285 quantity(countEntities(entityIds("requestId")), "pending question request"),
286286 );
287287 break;
288 case "secret-request":
289 label = phrase("Asked for", "ask for", quantity(selected.length, "secret"));
290 break;
288291 case "worktree-handoff":
289292 label = phrase(
290293 "Handed off to",
packages/contracts/src/baseSchemas.ts+3
......@@ -349,6 +349,9 @@ export type RuntimeRequestId = typeof RuntimeRequestId.Type;
349349export const RuntimeTaskId = makeEntityId("RuntimeTaskId");
350350export type RuntimeTaskId = typeof RuntimeTaskId.Type;
351351export 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;
352355export type ScheduledTaskId = typeof ScheduledTaskId.Type;
353356export const ApprovalRequestId = makeEntityId("ApprovalRequestId");
354357export type ApprovalRequestId = typeof ApprovalRequestId.Type;
packages/contracts/src/index.ts+1
......@@ -60,3 +60,4 @@ export * from "./worktreeMcp.ts";
6060export * from "./resourceTelemetry.ts";
6161export * from "./rpc.ts";
6262export * from "./worktreeSetup.ts";
63export * from "./secretRequest.ts";
packages/contracts/src/orchestrationV2.test.ts+1-1
......@@ -205,7 +205,7 @@ describe("orchestration V2 contracts", () => {
205205 });
206206 const known = item("item-known", "system_notice", { message: "Hello" });
207207 // 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" });
209209 const projected = (position: number, turnItem: { readonly id: string }) => ({
210210 position,
211211 visibility: "local",
packages/contracts/src/orchestrationV2.ts+45
......@@ -1346,6 +1346,26 @@ export const OrchestrationV2WebSearchResult = Schema.Struct({
13461346});
13471347export 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
13491369export const OrchestrationV2TurnItem = Schema.Union([
13501370 Schema.Struct({
13511371 ...OrchestrationV2TurnItemBaseFields,
......@@ -1525,6 +1545,10 @@ export const OrchestrationV2TurnItem = Schema.Union([
15251545 targetProviderInstanceId: ProviderInstanceId,
15261546 targetModel: TrimmedNonEmptyString,
15271547 }),
1548 Schema.Struct({
1549 ...OrchestrationV2TurnItemBaseFields,
1550 ...OrchestrationV2SecretRequestFields,
1551 }),
15281552 Schema.Struct({
15291553 ...OrchestrationV2TurnItemBaseFields,
15301554 type: Schema.Literal("subagent"),
......@@ -2296,6 +2320,10 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
22962320 targetProviderInstanceId: ProviderInstanceId,
22972321 targetModel: TrimmedNonEmptyString,
22982322 }),
2323 Schema.Struct({
2324 ...OrchestrationV2TurnItemJsonBaseFields,
2325 ...OrchestrationV2SecretRequestFields,
2326 }),
22992327 Schema.Struct({
23002328 ...OrchestrationV2TurnItemJsonBaseFields,
23012329 type: Schema.Literal("subagent"),
......@@ -3013,6 +3041,7 @@ export const OrchestrationV2Command = Schema.Union([
30133041 targetThreadId: ThreadId,
30143042 targetRunId: Schema.NullOr(RunId),
30153043 }),
3044
30163045 Schema.Struct({
30173046 type: Schema.Literal("provider.switch"),
30183047 commandId: CommandId,
......@@ -3081,6 +3110,22 @@ const OrchestrationV2InternalCommand = Schema.Union([
30813110 threadId: ThreadId,
30823111 reason: Schema.optional(Schema.String),
30833112 }),
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 }),
30843129]);
30853130export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalCommand.Type;
30863131
packages/contracts/src/orchestratorMcp.ts+47-2
......@@ -12,6 +12,7 @@ import {
1212 ProjectId,
1313 RunId,
1414 ScheduledTaskId,
15 SecretRef,
1516 ThreadId,
1617 TrimmedNonEmptyString,
1718 TurnItemId,
......@@ -541,8 +542,14 @@ export const OrchestratorMcpScheduledTask = Schema.Struct({
541542 schedule: ScheduledTaskSchedule,
542543 nextRunAt: Schema.NullOr(IsoDateTime),
543544 lastRunStatus: ScheduledTaskRunStatus,
544 /** For webhook tasks: the public T3 Connect URL, or the environment-relative path when the environment is not linked. */
545 webhookUrl: Schema.optional(Schema.String),
545 /** For webhook tasks: the public T3 Connect URL. Absent when this environment has no managed tunnel. */
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 }),
546553});
547554export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.Type;
548555
......@@ -577,6 +584,44 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({
577584export type OrchestratorMcpUpdateScheduledTaskInput =
578585 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
580625export const OrchestratorMcpDeleteScheduledTaskInput = Schema.Struct({
581626 scheduledTaskId: ScheduledTaskId,
582627});
packages/contracts/src/rpc.ts+8
......@@ -320,6 +320,7 @@ import {
320320 ScheduledTaskUpsertInput,
321321 ScheduledTaskMutationResult,
322322} from "./scheduledTask.ts";
323import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts";
323324import {
324325 ProjectCloneActionInput,
325326 ProjectCloneActionResult,
......@@ -480,6 +481,7 @@ export const WS_METHODS = {
480481 scheduledTasksDelete: "scheduledTasks.delete",
481482 scheduledTasksRunNow: "scheduledTasks.runNow",
482483 scheduledTasksRotateWebhookToken: "scheduledTasks.rotateWebhookToken",
484 secretsAnswerRequest: "secrets.answerRequest",
483485 scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries",
484486 scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery",
485487
......@@ -1685,6 +1687,11 @@ const WsScheduledTasksRotateWebhookTokenRpc = Rpc.make(
16851687 },
16861688);
16871689
1690const WsSecretsAnswerRequestRpc = Rpc.make(WS_METHODS.secretsAnswerRequest, {
1691 payload: SecretRequestAnswerInput,
1692 error: Schema.Union([SecretRequestError, EnvironmentAuthorizationError]),
1693});
1694
16881695const WsScheduledTasksListWebhookDeliveriesRpc = Rpc.make(
16891696 WS_METHODS.scheduledTasksListWebhookDeliveries,
16901697 {
......@@ -1789,6 +1796,7 @@ export const WsRpcGroup = RpcGroup.make(
17891796 WsScheduledTasksDeleteRpc,
17901797 WsScheduledTasksRunNowRpc,
17911798 WsScheduledTasksRotateWebhookTokenRpc,
1799 WsSecretsAnswerRequestRpc,
17921800 WsScheduledTasksListWebhookDeliveriesRpc,
17931801 WsScheduledTasksGetWebhookDeliveryRpc,
17941802 WsServerReportClientActivityRpc,
packages/contracts/src/scheduledTask.ts+5
......@@ -6,6 +6,7 @@ import {
66 IsoDateTime,
77 ProjectId,
88 ScheduledTaskId,
9 SecretRef,
910 ThreadId,
1011 TrimmedNonEmptyString,
1112} from "./baseSchemas.ts";
......@@ -106,6 +107,10 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({
106107 secret: Schema.optional(TrimmedNonEmptyString).annotate({
107108 description: "Shared signing secret. Omit to keep the stored secret.",
108109 }),
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 }),
109114 }),
110115 ),
111116 ).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 =
3838 | "question-list"
3939 | "question-read"
4040 | "question-respond"
41 | "secret-request"
4142 | "worktree-handoff"
4243 | "worktree-list"
4344 | "worktree-status"
......@@ -130,6 +131,7 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
130131 ["Delete", "Deleting", "Requested deletion of", "a scheduled task"],
131132 "schedule-delete",
132133 ),
134 request_secret: tool(["Ask for", "Asking for", "Asked for", "a secret"], "secret-request"),
133135 create_threads: tool(["Create", "Creating", "Created", "T3 threads"], "thread-create"),
134136 t3_thread_start: tool(["Start", "Starting", "Started", "a T3 thread"], "thread-create"),
135137 t3_thread_list: tool(["List", "Listing", "Listed", "T3 threads"], "thread-list"),