Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/redact-plan-confirm-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@call-e/cli": patch
---

Add opt-in `--redact-confirm-token` for `call plan` and let `call run --plan-id` reuse the private cache.
11 changes: 8 additions & 3 deletions packages/cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ subcommand are rejected instead of being silently ignored.
| `calle mcp config` | Print MCP client configuration JSON. | None |
| `calle mcp tools` | List tools from the configured MCP server. | None |
| `calle mcp call <tool-name>` | Call an arbitrary MCP tool. | `<tool-name>` |
| `calle call plan` | Plan a phone call through `plan_call`. | `--to-phone`, `--goal` |
| `calle call plan` | Plan a phone call through `plan_call`. Prints `confirm_token` by default. `--redact-confirm-token` (or `CALLE_REDACT_CONFIRM_TOKEN=1`) hides it, sets `has_confirm_token`, and stores the pair in the private cache. | `--to-phone`, `--goal` |
| `calle call start` | Plan and run a phone call without printing confirmation data. | `--to-phone`, `--goal` |
| `calle call run` | Run a planned phone call, then fetch status once. | `--plan-id`, `--confirm-token` |
| `calle call run` | Run a planned phone call, then fetch status once. `--confirm-token` is required unless `call plan --redact-confirm-token` stored that `plan_id`. Cache hits report `confirm_token_source: "private_cache"`. | `--plan-id`, `--confirm-token` |
| `calle call recover` | Safely repeat an uncertain `run_call` with its original private confirmation data. | `--recovery-id` |
| `calle call status` | Query a call run through `get_call_run`. | `--run-id` |
| `calle regions list` | Print the supported regions and languages documentation URL. | None |
Expand Down Expand Up @@ -264,13 +264,18 @@ network requests or output.
| `--language` | Text | None | `call plan`, `call start` | No | No | Language hint passed to `plan_call`. Only provide when explicitly known. | `calle call plan --to-phone +15551234567 --goal "Confirm" --language English` |
| `--region` | Text | None | `call plan`, `call start` | No | No | Region hint passed to `plan_call`. Only provide when explicitly known. | `calle call plan --to-phone +15551234567 --goal "Confirm" --region US` |
| `--timezone` | IANA timezone | System timezone | `call plan`, `call start`, `call run`, `call recover`, `call status` | No | No | Adds planning timezone metadata for planning commands and localizes returned call timestamps for run/status commands. | `calle call status --run-id run_123 --timezone Asia/Shanghai` |
| `--redact-confirm-token` | Boolean | `false`; `CALLE_REDACT_CONFIRM_TOKEN=1` | `call plan` | No | No | Hide `confirm_token` in `call plan` stdout, set `has_confirm_token`, and store `plan_id` + `confirm_token` in the private recovery cache (`0600`). Default remains printed so skill `call plan` → `call run` keeps working. | `calle call plan --to-phone +15551234567 --goal "Confirm" --redact-confirm-token` |
| `--plan-id` | Text | None | `call run` | Yes | No | Planned call ID returned by `plan_call`. Preserve exactly. | `calle call run --plan-id plan_123 --confirm-token token_123` |
| `--confirm-token` | Text | None | `call run` | Yes | No | Execution confirmation token returned by `plan_call`. Preserve exactly. | `calle call run --plan-id plan_123 --confirm-token token_123` |
| `--confirm-token` | Text | None | `call run` | Yes unless cached | No | Execution confirmation token returned by `plan_call`. Omit only when `call plan --redact-confirm-token` stored that `plan_id`. | `calle call run --plan-id plan_123 --confirm-token token_123` |
| `--recovery-id` | Opaque text | None | `call recover` | Yes | No | Private-cache lookup ID returned when `run_call` has an uncertain outcome. Use only with the returned recovery command. | `calle call recover --recovery-id <recovery_id>` |
| `--run-id` | Text | None | `call status` | Yes | No | Call run ID returned by `run_call` or `call start`. | `calle call status --run-id run_123` |
| `--cursor` | Text | None | `call status` | No | No | Pagination cursor for `get_call_run` activity entries. | `calle call status --run-id run_123 --cursor cursor_123` |
| `--limit` | Positive integer | None | `call status` | No | No | Maximum number of activity entries to request. | `calle call status --run-id run_123 --limit 20` |

`CALLE_REDACT_CONFIRM_TOKEN=1` is the environment equivalent of
`--redact-confirm-token` on `call plan`, following the same override order as
`CALLE_TIMEZONE` for `--timezone`: an explicit flag wins.

## Telemetry Options

The CLI sends best-effort usage telemetry for setup, auth, and MCP readiness
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/lib/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";

import {
parseIsoDate,
readJson,
serverHash,
writePrivateJson,
Expand Down Expand Up @@ -71,6 +72,50 @@ export function removeCallRecovery(config, recoveryId) {
}
}

function planConfirmCacheId(planId) {
return crypto.createHash("sha256").update(String(planId), "utf8").digest("base64url");
}

export function writePlanConfirm(config, { planId, confirmToken, expiresAt = null }) {
if (typeof planId !== "string" || !planId.trim() || typeof confirmToken !== "string" || !confirmToken.trim()) {
throw new TypeError("Invalid plan confirmation cache record");
}
writePrivateJson(callRecoveryCachePath(config.cacheRoot, config.serverUrl, planConfirmCacheId(planId.trim())), {
schema_version: 1,
created_at: new Date().toISOString(),
plan_id: planId.trim(),
confirm_token: confirmToken.trim(),
timezone: null,
expires_at: typeof expiresAt === "string" && expiresAt.trim() ? expiresAt.trim() : null,
});
}

export function readPlanConfirm(config, planId) {
if (typeof planId !== "string" || !planId.trim()) {
return null;
}
const record = readJson(callRecoveryCachePath(config.cacheRoot, config.serverUrl, planConfirmCacheId(planId.trim())));
if (
record?.schema_version !== 1
|| record.plan_id !== planId.trim()
|| typeof record.confirm_token !== "string"
|| !record.confirm_token
) {
return null;
}
if (record.expires_at !== null && record.expires_at !== undefined && record.expires_at !== "") {
const expiresAt = parseIsoDate(record.expires_at);
if (!expiresAt || Date.now() >= expiresAt.getTime()) {
return null;
}
}
return {
planId: record.plan_id,
confirmToken: record.confirm_token,
expiresAt: typeof record.expires_at === "string" && record.expires_at ? record.expires_at : null,
};
}

export function removeCallRecoveries(config) {
const recoveryDir = callRecoveryCacheDir(config.cacheRoot, config.serverUrl);
const existed = fs.existsSync(recoveryDir);
Expand Down
148 changes: 140 additions & 8 deletions packages/cli/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
readCallRecovery,
readJson,
readPendingLogin,
readPlanConfirm,
removeCallRecoveries,
removeCallRecovery,
removeFile,
removeTokenCache,
tokenCachePath,
tokenIsUsable,
writeCallRecovery,
writePlanConfirm,
} from "./cache.js";
import {
DEFAULT_BASE_URL,
Expand Down Expand Up @@ -157,6 +159,7 @@ const COMMAND_GROUPS = {
" --language <language> Optional language hint",
" --region <region> Optional region hint",
" --timezone <iana> Optional planning timezone metadata",
" --redact-confirm-token Hide confirm_token and store it in the private cache",
],
examples: [
`calle call plan --to-phone +15551234567 --goal "Confirm the appointment"`,
Expand All @@ -181,7 +184,7 @@ const COMMAND_GROUPS = {
usage: "calle call run --plan-id <id> --confirm-token <token> [options]",
options: [
" --plan-id <id> Required; plan ID returned by plan_call",
" --confirm-token <token> Required; confirmation token returned by plan_call",
" --confirm-token <token> Required unless a redacted plan is in the private cache",
" --timezone <iana> Local timezone for returned call timestamps",
],
examples: ["calle call run --plan-id plan_123 --confirm-token token_123"],
Expand Down Expand Up @@ -250,7 +253,7 @@ const COMMAND_OPTION_NAMES = {
"mcp config": new Set(),
"mcp tools": new Set(),
"mcp call": new Set(["args-json", "timezone"]),
"call plan": new Set(["to-phone", "goal", "language", "region", "timezone"]),
"call plan": new Set(["to-phone", "goal", "language", "region", "timezone", "redact-confirm-token"]),
"call start": new Set(["to-phone", "goal", "language", "region", "timezone"]),
"call run": new Set(["plan-id", "confirm-token", "timezone"]),
"call recover": new Set(["recovery-id", "timezone"]),
Expand Down Expand Up @@ -383,6 +386,7 @@ function parseOptions(argv) {
"telemetry",
"json",
"help",
"redact-confirm-token",
]);
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
Expand Down Expand Up @@ -491,6 +495,21 @@ function resolvePlanTimezone(options, env = process.env) {
return normalizeIanaTimezone(osTimezone());
}

function envFlagEnabled(env, key) {
const value = optionalEnvString(env, key);
if (!value) {
return false;
}
return ["1", "true", "yes", "on", "enabled"].includes(value.toLowerCase());
}

function resolveRedactConfirmToken(options, env = process.env) {
if (options.redactConfirmToken !== undefined) {
return Boolean(firstOptionValue(options.redactConfirmToken));
}
return envFlagEnabled(env, "CALLE_REDACT_CONFIRM_TOKEN");
}

function timezoneOffsetMinutes(timezone, instant = new Date()) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
Expand Down Expand Up @@ -971,6 +990,87 @@ function mcpSuccessPayload({ config, toolName = null, result, method = null }) {
};
}

function hasConfirmTokenValue(value) {
return typeof value === "string" && value.trim().length > 0;
}

function extractPlanConfirm(result) {
let planId = null;
let confirmToken = null;
let expiresAt = null;
const structured = recordObject(structuredPayload(result)) || {};
if (typeof structured.plan_id === "string" && structured.plan_id.trim()) {
planId = structured.plan_id.trim();
}
if (hasConfirmTokenValue(structured.confirm_token)) {
confirmToken = structured.confirm_token.trim();
}
if (typeof structured.confirm_expires_at === "string" && structured.confirm_expires_at.trim()) {
expiresAt = structured.confirm_expires_at.trim();
}
if (Array.isArray(result?.content)) {
for (const item of result.content) {
if (typeof item?.text !== "string") {
continue;
}
try {
const parsed = JSON.parse(item.text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
continue;
}
if (!planId && typeof parsed.plan_id === "string" && parsed.plan_id.trim()) {
planId = parsed.plan_id.trim();
}
if (!confirmToken && hasConfirmTokenValue(parsed.confirm_token)) {
confirmToken = parsed.confirm_token.trim();
}
if (!expiresAt && typeof parsed.confirm_expires_at === "string" && parsed.confirm_expires_at.trim()) {
expiresAt = parsed.confirm_expires_at.trim();
}
} catch {
// Content text is not JSON; structured fields already collected.
}
}
}
return { planId, confirmToken, expiresAt };
}

function redactPlanCallResult(result, token) {
const cloned = result && typeof result === "object" ? structuredClone(result) : result;
if (!cloned || typeof cloned !== "object") {
return cloned;
}
const structured = recordObject(cloned.structuredContent) || recordObject(cloned.structured_content);
if (structured) {
structured.has_confirm_token = Boolean(token);
delete structured.confirm_token;
}
if (Array.isArray(cloned.content)) {
cloned.content = cloned.content.map((item) => {
if (!item || typeof item.text !== "string") {
return item;
}
let text = item.text;
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const nestedToken = hasConfirmTokenValue(parsed.confirm_token) ? parsed.confirm_token.trim() : token;
parsed.has_confirm_token = Boolean(nestedToken || token);
delete parsed.confirm_token;
text = JSON.stringify(parsed);
}
} catch {
// Fall through to string redaction when content is not JSON.
}
if (token && text.includes(token)) {
text = text.split(token).join("");
}
return { ...item, text };
});
}
return cloned;
}

function buildPlanArguments(options) {
const toPhones = optionValues(options.toPhone)
.map((value) => String(value).trim())
Expand Down Expand Up @@ -1012,11 +1112,23 @@ function buildPlanRequestMeta(options, env = process.env) {
return meta;
}

function buildRunArguments(options) {
return {
plan_id: requireStringOption(options, "planId", "--plan-id"),
confirm_token: requireStringOption(options, "confirmToken", "--confirm-token"),
};
function buildRunArguments(options, config) {
const planId = requireStringOption(options, "planId", "--plan-id");
const explicitToken = optionalStringOption(options, "confirmToken");
if (explicitToken) {
return { plan_id: planId, confirm_token: explicitToken, confirm_token_source: null };
}
const cached = readPlanConfirm(config, planId);
if (cached?.confirmToken) {
return {
plan_id: planId,
confirm_token: cached.confirmToken,
confirm_token_source: "private_cache",
};
}
throw new InvalidArgumentsError(
"Missing required --confirm-token. After `calle call plan --redact-confirm-token`, `call run --plan-id` reads the token from the private cache."
);
}

function structuredPayload(result) {
Expand Down Expand Up @@ -1291,6 +1403,7 @@ async function writeRunCallSuccess({
runId,
statusTimezone,
includeRunResult,
confirmTokenSource = null,
}) {
const { statusResult, statusError } = await fetchCallStatusBestEffort({ config, deps, runId });
if (statusResult) {
Expand All @@ -1309,6 +1422,7 @@ async function writeRunCallSuccess({
status_query_succeeded: statusError === null,
status_result: statusResult,
...(statusError ? { status_error: statusError } : {}),
...(confirmTokenSource ? { confirm_token_source: confirmTokenSource } : {}),
...callStatusCommand(config, runId, statusTimezone),
});
}
Expand Down Expand Up @@ -1378,6 +1492,23 @@ async function handleCallCommand({ command, positional, options, config, deps, s
callStarted: false,
retrySafe: true,
});
const env = deps.env || process.env;
if (resolveRedactConfirmToken(options, env)) {
const extracted = extractPlanConfirm(result);
if (extracted.planId && extracted.confirmToken) {
writePlanConfirm(config, {
planId: extracted.planId,
confirmToken: extracted.confirmToken,
expiresAt: extracted.expiresAt,
});
}
writeJson(stdout, mcpSuccessPayload({
config,
toolName,
result: redactPlanCallResult(result, extracted.confirmToken),
}));
return 0;
}
writeJson(stdout, mcpSuccessPayload({ config, toolName, result }));
return 0;
}
Expand Down Expand Up @@ -1444,7 +1575,7 @@ async function handleCallCommand({ command, positional, options, config, deps, s

if (command === "run") {
const statusTimezone = resolvePlanTimezone(options, deps.env || process.env);
const runArguments = buildRunArguments(options);
const runArguments = buildRunArguments(options, config);
const { runResult, runId } = await runPlannedCall({
config,
deps,
Expand All @@ -1461,6 +1592,7 @@ async function handleCallCommand({ command, positional, options, config, deps, s
runId,
statusTimezone,
includeRunResult: true,
confirmTokenSource: runArguments.confirm_token_source,
});
return 0;
}
Expand Down
Loading