evot-agent 0.4.0 → 0.4.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evot-agent",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Local browser agent for evot — browses sites with Claude or Codex and appends results as cells.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { AGENT_DIR } from "../config.js";
3
- import { SYSTEM_APPEND, buildMcpArgs } from "./common.js";
3
+ import { SYSTEM_APPEND, buildPlaywrightMcp } from "./common.js";
4
4
 
5
5
  /**
6
6
  * 잡을 Claude Agent SDK + Playwright MCP(전용 프로필)로 실행하고 최종 텍스트를 돌려준다.
@@ -12,7 +12,7 @@ export async function runClaudeJob(job) {
12
12
  const prompt = promptParts.join("\n");
13
13
 
14
14
  const headless = job.headless !== false;
15
- const mcpArgs = buildMcpArgs(headless);
15
+ const mcp = buildPlaywrightMcp(headless);
16
16
  console.log(headless ? "Browsing headlessly (no window)." : "Browsing with a visible window.");
17
17
 
18
18
  let finalText = "";
@@ -34,8 +34,8 @@ export async function runClaudeJob(job) {
34
34
  mcpServers: {
35
35
  playwright: {
36
36
  type: "stdio",
37
- command: "npx",
38
- args: mcpArgs,
37
+ command: mcp.command,
38
+ args: mcp.args,
39
39
  },
40
40
  },
41
41
  allowedTools: ["mcp__playwright"],
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { mkdir, copyFile, chmod } from "node:fs/promises";
4
4
  import { AGENT_DIR, PROFILE_DIR } from "../config.js";
5
- import { SYSTEM_APPEND, buildMcpArgs } from "./common.js";
5
+ import { SYSTEM_APPEND, buildPlaywrightMcp } from "./common.js";
6
6
 
7
7
  // ChatGPT 계정에서 쓸 수 있는 모델(사용자 config.toml 기본값 gpt-5.3-codex 등은 미지원).
8
8
  // 중간급(밸런스) 기본값. 더 강한/약한 모델이 필요하면 EVOT_AGENT_CODEX_MODEL로 교체
@@ -57,7 +57,7 @@ export async function runCodexJob(job) {
57
57
  const prompt = promptParts.join("\n");
58
58
 
59
59
  const headless = job.headless !== false;
60
- const mcpArgs = buildMcpArgs(headless);
60
+ const mcp = buildPlaywrightMcp(headless);
61
61
  console.log(headless ? "Browsing headlessly (no window)." : "Browsing with a visible window.");
62
62
 
63
63
  // ChatGPT 로그인 크리덴셜 강제: API 키가 있으면 로그인 흐름과 충돌(이슈 #3286).
@@ -72,8 +72,8 @@ export async function runCodexJob(job) {
72
72
  config: {
73
73
  mcp_servers: {
74
74
  playwright: {
75
- command: "npx",
76
- args: mcpArgs,
75
+ command: mcp.command,
76
+ args: mcp.args,
77
77
  default_tools_approval_mode: "approve",
78
78
  },
79
79
  },
@@ -1,5 +1,9 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { PROFILE_DIR } from "../config.js";
2
3
 
4
+ // 읽기 전용 프록시 절대 경로(src/readonly-proxy.js). backends/에서 상위 src/로.
5
+ const READONLY_PROXY_PATH = fileURLToPath(new URL("../readonly-proxy.js", import.meta.url));
6
+
3
7
  // 백엔드(claude/codex)가 공유하는 프롬프트·상수. 순환 import을 피하려고
4
8
  // browse.js가 아니라 이 leaf 모듈에 둔다(config.js에만 의존).
5
9
 
@@ -19,6 +23,14 @@ export const SYSTEM_APPEND = [
19
23
  " LOGIN_REQUIRED: <domain>",
20
24
  ' (e.g. "LOGIN_REQUIRED: news.ycombinator.com"). No other text.',
21
25
  "",
26
+ "YOUTUBE:",
27
+ "- Do NOT play the video. Open the description (\"more\"/\"더보기\") and click",
28
+ ' "Show transcript"("스크립트 표시") — the FULL transcript loads into the panel at',
29
+ " once; read it with a single snapshot (scroll the transcript panel only if the",
30
+ " snapshot is truncated). Summarize from that text.",
31
+ "- If no transcript exists, fall back to metadata only: title, channel, description,",
32
+ " and chapters — and say the summary is metadata-based.",
33
+ "",
22
34
  "MISSION FIDELITY:",
23
35
  "- Complete exactly what the user asked. Do not narrow the scope on your own or",
24
36
  " substitute a different deliverable — e.g. do NOT return only text/metadata when",
@@ -44,17 +56,22 @@ export const SYSTEM_APPEND = [
44
56
  ].join("\n");
45
57
 
46
58
  /**
47
- * Playwright MCP(stdio) 실행 인자. 기본은 headless(창 없이),
48
- * headless === false면 창을 띄운다(봇차단 폴백). 전용 프로필을 재사용한다.
59
+ * Playwright MCP(stdio) 서버 스펙 { command, args }을 만든다.
60
+ * 읽기 전용 프록시(readonly-proxy.js)를 앞단에 끼워 쓰기 계열 툴을 물리 차단한다:
61
+ * command = 현재 node 실행파일, args = [proxy.js, "npx", "@playwright/mcp", ...]
62
+ * 기본은 headless(창 없이), headless === false면 창을 띄운다(봇차단 폴백). 전용 프로필 재사용.
49
63
  */
50
- export function buildMcpArgs(headless) {
51
- const args = [
64
+ export function buildPlaywrightMcp(headless) {
65
+ const playwrightArgs = [
52
66
  "@playwright/mcp",
53
67
  "--browser",
54
68
  "chrome",
55
69
  "--user-data-dir",
56
70
  PROFILE_DIR,
57
71
  ];
58
- if (headless) args.push("--headless");
59
- return args;
72
+ if (headless) playwrightArgs.push("--headless");
73
+ return {
74
+ command: process.execPath,
75
+ args: [READONLY_PROXY_PATH, "npx", ...playwrightArgs],
76
+ };
60
77
  }
package/src/browse.js CHANGED
@@ -103,6 +103,8 @@ function isSafeDomain(d) {
103
103
  * loginDomain(세션만료 마커)이 있으면 재로그인 절차를 안내한다.
104
104
  */
105
105
  export function describeJobFailure(err) {
106
+ const msg = err?.message ?? "";
107
+
106
108
  if (err?.loginDomain && isSafeDomain(err.loginDomain)) {
107
109
  return [
108
110
  `The agent is signed out of ${err.loginDomain}, so this task could not be completed.`,
@@ -121,5 +123,41 @@ export function describeJobFailure(err) {
121
123
  "with the site you want to collect from, sign in once, then ask the Vot to retry.",
122
124
  ].join("\n");
123
125
  }
124
- return `The agent could not complete this task.\n\nReason: ${err?.message ?? "unknown"}`;
126
+
127
+ // 실행 시간 초과(Codex 벽시계 타임아웃 등) — 무엇을 하라고 명확히 안내.
128
+ if (err?.name === "AbortError" || /\baborted?\b|timed out|timeout/i.test(msg)) {
129
+ return [
130
+ "The task timed out on the connected computer before it finished.",
131
+ "",
132
+ "Try a narrower or simpler request, then ask the Vot to retry.",
133
+ ].join("\n");
134
+ }
135
+
136
+ // Codex 백엔드 미로그인 — 유저 컴퓨터에 Codex가 없거나 로그인 안 된 상태로 실행한 경우.
137
+ if (/codex is not signed in|codex login/i.test(msg)) {
138
+ return [
139
+ "Codex is not signed in on the connected computer, so this task could not run.",
140
+ "",
141
+ "To fix it, run `codex login` in a terminal on that computer, then ask the Vot to retry.",
142
+ "Or switch the runtime to Claude Code in Settings → Computer Use.",
143
+ ].join("\n");
144
+ }
145
+
146
+ // Claude Code 백엔드 미로그인/인증 실패 — Codex 대칭 보강.
147
+ if (/not logged in|invalid api key|\/login|unauthorized|authentication|credential/i.test(msg)) {
148
+ return [
149
+ "Claude Code is not logged in on the connected computer, so this task could not run.",
150
+ "",
151
+ "To fix it, run `claude` in a terminal on that computer and log in with `/login`,",
152
+ "then ask the Vot to retry (this is separate from the Claude desktop app login).",
153
+ ].join("\n");
154
+ }
155
+
156
+ return [
157
+ "The agent could not complete this task.",
158
+ "",
159
+ `Reason: ${msg || "unknown"}`,
160
+ "",
161
+ "Check the connected computer and the agent status in Settings → Computer Use.",
162
+ ].join("\n");
125
163
  }
package/src/login.js CHANGED
@@ -37,7 +37,28 @@ export async function runLogin(url) {
37
37
  "When you're done, just close the browser window.\n",
38
38
  );
39
39
 
40
- // 유저가 창을 닫을 때까지 대기.
41
- await new Promise((resolve) => context.on("close", resolve));
40
+ // 유저가 모든 탭을 닫으면 완료로 간주하고, 데몬이 직접 context.close()로 마무리한다.
41
+ // context.on("close") 하나에만 의존하면 macOS에서 창의 빨간 X를 눌러도 CDP 파이프가 남아
42
+ // Chrome 프로세스가 죽지 않고(=close 미발화) CLI가 행 + 프로필 SingletonLock을 계속 쥔다.
43
+ // 이후 browse 잡이 프로필 락 충돌로 실패하는 걸 막으려면 여기서 확실히 닫아야 한다.
44
+ await new Promise((resolve) => {
45
+ let done = false;
46
+ const finish = async () => {
47
+ if (done) return;
48
+ done = true;
49
+ try {
50
+ await context.close(); // Chrome 프로세스·프로필 락을 확실히 해제
51
+ } catch {
52
+ // 이미 닫힌 경우 무시
53
+ }
54
+ resolve();
55
+ };
56
+ const onPageClose = () => {
57
+ if (context.pages().length === 0) void finish();
58
+ };
59
+ for (const p of context.pages()) p.on("close", onPageClose);
60
+ context.on("page", (p) => p.on("close", onPageClose));
61
+ context.on("close", () => void finish()); // 프로세스가 정상 종료되는 경우도 처리
62
+ });
42
63
  console.log("Login session saved. You can now run jobs against those sites.");
43
64
  }
@@ -0,0 +1,105 @@
1
+ // evot 로컬 에이전트 읽기 전용 MCP 프록시.
2
+ // Claude/Codex ↔ @playwright/mcp 사이 stdio JSON-RPC(NDJSON) 파이프 중간에 끼어:
3
+ // - tools/list 응답에서 쓰기 계열 툴을 제거 → 모델이 아예 목록에서 못 본다.
4
+ // - 혹시 이름으로 직접 tools/call 하더라도 하위 서버로 전달하지 않고 isError로 즉시 거절.
5
+ // allowlist가 없는 Codex 백엔드에서도 쓰기 동작이 물리적으로 차단된다(프롬프트에만 의존 X).
6
+ // 의존성 0(node 내장만). MCP stdio는 줄 단위 JSON이라 readline으로 충분하다.
7
+ //
8
+ // 실행: node readonly-proxy.js <child-command> [child-args...]
9
+ // 예) node readonly-proxy.js npx @playwright/mcp --browser chrome --user-data-dir <dir>
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { createInterface } from "node:readline";
13
+
14
+ // 상태를 바꿀 수 있는 툴 — 물리 차단.
15
+ // click/type/press_key/navigate/snapshot/hover 등 읽기·탐색에 필요한 툴은 허용한다
16
+ // (더보기·transcript 클릭, 검색어 입력, 쿠키 배너 닫기 등). 폼 일괄 작성·파일 업로드·
17
+ // 임의 JS 실행·네이티브 select·드래그·다이얼로그 승인 등 쓰기의 핵심 경로만 막는다.
18
+ const BLOCKED = new Set([
19
+ "browser_fill_form",
20
+ "browser_file_upload",
21
+ "browser_select_option",
22
+ "browser_drag",
23
+ "browser_drop",
24
+ "browser_evaluate",
25
+ "browser_run_code_unsafe",
26
+ "browser_handle_dialog",
27
+ ]);
28
+
29
+ const BLOCK_MESSAGE =
30
+ "This tool is blocked: the evot agent is read-only and cannot perform write actions " +
31
+ "(form fill/submit, uploads, native <select>, drag/drop, dialogs, arbitrary JS). " +
32
+ "Read and summarize what is visible instead, or report that the task needs a write action.";
33
+
34
+ const childArgs = process.argv.slice(2);
35
+ if (childArgs.length === 0) {
36
+ process.stderr.write("readonly-proxy: missing child command\n");
37
+ process.exit(1);
38
+ }
39
+
40
+ const child = spawn(childArgs[0], childArgs.slice(1), {
41
+ // stderr는 그대로 흘려보내 하위 MCP 서버 로그를 보존한다(디버깅).
42
+ stdio: ["pipe", "pipe", "inherit"],
43
+ });
44
+
45
+ child.on("exit", (code) => process.exit(code ?? 0));
46
+ child.on("error", (err) => {
47
+ process.stderr.write(`readonly-proxy: failed to start child: ${err.message}\n`);
48
+ process.exit(1);
49
+ });
50
+
51
+ function writeToClient(obj) {
52
+ process.stdout.write(JSON.stringify(obj) + "\n");
53
+ }
54
+ function writeToServer(line) {
55
+ child.stdin.write(line + "\n");
56
+ }
57
+
58
+ // 클라이언트(Claude/Codex) → 서버: 차단 툴 호출은 가로채 즉시 거절, 나머진 원문 그대로 전달.
59
+ const fromClient = createInterface({ input: process.stdin });
60
+ fromClient.on("line", (line) => {
61
+ if (!line.trim()) return;
62
+ let msg;
63
+ try {
64
+ msg = JSON.parse(line);
65
+ } catch {
66
+ writeToServer(line); // 파싱 불가 → 원문 전달(프로토콜 훼손 방지)
67
+ return;
68
+ }
69
+ if (msg?.method === "tools/call" && BLOCKED.has(msg?.params?.name)) {
70
+ writeToClient({
71
+ jsonrpc: "2.0",
72
+ id: msg.id,
73
+ result: { content: [{ type: "text", text: BLOCK_MESSAGE }], isError: true },
74
+ });
75
+ return;
76
+ }
77
+ writeToServer(line);
78
+ });
79
+ fromClient.on("close", () => {
80
+ try {
81
+ child.stdin.end();
82
+ } catch {
83
+ // 이미 닫힌 경우 무시
84
+ }
85
+ });
86
+
87
+ // 서버 → 클라이언트: tools/list 응답에서 차단 툴을 제거, 나머진 원문 그대로 전달.
88
+ // tools/list 응답은 result.tools 배열 형태로 고유 식별되므로 요청 id 추적이 불필요하다.
89
+ const fromServer = createInterface({ input: child.stdout });
90
+ fromServer.on("line", (line) => {
91
+ if (!line.trim()) return;
92
+ let msg;
93
+ try {
94
+ msg = JSON.parse(line);
95
+ } catch {
96
+ process.stdout.write(line + "\n"); // 파싱 불가 → 원문 전달
97
+ return;
98
+ }
99
+ if (msg?.result && Array.isArray(msg.result.tools)) {
100
+ msg.result.tools = msg.result.tools.filter((tool) => !BLOCKED.has(tool?.name));
101
+ writeToClient(msg);
102
+ return;
103
+ }
104
+ process.stdout.write(line + "\n");
105
+ });