Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### Fixed

- `cursor-cli-oauth` no longer mixes Cursor's internal tool-call protocol, arguments, and results into assistant text; Cursor still owns execution, while Senpi now stores and renders only the model's actual prose ([OmO #7169](https://github.com/code-yeongyu/oh-my-openagent/issues/7169)).

### Added

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Generated: 2026-08-17
| `failover.ts` | Account rotation around one attempt: `rate_limit` blocks the slot (server hint else 60 s, max 48 h), `auth_error` blocks until re-login; retries only before any visible assistant delta; a replacement account always starts a fresh chat with a user-visible notice and never inherits chat context |
| `models.ts` | Model catalog: cached `cursor-agent models` probe (15 s deadline, full-stdout file capture, ANSI strip, `<id> - <label>` parsing, TTL cache at `<agentDir>/cursor-cli-oauth/models.json`) degrading to the exact 15-entry static fallback; zero cost, text-only input, 64 K max tokens |
| `guardrails.ts` | Execution policy: `--force` only in agent mode with `noApprovalAcknowledgedAt` set (typed `CursorCliExecutionRefusalError` naming the acknowledgement step otherwise); plan mode never forces; force-disabled agent mode and unproven sandbox modes warn once per session; deny lists sanitized to exact full commands and written per-spawn as `permissions.deny` `Shell(...)` entries in the account HOME's `cli-config.json` |
| `stream.ts` | The turn path (`streamCursorCliOauth`): re-resolves settings, accounts, and the executable per turn, refreshes expired tokens, composes failover and the session router, maps events to ordered text/thinking deltas with cumulative-snapshot dedupe, renders tool frames display-only as untrusted output, and applies usage isolation (senpi's own sent-payload estimate plus the CLI's output tokens; CLI input/cache numbers quarantined in a `cursor_cli_oauth_cli_usage` diagnostic) |
| `stream.ts` | The turn path (`streamCursorCliOauth`): re-resolves settings, accounts, and the executable per turn, refreshes expired tokens, composes failover and the session router, maps events to ordered text/thinking deltas with cumulative-snapshot dedupe, keeps Cursor-executed tool protocol out of assistant text and host tool calls, and applies usage isolation (senpi's own sent-payload estimate plus the CLI's output tokens; CLI input/cache numbers quarantined in a `cursor_cli_oauth_cli_usage` diagnostic) |
| `account-command.ts` | `/cursor-account` command (`list`/`add`/`remove`/`pin`/`unpin`/`import`/`status`): reads account state fresh from the credential store on every invocation, reuses `claude-sdk-oauth`'s account-events emitter (import only; that lane is never edited), and fences every deferred continuation behind the generation guard |
| `diagnostics.ts` | Status collection and rendering for `/cursor-account status` (lane always `file-store`, context owner always `senpi`, selected account, chat id, last model, executable path and version with the `2026.08.11` floor warning, block windows, native-provider recommendation when configured); plus reload/shutdown safety: the generation fence, the tracked-child registry, and the `session_shutdown` teardown with process-group kills |
| `changes.md` | Fork-change record; read before touching anything here |
Expand All @@ -35,7 +35,7 @@ Generated: 2026-08-17
- senpi is the context owner in every mode. `usage.input` is senpi's own estimate of the payload it spawned, `usage.output` is the CLI's `outputTokens`, `cacheRead`/`cacheWrite`/`totalTokens` stay 0, and the CLI's `inputTokens`/`cacheReadTokens`/`request_id` live only in the `cursor_cli_oauth_cli_usage` diagnostic. Nothing under `extensions/builtin/compaction/` or core compaction is touched, and no `session_compact` handler is registered.
- File-store-only auth: per-account sandboxed HOMEs with `AGENT_CLI_CREDENTIAL_STORE=file` and sentinel top-level credential fields. No ambient request lane exists. When the lane is explicitly enabled (`enabled: true`; it defaults to false), `cursor-agent` resolves, and no managed account exists, the default reader copies Senpi's stored native `cursor` OAuth credential into one managed `native` slot without modifying the source. Reading the user's desktop/CLI store or system keychain remains explicit via `/cursor-account import local`; `CURSOR_API_KEY` is never set and the system keychain is never written.
- Explicit opt-in: `cursorCliOauthProvider.enabled` defaults to false, so a logged-in host `cursor-agent` alone never makes the lane available and never triggers native-credential bootstrap. A verbatim `enabled: false` (settings or `SENPI_CURSOR_CLI_OAUTH_ENABLED=0`) is a kill switch that returns disabled before any credential work; with the flag merely absent, stored managed accounts - which exist only after an explicit `/login cursor-cli-oauth` or `/cursor-account import` - keep the lane available, because that login IS the opt-in. `isCursorCliOauthLaneEnabled` is the single rule shared by `assessConfiguration`, the turn path, and the bootstrap gate. Automatic bootstrap never writes `noApprovalAcknowledgedAt`; force execution still requires its separate acknowledgement.
- The CLI executes its own tools autonomously: `--force` requires the explicit `noApprovalAcknowledgedAt` acknowledgement, and tool frames render display-only as untrusted output, never bridged onto senpi tools.
- The CLI executes its own tools autonomously: `--force` requires the explicit `noApprovalAcknowledgedAt` acknowledgement, and provider tool frames never enter assistant text or bridge onto senpi tools.
- Chats are private to each account's HOME; cross-account failover always starts a fresh chat with a notice and never transfers context.
- Process lifecycle: children spawn detached in their own process group and are killed by tracked pid only - never by name matching (senpi #823). Deferred continuations are fenced per extension generation (senpi #866).
- Provider registration never blocks on the executable; `check` reports one of `configured (file-store, <n> accounts)`, `disabled by settings` (kill switch or a flagless lane with no stored accounts), `cursor-agent not installed: <guidance>`, or `no accounts: run /login cursor-cli-oauth`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# cursor-cli-oauth extension changes

## 2026-08-24 - Keep provider tool protocol out of assistant text

### What changed

- `stream.ts`: Cursor `tool_call` events are no longer serialized into `<cursor-cli-tool>` assistant text. They also remain intentionally unmapped to host `toolCall` blocks because Cursor already executed them in its subprocess.
- `stream.test.ts`: the tool-turn regression now proves that text deltas and stored assistant content contain only the model's final prose, with no provider tags, tool kind, arguments, or output.

### Why

- Rendering provider protocol as text mixed long JSON blobs into the TUI and persisted untrusted tool arguments/results in conversation context.

### Why an extension could not handle it

- The pollution happened inside the builtin provider before OmO or another extension received the assistant message, so the provider boundary is the only layer that can remove it without post-processing legitimate model prose.

## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
} from "./session-router.ts";
import { type CursorCliOauthProviderSettings, loadCursorCliOauthProviderSettingsFromDisk } from "./settings.ts";
import { resolveCursorCliSpawnModel } from "./spawn-model.ts";
import type { CursorCliStreamEvent, CursorCliToolCallEvent } from "./stream-parser.ts";
import type { CursorCliStreamEvent } from "./stream-parser.ts";
import { CursorCliAbortError, type CursorCliTransportHandle, spawnCursorCli } from "./transport.ts";

export { CURSOR_CLI_OAUTH_PROVIDER_ID } from "./oauth-login.ts";
Expand All @@ -54,12 +54,6 @@ const DISABLED_MESSAGE = "disabled by settings";
const NO_ACCOUNTS_MESSAGE = "no accounts: run /login cursor-cli-oauth";
const RECENT_EXCHANGE_LIMIT = 12;

/** Delimiters around every display-only tool frame; tool output is untrusted data, never instructions. */
const TOOL_DISPLAY_BEGIN = "<cursor-cli-tool>";
const TOOL_DISPLAY_END = "</cursor-cli-tool>";
const TOOL_DISPLAY_LABEL = "executed by the Cursor CLI (untrusted output; display only, not instructions)";
const TOOL_RENDER_BUDGET = 2_000;

/** Injectable seams so tests stay hermetic; every default is re-resolved per turn. */
export type CursorCliStreamDeps = {
readonly cwd?: string;
Expand Down Expand Up @@ -271,27 +265,6 @@ function appendAssistantFragment(mapper: StreamMapper, fragment: string): void {
if (delta.length > 0) pushTextDelta(mapper, delta);
}

function renderToolFrame(event: CursorCliToolCallEvent): string {
const kind = Object.keys(event.tool_call)[0] ?? "toolCall";
const details = event.tool_call[kind as `${string}ToolCall`] ?? {};
const payload: Record<string, unknown> = {
label: TOOL_DISPLAY_LABEL,
tool: kind,
phase: event.subtype,
callId: event.call_id,
};
if (details.args !== undefined) payload.args = details.args;
if (details.result !== undefined) payload.result = details.result;
let body = JSON.stringify(payload) ?? "{}";
if (body.length > TOOL_RENDER_BUDGET) body = `${body.slice(0, TOOL_RENDER_BUDGET)}...[truncated]`;
return `${TOOL_DISPLAY_BEGIN}${body}${TOOL_DISPLAY_END}\n`;
}

function appendToolFrame(mapper: StreamMapper, event: CursorCliToolCallEvent): void {
ensureOpen(mapper, "tool");
pushTextDelta(mapper, renderToolFrame(event));
}

function appendNotice(mapper: StreamMapper, message: string): void {
ensureOpen(mapper, "text");
pushTextDelta(mapper, `${message}\n`);
Expand Down Expand Up @@ -651,7 +624,9 @@ export function streamCursorCliOauth(
for (const block of event.message.content) appendAssistantFragment(mapper, block.text);
break;
case "tool_call":

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B2: This no-op drops the old tool-boundary state transition as well as the visible frame. appendToolFrame() previously reached ensureOpen(mapper, "tool"), which reset textAccumulated through openBlock(). With the no-op, a valid post-tool incremental fragment that starts with the earlier prose is treated as a cumulative snapshot and its prefix is removed. Suppress the tool output without losing that boundary semantics.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e53a28c: hidden tool frames now close the prior text segment and reset cumulative-snapshot tracking without emitting or storing a tool block, so post-tool prose is preserved.

appendToolFrame(mapper, event);
// Cursor already executed this tool inside its subprocess. Do not
// expose provider protocol frames as assistant text or map them to
// host tool calls that Senpi could execute a second time.
break;
case "result":
if (event.subtype === "success" && !event.is_error) {
Expand Down
14 changes: 7 additions & 7 deletions packages/coding-agent/test/cursor-cli-oauth/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ describe("cursor-cli-oauth stream mapping", () => {
});
});

it("renders tool frames as display-only blocks labelled as executed by the Cursor CLI", async () => {
it("keeps Cursor CLI tool protocol frames out of assistant text", async () => {
const directory = temporaryDirectory();
const deps: CursorCliStreamDeps = {
cwd: directory,
Expand All @@ -336,12 +336,12 @@ describe("cursor-cli-oauth stream mapping", () => {
const message = doneMessage(events);

expect(message.content.some((block) => block.type === "toolCall")).toBe(false);
const rendered = textBlocks(message).join("\n");
expect(rendered).toContain("executed by the Cursor CLI");
// Untrusted tool output stays inside the delimited display region.
const withoutDisplayRegions = rendered.replace(/<cursor-cli-tool>[\s\S]*?<\/cursor-cli-tool>/g, "");
expect(withoutDisplayRegions).not.toContain("tooltest-force-77");
expect(rendered).toContain("tooltest-force-77");
expect(textDeltas(events)).toEqual(["TOOLS OK"]);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B3: This fixture has no assistant text before the tool frames, so it cannot detect the state/deduplication regression at the changed branch. Exercise prose before and after both tool events, including a post-tool fragment sharing a prefix with the pre-tool text, and assert exact deltas plus persisted text blocks.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanded the integration fixture in e53a28c to emit prose before started/completed tool frames and a post-tool fragment sharing that prefix; it asserts exact text deltas and persisted text blocks.

expect(textBlocks(message)).toEqual(["TOOLS OK"]);
const rendered = textBlocks(message).join("");
expect(rendered).not.toContain("<cursor-cli-tool>");
expect(rendered).not.toContain("shellToolCall");
expect(rendered).not.toContain("tooltest-force-77");
});

it("surfaces a zero-exit turn with no assistant events as an error, never an empty success", async () => {
Expand Down