codex-computer-use-mcp 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/ARCHITECTURE.md +150 -0
  2. package/CONTRIBUTING.md +16 -10
  3. package/MIGRATION.md +94 -0
  4. package/PROOF.md +128 -54
  5. package/README.md +97 -115
  6. package/SECURITY.md +45 -22
  7. package/dist/audit.d.ts +15 -14
  8. package/dist/audit.js +1 -1
  9. package/dist/audit.js.map +1 -1
  10. package/dist/config.d.ts +8 -8
  11. package/dist/config.js +8 -103
  12. package/dist/config.js.map +1 -1
  13. package/dist/direct-broker.d.ts +71 -0
  14. package/dist/direct-broker.js +652 -0
  15. package/dist/direct-broker.js.map +1 -0
  16. package/dist/direct-service.d.ts +33 -0
  17. package/dist/direct-service.js +350 -0
  18. package/dist/direct-service.js.map +1 -0
  19. package/dist/lock.d.ts +1 -0
  20. package/dist/lock.js +32 -3
  21. package/dist/lock.js.map +1 -1
  22. package/dist/mcp-elicitation.d.ts +8 -0
  23. package/dist/mcp-elicitation.js +47 -0
  24. package/dist/mcp-elicitation.js.map +1 -0
  25. package/dist/mcp-server.js +43 -100
  26. package/dist/mcp-server.js.map +1 -1
  27. package/dist/system.d.ts +0 -1
  28. package/dist/system.js +12 -37
  29. package/dist/system.js.map +1 -1
  30. package/dist/tools.d.ts +84 -0
  31. package/dist/tools.js +262 -0
  32. package/dist/tools.js.map +1 -0
  33. package/docs/adr/0001-standalone-code-mode-mcp.md +45 -0
  34. package/integrations/pi/README.md +51 -27
  35. package/integrations/pi/index.ts +237 -125
  36. package/integrations/pi/mcp.json.example +3 -3
  37. package/npm-shrinkwrap.json +3275 -48
  38. package/package.json +41 -13
  39. package/dist/policy.d.ts +0 -42
  40. package/dist/policy.js +0 -428
  41. package/dist/policy.js.map +0 -1
  42. package/dist/prompt.d.ts +0 -62
  43. package/dist/prompt.js +0 -119
  44. package/dist/prompt.js.map +0 -1
  45. package/dist/runner.d.ts +0 -55
  46. package/dist/runner.js +0 -494
  47. package/dist/runner.js.map +0 -1
  48. package/dist/service.d.ts +0 -17
  49. package/dist/service.js +0 -382
  50. package/dist/service.js.map +0 -1
@@ -1,135 +1,247 @@
1
- import crypto from "node:crypto";
2
1
  import path from "node:path";
3
2
  import { StringEnum } from "@earendil-works/pi-ai";
4
- import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { getAgentDir, truncateHead, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
4
  import { Text } from "@earendil-works/pi-tui";
6
- import { Type } from "typebox";
7
- import { appendAudit, type AuditRecord } from "../../dist/audit.js";
8
- import { ConfigError, loadConfig, saveConfig } from "../../dist/config.js";
9
- import { COMPUTER_USE_TOOLS, MODEL } from "../../dist/policy.js";
10
- import { executeOperation, getStatus } from "../../dist/service.js";
11
-
12
- const Params = Type.Object({
13
- mode: StringEnum(["list", "inspect", "act", "dictionary_lookup"] as const, {
14
- description: "safe mode permits only list; targeted modes require explicitly acknowledged broad full-permissions",
15
- }),
16
- app: Type.Optional(Type.String({ maxLength: 500, description: "App name, bundle identifier, or app-bundle path" })),
17
- task: Type.Optional(Type.String({ maxLength: 4000, description: "Concrete target-app task for act mode" })),
18
- query: Type.Optional(Type.String({ maxLength: 200, description: "Local word or phrase for dictionary_lookup" })),
19
- cleanup: Type.Optional(Type.Boolean({ description: "Restore transient state and verify cleanup" })),
20
- cleanup_instructions: Type.Optional(Type.String({ maxLength: 2000, description: "Optional cleanup postcondition" })),
21
- required_capabilities: Type.Optional(
22
- Type.Array(StringEnum(COMPUTER_USE_TOOLS), {
23
- maxItems: 8,
24
- description: "Official Computer Use methods that must genuinely be exercised",
25
- }),
26
- ),
27
- });
28
-
29
- export default function codexComputerUse(pi: ExtensionAPI) {
30
- const stateRoot = process.env.CODEX_COMPUTER_USE_HOME || path.join(getAgentDir(), "background-computer-use");
31
-
32
- pi.registerCommand("background-computer-use-status", {
33
- description: "Show permission mode, signed Computer Use surface, audit path, and approval boundary",
5
+ import { Type, type TSchema } from "typebox";
6
+ import { executeDirectTool, getDirectStatus } from "../../dist/direct-service.js";
7
+ import type {
8
+ DirectBrokerElicitationRequest,
9
+ DirectBrokerElicitationResponse,
10
+ } from "../../dist/direct-broker.js";
11
+ import { OFFICIAL_TOOL_METADATA, type DirectMethod } from "../../dist/tools.js";
12
+
13
+ const App = Type.String({ description: "App name, full app path, or unambiguous bundle identifier" });
14
+
15
+ const ToolParameters: Record<DirectMethod, TSchema> = {
16
+ list_apps: Type.Object({}, { additionalProperties: false }),
17
+ get_app_state: Type.Object({ app: App }, { additionalProperties: false }),
18
+ click: Type.Object({
19
+ app: App,
20
+ click_count: Type.Optional(Type.Integer({ description: "Number of clicks. Defaults to 1" })),
21
+ element_index: Type.Optional(Type.String({ description: "Element index to click" })),
22
+ mouse_button: Type.Optional(StringEnum(["left", "right", "middle"] as const, { description: "Mouse button to click. Defaults to left." })),
23
+ x: Type.Optional(Type.Number({ description: "X coordinate in screenshot pixel coordinates" })),
24
+ y: Type.Optional(Type.Number({ description: "Y coordinate in screenshot pixel coordinates" })),
25
+ }, { additionalProperties: false }),
26
+ perform_secondary_action: Type.Object({
27
+ app: App,
28
+ element_index: Type.String({ description: "Element identifier" }),
29
+ action: Type.String({ description: "Secondary accessibility action name" }),
30
+ }, { additionalProperties: false }),
31
+ set_value: Type.Object({
32
+ app: App,
33
+ element_index: Type.String({ description: "Element identifier" }),
34
+ value: Type.String({ description: "Value to assign" }),
35
+ }, { additionalProperties: false }),
36
+ select_text: Type.Object({
37
+ app: Type.String({ description: "App name or bundle identifier" }),
38
+ element_index: Type.String({ description: "Text element identifier" }),
39
+ text: Type.String({ description: "Target text as shown in the accessibility tree" }),
40
+ prefix: Type.Optional(Type.String({ description: "Optional text immediately before the target, used to disambiguate repeated matches" })),
41
+ selection: Type.Optional(StringEnum(["text", "cursor_before", "cursor_after"] as const, { description: "Whether to select the text or place the cursor before or after it. Defaults to text." })),
42
+ suffix: Type.Optional(Type.String({ description: "Optional text immediately after the target, used to disambiguate repeated matches" })),
43
+ }, { additionalProperties: false }),
44
+ scroll: Type.Object({
45
+ app: App,
46
+ element_index: Type.String({ description: "Element identifier" }),
47
+ direction: Type.String({ description: "Scroll direction: up, down, left, or right" }),
48
+ pages: Type.Optional(Type.Number({ description: "Number of pages to scroll. Fractional values are supported. Defaults to 1" })),
49
+ }, { additionalProperties: false }),
50
+ drag: Type.Object({
51
+ app: App,
52
+ from_x: Type.Number({ description: "Start X coordinate" }),
53
+ from_y: Type.Number({ description: "Start Y coordinate" }),
54
+ to_x: Type.Number({ description: "End X coordinate" }),
55
+ to_y: Type.Number({ description: "End Y coordinate" }),
56
+ }, { additionalProperties: false }),
57
+ press_key: Type.Object({ app: App, key: Type.String({ description: "Key or key combination to press" }) }, { additionalProperties: false }),
58
+ type_text: Type.Object({ app: App, text: Type.String({ description: "Literal text to type" }) }, { additionalProperties: false }),
59
+ };
60
+
61
+ function toolDescription(method: DirectMethod): string {
62
+ return OFFICIAL_TOOL_METADATA[method].description;
63
+ }
64
+
65
+ function titleFor(method: DirectMethod): string {
66
+ return method.split("_").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
67
+ }
68
+
69
+ const INSPECTION_METHODS = new Set<DirectMethod>(["list_apps", "get_app_state"]);
70
+ export const INSPECTION_TOOL_NAMES = [
71
+ "computer_use_list_apps",
72
+ "computer_use_get_app_state",
73
+ ] as const;
74
+ export const INTERACTION_TOOL_NAMES = [
75
+ "computer_use_click",
76
+ "computer_use_perform_secondary_action",
77
+ "computer_use_set_value",
78
+ "computer_use_select_text",
79
+ "computer_use_scroll",
80
+ "computer_use_drag",
81
+ "computer_use_press_key",
82
+ "computer_use_type_text",
83
+ ] as const;
84
+
85
+ export function setInitialComputerUseTools(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">): void {
86
+ const interactionTools = new Set<string>(INTERACTION_TOOL_NAMES);
87
+ const preserved = pi.getActiveTools().filter((name) => !interactionTools.has(name));
88
+ pi.setActiveTools([...new Set([...preserved, ...INSPECTION_TOOL_NAMES])]);
89
+ }
90
+
91
+ export function activateInteractionTools(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">): void {
92
+ const active = pi.getActiveTools();
93
+ pi.setActiveTools([...new Set([...active, ...INTERACTION_TOOL_NAMES])]);
94
+ }
95
+
96
+ function toPiContent(content: Array<Record<string, unknown>>): Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> {
97
+ const result: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [];
98
+ for (const block of content) {
99
+ if (block.type === "image") {
100
+ result.push({ type: "image", data: String(block.data), mimeType: String(block.mimeType) });
101
+ continue;
102
+ }
103
+ const original = String(block.text ?? "");
104
+ const truncated = truncateHead(original, { maxLines: 2_000, maxBytes: 50 * 1024 });
105
+ const suffix = truncated.truncated
106
+ ? `\n\n[Official Computer Use text truncated in-memory: ${truncated.outputLines}/${truncated.totalLines} lines, ${truncated.outputBytes}/${truncated.totalBytes} bytes. No full-output file was written.]`
107
+ : "";
108
+ result.push({ type: "text", text: `${truncated.content}${suffix}` });
109
+ }
110
+ return result;
111
+ }
112
+
113
+ function errorText(content: Array<Record<string, unknown>>): string {
114
+ return content
115
+ .filter((block) => block.type === "text")
116
+ .map((block) => String(block.text ?? ""))
117
+ .join("\n")
118
+ .slice(0, 2_000) || "Official Computer Use returned an error";
119
+ }
120
+
121
+ function isRecord(value: unknown): value is Record<string, unknown> {
122
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
123
+ }
124
+
125
+ function initialFormContent(schema: unknown): Record<string, unknown> {
126
+ if (!isRecord(schema) || !isRecord(schema.properties)) return {};
127
+ const content: Record<string, unknown> = {};
128
+ for (const [key, rawProperty] of Object.entries(schema.properties)) {
129
+ if (!isRecord(rawProperty)) continue;
130
+ if (rawProperty.default !== undefined) content[key] = rawProperty.default;
131
+ else if (Array.isArray(rawProperty.enum) && rawProperty.enum.length > 0) content[key] = rawProperty.enum[0];
132
+ else if (rawProperty.type === "boolean") content[key] = false;
133
+ else if (rawProperty.type === "number" || rawProperty.type === "integer") content[key] = 0;
134
+ else if (rawProperty.type === "array") content[key] = [];
135
+ else content[key] = "";
136
+ }
137
+ return content;
138
+ }
139
+
140
+ export async function handleOfficialElicitation(
141
+ request: DirectBrokerElicitationRequest,
142
+ ctx: { hasUI: boolean; ui: {
143
+ select(title: string, options: string[]): Promise<string | undefined>;
144
+ editor(title: string, prefill?: string): Promise<string | undefined>;
145
+ notify(message: string, type?: "info" | "warning" | "error"): void;
146
+ } },
147
+ openUrl: (url: string) => Promise<boolean>,
148
+ ): Promise<DirectBrokerElicitationResponse> {
149
+ if (!ctx.hasUI) return { action: "cancel" };
150
+ const message = typeof request.message === "string" ? request.message : "Official Computer Use requests input";
151
+
152
+ if (request.mode === "url") {
153
+ if (typeof request.url !== "string") return { action: "cancel" };
154
+ const choice = await ctx.ui.select(`${message}\n${request.url}`, ["Open URL", "Decline", "Cancel"]);
155
+ if (choice === "Decline") return { action: "decline" };
156
+ if (choice !== "Open URL") return { action: "cancel" };
157
+ if (!await openUrl(request.url)) {
158
+ ctx.ui.notify("Could not open the official Computer Use URL.", "error");
159
+ return { action: "cancel" };
160
+ }
161
+ return { action: "accept" };
162
+ }
163
+
164
+ if (request.mode !== undefined && request.mode !== "form" && request.mode !== "openai/form") {
165
+ ctx.ui.notify(`Official Computer Use sent an unsupported elicitation mode: ${request.mode}`, "warning");
166
+ return { action: "cancel" };
167
+ }
168
+ const openAiForm = request.mode === "openai/form";
169
+ if (request.requestedSchema === undefined || (!openAiForm && !isRecord(request.requestedSchema))) return { action: "cancel" };
170
+ const choice = await ctx.ui.select(message, ["Respond", "Decline", "Cancel"]);
171
+ if (choice === "Decline") return { action: "decline" };
172
+ if (choice !== "Respond") return { action: "cancel" };
173
+ let prefill = JSON.stringify(initialFormContent(request.requestedSchema), null, 2);
174
+ const title = `${message}\nSchema: ${JSON.stringify(request.requestedSchema)}`;
175
+ while (true) {
176
+ const edited = await ctx.ui.editor(title, prefill);
177
+ if (edited === undefined) return { action: "cancel" };
178
+ prefill = edited;
179
+ try {
180
+ const content = JSON.parse(edited);
181
+ if (!openAiForm && !isRecord(content)) throw new Error("Response must be a JSON object");
182
+ return { action: "accept", content };
183
+ } catch (error) {
184
+ ctx.ui.notify(error instanceof Error ? error.message : "Response must be valid JSON", "error");
185
+ }
186
+ }
187
+ }
188
+
189
+ export default function directComputerUse(pi: ExtensionAPI) {
190
+ const stateRoot = process.env.CODEX_COMPUTER_USE_HOME || path.join(getAgentDir(), "direct-computer-use");
191
+
192
+ pi.registerCommand("computer-use-status", {
193
+ description: "Show the direct-call architecture, durable no-permissions policy, signed broker, and private audit path",
34
194
  handler: async (_args, ctx) => {
35
- const status = await getStatus(stateRoot);
36
- ctx.ui.notify(JSON.stringify(status, null, 2), status.permissionMode === "full-permissions" ? "warning" : "info");
195
+ const status = await getDirectStatus(stateRoot);
196
+ ctx.ui.notify(JSON.stringify(status, null, 2), "info");
37
197
  },
38
198
  });
39
199
 
40
- pi.registerCommand("background-computer-use-mode", {
41
- description: "Show or explicitly change safe/default vs full-permissions mode",
42
- handler: async (args, ctx) => {
43
- const requested = args.trim();
44
- const current = await loadConfig(stateRoot);
45
- if (!requested) {
46
- ctx.ui.notify(`Current Computer Use permission mode: ${current.permissionMode}`, "info");
47
- return;
48
- }
49
- if (requested !== "safe" && requested !== "full-permissions") {
50
- throw new ConfigError("Usage: /background-computer-use-mode safe|full-permissions");
51
- }
52
- if (requested === current.permissionMode) {
53
- ctx.ui.notify(`Computer Use permission mode is already ${requested}.`, "info");
54
- return;
55
- }
56
- if (requested === "full-permissions") {
57
- if (!ctx.hasUI) throw new ConfigError("Enabling full-permissions requires an interactive Pi UI");
58
- const confirmed = await ctx.ui.confirm(
59
- "Enable FULL Computer Use permissions?",
60
- "This broadly enables targeted operations that safe mode refuses. Official OpenAI/macOS approvals and signing, post-dispatch validation, lock, focus, timeout, cleanup, and audit controls remain.",
61
- { timeout: 60_000 },
200
+ const methods = Object.keys(ToolParameters) as DirectMethod[];
201
+ for (const method of methods) {
202
+ const piName = `computer_use_${method}`;
203
+ const inspectionPromptMetadata = INSPECTION_METHODS.has(method) ? {
204
+ promptSnippet: `${titleFor(method)} through the official signed macOS Computer Use service`,
205
+ promptGuidelines: [
206
+ `${piName} is a direct typed tool: choose its arguments yourself; it does not invoke a nested planner or model.`,
207
+ `Call computer_use_get_app_state once per assistant turn before interacting with an app.`,
208
+ ],
209
+ } : {};
210
+ pi.registerTool({
211
+ name: piName,
212
+ label: titleFor(method),
213
+ description: toolDescription(method),
214
+ ...inspectionPromptMetadata,
215
+ parameters: ToolParameters[method] as any,
216
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
217
+ const response = await executeDirectTool(
218
+ { method, arguments: params as Record<string, unknown> },
219
+ {
220
+ stateRoot,
221
+ signal,
222
+ supportsOpenAiFormElicitation: true,
223
+ onElicitation: (request) => handleOfficialElicitation(
224
+ request,
225
+ ctx,
226
+ async (url) => {
227
+ const result = await pi.exec("/usr/bin/open", ["--", url], { signal, timeout: 15_000 });
228
+ return result.code === 0;
229
+ },
230
+ ),
231
+ onProgress: (message) => onUpdate?.({ content: [{ type: "text", text: message }], details: { status: "running" } }),
232
+ },
62
233
  );
63
- if (!confirmed) {
64
- ctx.ui.notify("Full-permissions mode was not enabled.", "info");
65
- return;
66
- }
67
- }
68
- await saveConfig(stateRoot, { version: 1, permissionMode: requested });
69
- const record: AuditRecord = {
70
- timestamp: new Date().toISOString(),
71
- runId: crypto.randomUUID(),
72
- operation: "configure",
73
- permissionMode: requested,
74
- app: null,
75
- mutating: true,
76
- cleanupRequested: false,
77
- userConfirmed: requested === "full-permissions",
78
- authorization: requested === "full-permissions" ? "full_permissions_config" : "none",
79
- inputBytes: 0,
80
- outcome: "ok",
81
- durationMs: 0,
82
- model: MODEL,
83
- usage: { input: 0, cachedInput: 0, output: 0 },
84
- computerUseCalls: 0,
85
- computerUseMethods: [],
86
- backgroundPreserved: null,
87
- cleanupVerified: null,
88
- };
89
- try {
90
- await appendAudit(stateRoot, record);
91
- } catch {
92
- await saveConfig(stateRoot, current);
93
- throw new ConfigError("Permission mode change was rolled back because secure audit logging failed");
94
- }
95
- ctx.ui.notify(`Computer Use permission mode set to ${requested}.`, requested === "full-permissions" ? "warning" : "info");
96
- },
97
- });
234
+ if (response.isError) throw new Error(errorText(response.content));
235
+ if (method === "get_app_state") activateInteractionTools(pi);
236
+ return { content: toPiContent(response.content), details: response.details };
237
+ },
238
+ renderCall(args, theme) {
239
+ const renderedArgs = args as Record<string, unknown>;
240
+ const app = typeof renderedArgs.app === "string" ? ` ${renderedArgs.app}` : "";
241
+ return new Text(theme.fg("toolTitle", theme.bold(`${piName} `)) + theme.fg("accent", app.trim()), 0, 0);
242
+ },
243
+ });
244
+ }
98
245
 
99
- pi.registerTool({
100
- name: "background_computer_use",
101
- label: "Background Computer Use",
102
- description:
103
- "Use the official signed Codex Computer Use broker for native macOS apps. Safe mode is list-only; explicit full-permissions broadly authorizes targeted operations but never bypasses first-party approvals or technical controls. Calls consume separate Codex usage.",
104
- promptSnippet: "Inspect or operate a native macOS app in the background through signed Codex Computer Use",
105
- promptGuidelines: [
106
- "Use background_computer_use when the user asks to inspect or operate a native macOS app in the background.",
107
- "Set background_computer_use required_capabilities when the user explicitly needs click, secondary action, set value, select text, scroll, drag, keypress, or typing exercised.",
108
- "If background_computer_use reports a first-party approval or active app lease, do not retry automatically.",
109
- "Disclose that background_computer_use starts a nested Codex model call and consumes separate Codex usage.",
110
- ],
111
- parameters: Params,
112
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
113
- const response = await executeOperation(params, {
114
- stateRoot,
115
- signal,
116
- onProgress: (message) =>
117
- onUpdate?.({ content: [{ type: "text", text: message }], details: { status: "running" } }),
118
- });
119
- if (response.isError) throw new Error(response.text);
120
- return {
121
- content: [{ type: "text", text: response.text }],
122
- details: response.details,
123
- };
124
- },
125
- renderCall(args, theme) {
126
- return new Text(
127
- theme.fg("toolTitle", theme.bold("background_computer_use ")) +
128
- theme.fg("accent", String(args.app ?? "apps")) +
129
- theme.fg("muted", ` ${String(args.mode ?? "")}`),
130
- 0,
131
- 0,
132
- );
133
- },
134
- });
246
+ pi.on("session_start", () => setInitialComputerUseTools(pi));
135
247
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "mcpServers": {
3
- "codex-computer-use": {
3
+ "computer-use-direct": {
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "codex-computer-use-mcp@0.1.0"
7
+ "codex-computer-use-mcp@0.3.0"
8
8
  ],
9
9
  "lifecycle": "lazy",
10
- "requestTimeoutMs": 360000,
10
+ "requestTimeoutMs": 180000,
11
11
  "directTools": false,
12
12
  "debug": false
13
13
  }