evot-agent 0.4.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evot-agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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 = "";
@@ -30,14 +30,34 @@ export async function runClaudeJob(job) {
30
30
  preset: "claude_code",
31
31
  append: SYSTEM_APPEND,
32
32
  },
33
- disallowedTools: ["Bash", "Write", "Edit", "NotebookEdit"],
33
+ // 내장 툴을 전부 비운다. allowedTools는 "확인 없이 자동 승인할 목록"이지 제한이
34
+ // 아니라서(SDK: "To restrict which tools are available, use the `tools` option
35
+ // instead"), 예전처럼 allowedTools에만 mcp__playwright를 넣으면 Read·Grep·WebFetch·
36
+ // WebSearch가 그대로 살아 있고 bypassPermissions 탓에 확인도 없이 실행됐다.
37
+ // tools는 "내장" 툴 집합만 지정하며(SDK가 --tools ""로 전달), MCP 서버는 mcpServers로
38
+ // 따로 붙으므로 Playwright는 그대로 남는다.
39
+ tools: [],
40
+ // 이름 기준 이중 차단 — tools:[]가 이미 막지만, 상위 설정 변화에 대비해 남긴다.
41
+ disallowedTools: [
42
+ "Bash",
43
+ "Write",
44
+ "Edit",
45
+ "NotebookEdit",
46
+ "Read",
47
+ "Glob",
48
+ "Grep",
49
+ "WebFetch",
50
+ "WebSearch",
51
+ "Agent",
52
+ ],
34
53
  mcpServers: {
35
54
  playwright: {
36
55
  type: "stdio",
37
- command: "npx",
38
- args: mcpArgs,
56
+ command: mcp.command,
57
+ args: mcp.args,
39
58
  },
40
59
  },
60
+ // MCP 툴은 프롬프트 없이 실행되도록 자동 승인 유지.
41
61
  allowedTools: ["mcp__playwright"],
42
62
  },
43
63
  })) {
@@ -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,27 @@ 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 = [
52
- "@playwright/mcp",
64
+ export function buildPlaywrightMcp(headless) {
65
+ const playwrightArgs = [
66
+ // 버전 고정: readonly-proxy의 차단 목록은 이 버전의 툴 셋에 맞춰져 있다. 전역 설치(~/.evot-agent)
67
+ // 에서 npx는 로컬 node_modules가 없어 미고정 시 레지스트리 최신을 받으므로, 새 버전이 추가한
68
+ // 쓰기 툴이 차단을 우회할 수 있다. package.json의 @playwright/mcp 핀과 일치시킨다.
69
+ "@playwright/mcp@0.0.77",
53
70
  "--browser",
54
71
  "chrome",
55
72
  "--user-data-dir",
56
73
  PROFILE_DIR,
57
74
  ];
58
- if (headless) args.push("--headless");
59
- return args;
75
+ if (headless) playwrightArgs.push("--headless");
76
+ // NOTE: posix(macOS/Linux) 기준. Windows에서는 프록시가 "npx"를 그대로 spawn하면
77
+ // ENOENT/.cmd 스폰 정책에 걸릴 수 있어 별도 검증이 필요하다(현재 미검증 — 백로그).
78
+ return {
79
+ command: process.execPath,
80
+ args: [READONLY_PROXY_PATH, "npx", ...playwrightArgs],
81
+ };
60
82
  }
package/src/browse.js CHANGED
@@ -10,12 +10,22 @@ const BACKENDS = {
10
10
 
11
11
  /**
12
12
  * 실행 백엔드를 고른다. 우선순위: 명시 인자 > EVOT_AGENT_BACKEND > 기본 "claude".
13
+ *
14
+ * delegated(구독 위임 잡)면 codex를 거부한다. codex는 read-only 샌드박스라 쓰기·네트워크는
15
+ * 막아도 내장 shell의 읽기는 허용이라, 남의 지시로 내 머신에서 도는 잡에는 허용할 수 없다
16
+ * (claude는 tools:[]로 shell·Read가 아예 없다). 현재 서버가 위임 잡을 만들지 않아 항상
17
+ * false지만, 위임이 되살아나도 Codex 경로가 열리지 않도록 남겨둔 안전망이다.
13
18
  */
14
- export function resolveBackend(explicit) {
19
+ export function resolveBackend(explicit, opts = {}) {
15
20
  const name = (explicit || process.env.EVOT_AGENT_BACKEND || "claude").toLowerCase();
16
21
  if (!BACKENDS[name]) {
17
22
  throw new Error(`Unknown backend "${name}". Use "claude" or "codex".`);
18
23
  }
24
+ if (opts.delegated && name === "codex") {
25
+ const err = new Error("Codex runtime is not allowed for delegated tasks.");
26
+ err.delegatedBackendBlocked = true;
27
+ throw err;
28
+ }
19
29
  return name;
20
30
  }
21
31
 
@@ -66,7 +76,7 @@ export function parseSegments(raw) {
66
76
  * 백엔드는 최종 텍스트만 책임지고, LOGIN_REQUIRED 마커·빈결과 판정·파싱은 여기서 공통 처리한다.
67
77
  */
68
78
  export async function runBrowseJob(job, opts = {}) {
69
- const backend = resolveBackend(opts.backend);
79
+ const backend = resolveBackend(opts.backend, { delegated: opts.delegated });
70
80
  const finalText = await BACKENDS[backend](job);
71
81
 
72
82
  // 세션만료 마커: 모델이 로그인 벽에 막혀 정상 종료해도 실패로 다룬다.
@@ -102,7 +112,22 @@ function isSafeDomain(d) {
102
112
  * 잡 실패를 셀/텔레그램에 남길 사용자용 텍스트로 조립한다.
103
113
  * loginDomain(세션만료 마커)이 있으면 재로그인 절차를 안내한다.
104
114
  */
105
- export function describeJobFailure(err) {
115
+ export function describeJobFailure(err, backend) {
116
+ const msg = err?.message ?? "";
117
+ // 활성 백엔드(claude/codex). 미로그인 안내를 실제 실행 백엔드에 한해서만 내보내,
118
+ // 방문한 웹페이지의 "unauthorized/login" 문구를 CLI 미로그인으로 오인하는 것을 막는다.
119
+ const runtime = (backend || "claude").toLowerCase();
120
+
121
+ // 위임 잡인데 런타임이 Codex — 백엔드 선택 단계에서 거부된 경우.
122
+ if (err?.delegatedBackendBlocked) {
123
+ return [
124
+ "This task came from a Vot subscription, and the connected computer is set to the Codex runtime.",
125
+ "Subscription tasks only run on the Claude Code runtime for safety.",
126
+ "",
127
+ "To enable them, switch the runtime to Claude Code in Settings → Computer Use on that computer.",
128
+ ].join("\n");
129
+ }
130
+
106
131
  if (err?.loginDomain && isSafeDomain(err.loginDomain)) {
107
132
  return [
108
133
  `The agent is signed out of ${err.loginDomain}, so this task could not be completed.`,
@@ -121,5 +146,43 @@ export function describeJobFailure(err) {
121
146
  "with the site you want to collect from, sign in once, then ask the Vot to retry.",
122
147
  ].join("\n");
123
148
  }
124
- return `The agent could not complete this task.\n\nReason: ${err?.message ?? "unknown"}`;
149
+
150
+ // 실행 시간 초과(Codex 벽시계 타임아웃 등) — 무엇을 하라고 명확히 안내.
151
+ if (err?.name === "AbortError" || /\baborted?\b|timed out|timeout/i.test(msg)) {
152
+ return [
153
+ "The task timed out on the connected computer before it finished.",
154
+ "",
155
+ "Try a narrower or simpler request, then ask the Vot to retry.",
156
+ ].join("\n");
157
+ }
158
+
159
+ // Codex 백엔드 미로그인 — codex 실행일 때만. 유저 컴퓨터에 Codex가 없거나 미로그인.
160
+ if (runtime === "codex" && /not signed in|codex login/i.test(msg)) {
161
+ return [
162
+ "Codex is not signed in on the connected computer, so this task could not run.",
163
+ "",
164
+ "To fix it, run `codex login` in a terminal on that computer, then ask the Vot to retry.",
165
+ "Or switch the runtime to Claude Code in Settings → Computer Use.",
166
+ ].join("\n");
167
+ }
168
+
169
+ // Claude Code 백엔드 미로그인/인증 실패 — claude 실행일 때만. 정규식은 SDK가 내는
170
+ // 인증 에러(로그인/API 키)에 한정한다. 웹페이지의 401 "unauthorized/authentication"을
171
+ // CLI 미로그인으로 오인하지 않도록 그런 범용 단어는 매칭에서 제외한다.
172
+ if (runtime === "claude" && /not logged in|invalid api key|please run.*\/login|\bno api key\b/i.test(msg)) {
173
+ return [
174
+ "Claude Code is not logged in on the connected computer, so this task could not run.",
175
+ "",
176
+ "To fix it, run `claude` in a terminal on that computer and log in with `/login`,",
177
+ "then ask the Vot to retry (this is separate from the Claude desktop app login).",
178
+ ].join("\n");
179
+ }
180
+
181
+ return [
182
+ "The agent could not complete this task.",
183
+ "",
184
+ `Reason: ${msg || "unknown"}`,
185
+ "",
186
+ "Check the connected computer and the agent status in Settings → Computer Use.",
187
+ ].join("\n");
125
188
  }
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,109 @@
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 runs in a restricted mode: bulk form fill, " +
31
+ "file uploads, native <select>, drag/drop, dialogs, and arbitrary JS execution are " +
32
+ "disabled. Read and summarize what is visible instead, or report that the task needs " +
33
+ "one of these blocked actions.";
34
+
35
+ const childArgs = process.argv.slice(2);
36
+ if (childArgs.length === 0) {
37
+ process.stderr.write("readonly-proxy: missing child command\n");
38
+ process.exit(1);
39
+ }
40
+
41
+ const child = spawn(childArgs[0], childArgs.slice(1), {
42
+ // stderr는 그대로 흘려보내 하위 MCP 서버 로그를 보존한다(디버깅).
43
+ stdio: ["pipe", "pipe", "inherit"],
44
+ });
45
+
46
+ child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
47
+ child.on("error", (err) => {
48
+ process.stderr.write(`readonly-proxy: failed to start child: ${err.message}\n`);
49
+ process.exit(1);
50
+ });
51
+ // child가 먼저 종료된 뒤의 write는 EPIPE를 던진다 — 프록시 크래시 대신 조용히 무시
52
+ // (파이프가 닫히면 클라이언트가 stdout 종료를 감지해 잡 에러로 표면화한다).
53
+ child.stdin.on("error", () => {});
54
+
55
+ function writeToClient(obj) {
56
+ process.stdout.write(JSON.stringify(obj) + "\n");
57
+ }
58
+ function writeToServer(line) {
59
+ child.stdin.write(line + "\n");
60
+ }
61
+
62
+ // 클라이언트(Claude/Codex) → 서버: 차단 툴 호출은 가로채 즉시 거절, 나머진 원문 그대로 전달.
63
+ const fromClient = createInterface({ input: process.stdin });
64
+ fromClient.on("line", (line) => {
65
+ if (!line.trim()) return;
66
+ let msg;
67
+ try {
68
+ msg = JSON.parse(line);
69
+ } catch {
70
+ writeToServer(line); // 파싱 불가 → 원문 전달(프로토콜 훼손 방지)
71
+ return;
72
+ }
73
+ if (msg?.method === "tools/call" && BLOCKED.has(msg?.params?.name)) {
74
+ writeToClient({
75
+ jsonrpc: "2.0",
76
+ id: msg.id,
77
+ result: { content: [{ type: "text", text: BLOCK_MESSAGE }], isError: true },
78
+ });
79
+ return;
80
+ }
81
+ writeToServer(line);
82
+ });
83
+ fromClient.on("close", () => {
84
+ try {
85
+ child.stdin.end();
86
+ } catch {
87
+ // 이미 닫힌 경우 무시
88
+ }
89
+ });
90
+
91
+ // 서버 → 클라이언트: tools/list 응답에서 차단 툴을 제거, 나머진 원문 그대로 전달.
92
+ // tools/list 응답은 result.tools 배열 형태로 고유 식별되므로 요청 id 추적이 불필요하다.
93
+ const fromServer = createInterface({ input: child.stdout });
94
+ fromServer.on("line", (line) => {
95
+ if (!line.trim()) return;
96
+ let msg;
97
+ try {
98
+ msg = JSON.parse(line);
99
+ } catch {
100
+ process.stdout.write(line + "\n"); // 파싱 불가 → 원문 전달
101
+ return;
102
+ }
103
+ if (msg?.result && Array.isArray(msg.result.tools)) {
104
+ msg.result.tools = msg.result.tools.filter((tool) => !BLOCKED.has(tool?.name));
105
+ writeToClient(msg);
106
+ return;
107
+ }
108
+ process.stdout.write(line + "\n");
109
+ });
package/src/sync.js CHANGED
@@ -55,11 +55,13 @@ async function runJob(job, localBackend) {
55
55
  start_url: job.start_url ?? undefined,
56
56
  headless: true,
57
57
  },
58
- { backend },
58
+ // delegated: 구독 위임 잡(내 지시가 아니라 구독자 지시로 내 머신에서 도는 잡).
59
+ // 현재 서버는 위임 잡을 만들지 않아 항상 false — 재도입 대비 안전망.
60
+ { backend, delegated: job.delegated === true },
59
61
  ));
60
62
  } catch (err) {
61
63
  console.error(`Job ${job.id} failed: ${err.message}`);
62
- text = describeJobFailure(err);
64
+ text = describeJobFailure(err, backend);
63
65
  status = "error";
64
66
  }
65
67
  try {