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