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
7 changes: 7 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ tend cli action:verify --feed <feed-id> --work <work-id> --token <capability-tok
Repeat claim until it returns the idle handshake. An active claimed item also appears in `work:list`
for its own lane and is replayed by `work:claim`, so restart recovery stays simple and visible.

Tend's approval receipt records the local app decision. It cannot override a connector's rejection
of that approval source. Stop retries, preserve the blocked work, and use the connector or host's
trusted confirmation flow. During a Tend-owned Codex drain, a supported, exactly correlated host
question appears above the feed for a fresh human response. Do not answer that panel on the user's
behalf. A terminal denial with no native request stays blocked. See [the approval boundary](docs/approval-boundary.md)
for supported requests and the remaining host integration required for one-click approval.

Before Codex claims a mistaken dictated note, correct it with `work:edit` or return its card to the
sweep with `card:return-to-review`. Returning a queued card cancels its unstarted local work. A done
card can be returned for another review pass, but this does not reverse an external action that
Expand Down
94 changes: 94 additions & 0 deletions docs/approval-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Tend Approval And Connector Authorization

Status: recipient reporting and bounded native confirmation transport are implemented. A Tend click
is still not connector-attested authorization. Real-host Gmail acceptance remains unverified.

## What Failed

An exact, later Tend click can pass `action:verify` and still be rejected by a connector that requires
authorization from its trusted user interface. Repeating the same call with more emphatic receipt
text does not provide new authorization. The approval digest verifies Tend's selected action and
artifact; it is not a connector-issued approval token.

`work:claim` returns the receipt as tool output. `server/codexAppServer.ts` submits the drain prompt
as ordinary `turn/start` text. Neither path promotes an earlier Tend click to trusted host input.
The inspected Gmail send tool accepts message fields but no Tend receipt or approval-token parameter.

Receipts therefore identify `scope: tend_workflow` and `connectorAuthorization: not_attested`.
The retained `noSecondChatConfirmationNeeded` field describes Tend's local decision only. It does
not waive a connector's own approval requirement or override a denial.

If the connector rejects the approval source, stop retries and preserve the work as blocked with
the exact reason. Present the required confirmation through the connector or host's trusted user
interface. After a later trusted confirmation, recheck source state, duplicates, and `action:verify`.
Reconcile an already successful blocked action using its authoritative receipt rather than sending
again. Do not change approval policies or use a different execution channel to route around a denial.

## Recipient Reporting

Recipient summaries use addresses named in the selected action and any leading outbound To/Cc/Bcc
header block in its editable draft. Folded headers are supported. Source-email blocks, body text,
signatures, quoted senders, and historical To/Cc headers do not become destinations. The exact full artifact remains bound to
the approval digest; this change does not remove quoted content from what the user approves.

The parser is a display and confirmation aid, not an email transport or authorization parser.
Operators must put the actual outbound destinations in the action instruction or leading envelope.
Unknown destinations must be resolved from authoritative context before execution, never inferred
from arbitrary addresses in the body. Conflicting action and envelope recipients require review.

## Native Confirmation Handoff

During a Tend-owned Codex drain, supported `item/tool/requestUserInput` requests can now appear above
the feed. The panel displays the actual tool arguments and the host's exact questions and options.
Nothing is preselected. Only a new human response is sent back on that same JSON-RPC request; a
previous card click or receipt never supplies the answer. No generic CLI or MCP approval command is
provided. Browser responses use the existing mutation protections and an exact request digest.

The supported request must carry an explicit item ID matching an observed MCP tool call in the same
thread and active turn. The feed must have exactly one currently verified, claimed Tend action for
that task. The pending confirmation binds both snapshots. Changed arguments, card, mailbox, action,
or work state invalidate it. Request resolution, item/turn completion, transport loss, server shutdown,
and a five-minute expiry discard pending responses. Duplicate responses cannot be replayed.

Only one to three nonsecret choice questions are supported. MCP elicitation currently lacks a
guaranteed tool-item association, so it receives the protocol-correct `{ action: "decline", content:
null }` response. Nearest-call heuristics and arbitrary metadata are not accepted as correlation.
Other unsupported prompts are safely declined using their own response shapes. The client inherits
the host's approval policy instead of forcing `never`; it does not weaken the managed policy.

`native_confirmation.response_recorded` audits the local response digest, not connector acceptance
or delivery. A native response is never persisted for retry. If transport fails after a response,
reconcile authoritative source state before any new mutation. A terminal connector denial without
a pending native request cannot be reopened by this panel: keep it blocked and use the host's trusted
confirmation flow. This feature neither enables auto-drain nor retries existing blocked actions.

## Remaining Host Integration

The [Codex App Server approval documentation](https://learn.chatgpt.com/docs/app-server#approvals)
describes server-initiated approval requests and client responses. App tool calls may require user
input, and MCP elicitation requests carry a request identity and expected response shape. These
flows are distinct from embedding a receipt in prompt text. The inspected protocol and documentation
do not establish a way to attest an arbitrary Tend click to Gmail's authorization reviewer.

To deliver one approval click, the host must expose a trusted approval request that Tend can display
and answer for the exact operation. Required contract:

1. The host creates a pending operation with request, thread, turn, tool, and authenticated account
identity, plus exact To/Cc/Bcc, reply/source identity, subject, body, attachments, and content digest.
2. Tend displays that operation and binds the human click to the host's request identity. Background
work, source content, receipt prose, and agents cannot mint an equivalent approval.
3. The host consumes the correlated response once. Any changed content, recipient, mailbox, source
context, expiration, or consumed request rejects the response and requests fresh review.
4. Tend records connector acceptance and an authoritative result separately from its local approval.
A timeout requires outcome reconciliation before any retry.

The transport above implements a bounded subset of this contract when the host supplies a correlated
choice request. It does not attest an earlier Tend approval or establish that Gmail emits such a
request. Do not auto-answer arbitrary user-input or MCP requests, invent provenance metadata, or treat
host attestation for another purpose as proof of user consent.

Validation must show an actual host approval accepted once, text-only and forged receipts rejected,
changed snapshots rejected, and interrupted operations reconciled without duplicate sends. Local
digest tests alone cannot demonstrate connector acceptance. Protocol fixtures verify transport,
correlation, cancellation, expiry, changed arguments and local state, duplicate suppression, and
failure handling. They do not send email and are not a real-host Gmail acceptance test.
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DrainDispatcher } from "./server/dispatcher";
import { loadMobileCloudEnvFile, mobileCloudConfigFromEnv, SupabaseMobileCloudClient } from "./server/mobile/client";
import { MobileSyncWorker } from "./server/mobile/sync";
import { makeToken } from "./server/util";
import { NativeApprovalBroker } from "./server/nativeApprovals";

declare const Bun: {
serve(options: { port: number; hostname: string; idleTimeout: number; fetch: (...args: any[]) => any }): { stop(force?: boolean): void };
Expand All @@ -29,7 +30,8 @@ const mutationToken = process.env.ATTENTION_MUTATION_TOKEN ?? makeToken();
const realtime = createRealtimeHub();
const feedEventBridge = createFeedEventBridge(store, realtime.notify);
await feedEventBridge.start();
const drainDispatcher = new DrainDispatcher(store, { appRoot: root, runtimeRoot });
const nativeApprovals = new NativeApprovalBroker(store, () => realtime.notify({ changedAt: new Date().toISOString() }));
const drainDispatcher = new DrainDispatcher(store, { appRoot: root, runtimeRoot, nativeApprovals });
if (process.env.ATTENTION_AUTODRAIN === "1") drainDispatcher.start();
const mobileConfig = mobileCloudConfigFromEnv();
const mobileSync = mobileConfig
Expand All @@ -44,6 +46,7 @@ app.route("/", apiRoutes({
domain,
mobileStatus: () => mobileSync?.currentStatus() ?? { enabled: false },
mutationToken,
nativeApprovals,
notify: realtime.notify,
port,
root,
Expand All @@ -65,6 +68,7 @@ console.log(`Tend API listening on http://127.0.0.1:${port}`);
export function closeServer() {
mobileSync?.stop();
drainDispatcher.stop();
nativeApprovals.close();
feedEventBridge.stop();
server.stop(true);
}
Loading
Loading