Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 37 additions & 21 deletions apps/mobile/src/features/threads/SecretRequestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import {
SECRET_REQUEST_DEFAULT_PLACEHOLDER,
SECRET_REQUEST_PRIVACY_NOTE,
secretRequestAnswerInput,
secretRequestComplete,
secretRequestDisplay,
secretRequestFailureMessage,
secretRequestFields,
type SecretRequestItem,
} from "@t3tools/client-runtime/secret-request";
import {
Expand Down Expand Up @@ -68,14 +70,18 @@ function PendingSecretRequestForm(props: {
reportFailure: false,
reportDefect: false,
});
const [secret, setSecret] = useState("");
const fields = secretRequestFields(item);
const [values, setValues] = useState<ReadonlyMap<string, string>>(() => new Map());
const complete = secretRequestComplete(item, values);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Submit then a tap can both run before a re-render; this guard is synchronous.
const inFlight = useRef(false);

const send = async (
reply: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
reply:
| { readonly type: "save"; readonly values: ReadonlyMap<string, string> }
| { readonly type: "decline" },
) => {
const input = secretRequestAnswerInput(item, reply);
if (input === null || inFlight.current) return;
Expand All @@ -87,7 +93,7 @@ function PendingSecretRequestForm(props: {
setSubmitting(false);
});
if (result._tag === "Success") {
setSecret("");
setValues(new Map());
return;
}
if (!isAtomCommandInterrupted(result)) {
Expand All @@ -103,22 +109,32 @@ function PendingSecretRequestForm(props: {
<Text className="font-sans text-sm leading-5 text-foreground-muted">{item.reason}</Text>
) : null}
</View>
<TextInput
accessibilityLabel={item.label}
placeholder={item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
value={secret}
onChangeText={setSecret}
editable={!submitting}
secureTextEntry
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
textContentType="none"
importantForAutofill="no"
spellCheck={false}
returnKeyType="done"
onSubmitEditing={() => void send({ type: "save", secret })}
/>
{fields.map((field) => (
<View key={field.name} className="gap-1">
{/* A single field is titled by the card itself. */}
{item.fields === undefined ? null : (
<Text className="font-sans text-xs text-foreground-muted">{field.label}</Text>
)}
<TextInput
accessibilityLabel={field.label}
placeholder={field.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
value={values.get(field.name) ?? ""}
onChangeText={(value) =>
setValues((current) => new Map(current).set(field.name, value))
}
editable={!submitting}
secureTextEntry
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
textContentType="none"
importantForAutofill="no"
spellCheck={false}
returnKeyType="done"
onSubmitEditing={() => void send({ type: "save", values })}
/>
</View>
))}
{error !== null ? (
<Text
accessibilityRole="alert"
Expand All @@ -130,8 +146,8 @@ function PendingSecretRequestForm(props: {
) : null}
<RequestActionButton
label="Save securely"
disabled={submitting || secret.trim().length === 0}
onPress={() => void send({ type: "save", secret })}
disabled={submitting || !complete}
onPress={() => void send({ type: "save", values })}
/>
<View className="flex-row items-center justify-between gap-2">
<View className="flex-1 flex-row items-center gap-1.5">
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2361,6 +2361,12 @@ const make = Effect.gen(function* () {
requestSecret: (scope, input) =>
Effect.gen(function* () {
const { scope: threadScope, parent } = yield* loadThreadCaller(scope, "request_secret");
if (input.fields !== undefined && input.placeholder !== undefined) {
return yield* failure(
"invalid_request",
"placeholder is for single-field requests; give each entry in fields its own.",
);
}
const threadId = threadScope.thread.threadId;
const run = ThreadManagementService.latestActiveRun(parent);
if (
Expand Down Expand Up @@ -2395,6 +2401,7 @@ const make = Effect.gen(function* () {
label: input.label,
reason: input.reason,
...(input.placeholder === undefined ? {} : { placeholder: input.placeholder }),
...(input.fields === undefined ? {} : { fields: input.fields }),
secretStatus,
})
.pipe(
Expand Down Expand Up @@ -2473,14 +2480,16 @@ const make = Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ "secret_request.status": status });
yield* Metrics.increment(Metrics.secretRequestsTotal, { status });
if (status !== "saved") return { status };
const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId });
if (Option.isNone(secretRef)) {
// Saved means the files were written before the card said so; none
// left means a storage fault, or the agent already used and deleted them.
const saved = yield* secretRequests.saved({ threadId: threadId, turnItemId });
if (Option.isNone(saved)) {
return yield* failure(
"orchestration_error",
"The user saved the secret, but it could not be read. Ask again with a new clientRequestId.",
);
}
return { status, secretRef: secretRef.value };
return { status, ...saved.value };
}).pipe(Effect.withSpan("OrchestratorMcpService.requestSecret")),

capabilities: (scope) =>
Expand Down
80 changes: 79 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,11 @@ describe("orchestrator MCP toolkit", () => {
SecretRequests.layer.pipe(
Layer.provide(layerMemorySecretStore),
Layer.provide(layerOrchestration),
Layer.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-secret-requests-" }).pipe(
Layer.provide(NodeServices.layer),
),
),
),
),
Layer.provide(
Expand Down Expand Up @@ -2057,16 +2062,27 @@ describe("orchestrator MCP toolkit", () => {
expect(secretCall.isError).toBe(false);
const secretResult = secretCall.structuredContent as {
status: string;
files: Record<string, string>;
secretRef?: string;
};
expect(secretResult.status).toBe("saved");
expect(
(yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest ?? null,
).toBeNull();
expect(secretResult.secretRef).toMatch(/^secret-ref:[0-9a-f]{32}$/);
// The agent gets the value only as a file it reads by path.
const secretFileSystem = yield* FileSystem.FileSystem.pipe(
Effect.provide(NodeServices.layer),
);
expect(Object.keys(secretResult.files)).toEqual(["secret"]);
expect(yield* secretFileSystem.readFileString(secretResult.files.secret!)).toBe(
"github-webhook-secret",
);
// The value appears nowhere in what the agent received or the thread recorded.
const received = [
...secretCall.content.map((part) => ("text" in part ? part.text : "")),
...Object.values(secretResult),
JSON.stringify(secretResult),
JSON.stringify(yield* orchestrator.getThreadProjection(parentThreadId)),
];
expect(received.some((value) => value.includes("github-webhook-secret"))).toBe(false);

Expand All @@ -2093,6 +2109,63 @@ describe("orchestrator MCP toolkit", () => {
.pipe(Effect.flip);
expect(reused.message).toContain("already used");

// Several values on one card: one file per field, and no ref.
const pairFiber = yield* invoke("request_secret", {
label: "Cloudflare API credentials",
reason: "Stored in OpenBao for the deploy job.",
fields: [
{ name: "account_id", label: "Account ID" },
{ name: "api_token", label: "API token", placeholder: "Paste the token" },
],
clientRequestId: "cloudflare-credentials",
}).pipe(Effect.forkChild);
const pairCard = yield* Effect.gen(function* () {
while (true) {
const projection = yield* orchestrator.getThreadProjection(parentThreadId);
const found = projection.turnItems.find(
(item) =>
item.type === "secret_request" &&
item.secretStatus === "pending" &&
item.label === "Cloudflare API credentials",
);
if (found?.type === "secret_request") return found;
yield* Effect.sleep("5 millis");
}
});
expect(pairCard.fields?.map((field) => field.name)).toEqual([
"account_id",
"api_token",
]);
yield* secretRequests.answer({
threadId: parentThreadId,
turnItemId: pairCard.id,
answer: {
type: "save",
secrets: { account_id: "acct-77", api_token: "cf-token-88" },
},
});
const pairCall = yield* Fiber.join(pairFiber);
expect(pairCall.isError).toBe(false);
const pairResult = pairCall.structuredContent as {
status: string;
files: Record<string, string>;
secretRef?: string;
};
expect(pairResult.status).toBe("saved");
expect(pairResult.secretRef).toBeUndefined();
expect(yield* secretFileSystem.readFileString(pairResult.files.account_id!)).toBe(
"acct-77",
);
expect(yield* secretFileSystem.readFileString(pairResult.files.api_token!)).toBe(
"cf-token-88",
);
const pairReceived = [
...pairCall.content.map((part) => ("text" in part ? part.text : "")),
JSON.stringify(pairResult),
JSON.stringify(yield* orchestrator.getThreadProjection(parentThreadId)),
].join("\n");
expect(pairReceived).not.toMatch(/acct-77|cf-token-88/);

const delegatedCall = yield* invoke("delegate_task", {
task: delegatedPrompt,
target: {
Expand Down Expand Up @@ -4310,6 +4383,11 @@ describe("orchestrator MCP toolkit", () => {
SecretRequests.layer.pipe(
Layer.provide(layerMemorySecretStore),
Layer.provide(layerOrchestration),
Layer.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-secret-requests-" }).pipe(
Layer.provide(NodeServices.layer),
),
),
),
),
Layer.provide(
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", {

const RequestSecretTool = Tool.make("request_secret", {
description:
"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.",
"Ask the user for ANY secret (a token, API key, password, login, signing secret) through a private card in this thread, and wait for them to answer. Use fields to ask for several values on one card, e.g. an ID and a key. The value is never in the result or the transcript: when saved, files maps each field name ('secret' without fields) to a private file on this environment holding exactly that value. Use it without printing it or reading it into your reply, e.g. TOKEN=\"$(cat <path>)\" cmd, bao kv put <path> <field>=- < <path>, or rbw add (reads stdin), then delete the files with rm. Unused files expire after 24 hours. A single-field request also returns a secretRef for a tool that accepts one (schedule_task's signature.secretRef); it works once. Never ask for secrets in chat, and never invent one.",
parameters: McpRouting.parameters(OrchestratorMcpRequestSecretInput),
success: OrchestratorMcpRequestSecretResult,
failure: OrchestratorMcpFailure,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8786,6 +8786,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
label: command.label,
reason: command.reason,
...(command.placeholder === undefined ? {} : { placeholder: command.placeholder }),
...(command.fields === undefined ? {} : { fields: command.fields }),
secretStatus: command.secretStatus,
};
yield* emit(
Expand Down
Loading
Loading