junis 0.3.8 → 0.3.10

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/dist/cli/index.js CHANGED
@@ -165,6 +165,40 @@ function sleep(ms) {
165
165
 
166
166
  // src/relay/client.ts
167
167
  import WebSocket from "ws";
168
+
169
+ // src/relay/upload.ts
170
+ var LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
171
+ async function uploadLargeFile(relay, base64Data, filename, contentType) {
172
+ const buffer = Buffer.from(base64Data, "base64");
173
+ const { put_url, access_url } = await relay.requestUploadUrl(
174
+ filename,
175
+ contentType,
176
+ buffer.length
177
+ );
178
+ const res = await fetch(put_url, {
179
+ method: "PUT",
180
+ headers: { "Content-Type": contentType },
181
+ body: buffer
182
+ });
183
+ if (!res.ok) {
184
+ throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
185
+ }
186
+ return access_url;
187
+ }
188
+ function isLargeBase64(base64) {
189
+ return base64.length * 0.75 > LARGE_FILE_THRESHOLD;
190
+ }
191
+ function detectContentType(base64) {
192
+ const header = base64.slice(0, 16);
193
+ if (header.startsWith("/9j/")) return "image/jpeg";
194
+ if (header.startsWith("iVBOR")) return "image/png";
195
+ if (header.startsWith("R0lGO")) return "image/gif";
196
+ if (header.startsWith("UklGR")) return "image/webp";
197
+ if (header.startsWith("JVBER")) return "application/pdf";
198
+ return "application/octet-stream";
199
+ }
200
+
201
+ // src/relay/client.ts
168
202
  var JUNIS_WS = (() => {
169
203
  if (process.env.JUNIS_WS_URL) return process.env.JUNIS_WS_URL;
170
204
  const apiUrl = process.env.JUNIS_API_URL ?? "https://junis.ai";
@@ -186,6 +220,8 @@ var RelayClient = class {
186
220
  heartbeatTimer = null;
187
221
  destroyed = false;
188
222
  lastPongTime = 0;
223
+ // upload_url_response 대기용 pending 맵
224
+ pendingUploadRequests = /* @__PURE__ */ new Map();
189
225
  async connect() {
190
226
  if (this.destroyed) return;
191
227
  const url = `${JUNIS_WS}/ws/devices/${this.config.device_key}`;
@@ -209,9 +245,22 @@ var RelayClient = class {
209
245
  this.lastPongTime = Date.now();
210
246
  return;
211
247
  }
248
+ if (msg.type === "upload_url_response") {
249
+ const pending = this.pendingUploadRequests.get(msg.request_id);
250
+ if (pending) {
251
+ this.pendingUploadRequests.delete(msg.request_id);
252
+ if (msg.error) {
253
+ pending.reject(new Error(msg.error));
254
+ } else {
255
+ pending.resolve(msg);
256
+ }
257
+ }
258
+ return;
259
+ }
212
260
  if (msg.type === "mcp_request") {
213
261
  try {
214
- const result = await this.onMCPRequest(msg.id, msg.payload);
262
+ let result = await this.onMCPRequest(msg.id, msg.payload);
263
+ result = await this.processLargeFiles(result);
215
264
  this.send({ type: "mcp_response", id: msg.id, payload: result });
216
265
  } catch (err) {
217
266
  this.send({
@@ -266,6 +315,76 @@ var RelayClient = class {
266
315
  this.ws.send(JSON.stringify(data));
267
316
  }
268
317
  }
318
+ /**
319
+ * 서버에 presigned PUT URL 요청.
320
+ * WebSocket으로 upload_url_request 전송 → upload_url_response 대기.
321
+ */
322
+ requestUploadUrl(filename, contentType, size) {
323
+ return new Promise((resolve, reject) => {
324
+ const requestId = crypto.randomUUID();
325
+ const timeout = setTimeout(() => {
326
+ this.pendingUploadRequests.delete(requestId);
327
+ reject(new Error("Upload URL request timeout (30s)"));
328
+ }, 3e4);
329
+ this.pendingUploadRequests.set(requestId, {
330
+ resolve: (data) => {
331
+ clearTimeout(timeout);
332
+ resolve(data);
333
+ },
334
+ reject: (err) => {
335
+ clearTimeout(timeout);
336
+ reject(err);
337
+ }
338
+ });
339
+ this.send({
340
+ type: "upload_url_request",
341
+ request_id: requestId,
342
+ filename,
343
+ content_type: contentType,
344
+ size
345
+ });
346
+ });
347
+ }
348
+ /**
349
+ * MCP 응답 내 대용량 base64 데이터를 감지하여 presigned URL 업로드 후 URL로 교체.
350
+ *
351
+ * 대상:
352
+ * 1. ImageContent: { type: "image", data: "<base64>", mimeType: "image/png" }
353
+ * → { type: "text", text: "![uploaded](https://...access_url)" }
354
+ * 2. TextContent with large base64: { type: "text", text: "<huge base64>" }
355
+ * → { type: "text", text: "https://...access_url" }
356
+ */
357
+ async processLargeFiles(result) {
358
+ if (!result || typeof result !== "object") return result;
359
+ const obj = result;
360
+ const inner = obj.result ?? obj;
361
+ const content = inner.content;
362
+ if (!Array.isArray(content)) return result;
363
+ for (let i = 0; i < content.length; i++) {
364
+ const item = content[i];
365
+ if (!item || typeof item !== "object") continue;
366
+ if (item.type === "image" && typeof item.data === "string" && isLargeBase64(item.data)) {
367
+ try {
368
+ const mimeType = item.mimeType || "image/png";
369
+ const ext = mimeType.split("/")[1] || "bin";
370
+ const url = await uploadLargeFile(this, item.data, `screenshot.${ext}`, mimeType);
371
+ content[i] = { type: "text", text: `![uploaded](${url})` };
372
+ } catch (err) {
373
+ console.error("Failed to upload large image:", err);
374
+ }
375
+ } else if (item.type === "text" && typeof item.text === "string" && isLargeBase64(item.text) && /^[A-Za-z0-9+/\n\r]+=*$/.test(item.text.trim())) {
376
+ try {
377
+ const contentType = detectContentType(item.text);
378
+ const ext = contentType.split("/")[1] || "bin";
379
+ const url = await uploadLargeFile(this, item.text, `file.${ext}`, contentType);
380
+ content[i] = { type: "text", text: url };
381
+ } catch (err) {
382
+ console.error("Failed to upload large text base64:", err);
383
+ }
384
+ }
385
+ }
386
+ return result;
387
+ }
269
388
  startHeartbeat() {
270
389
  this.heartbeatTimer = setInterval(() => {
271
390
  if (Date.now() - this.lastPongTime > 9e4) {
@@ -286,6 +405,10 @@ var RelayClient = class {
286
405
  this.destroyed = true;
287
406
  this.stopHeartbeat();
288
407
  this.ws?.close();
408
+ for (const [, pending] of this.pendingUploadRequests) {
409
+ pending.reject(new Error("Client destroyed"));
410
+ }
411
+ this.pendingUploadRequests.clear();
289
412
  }
290
413
  };
291
414
 
@@ -338,9 +461,9 @@ var toolPermissions = {
338
461
  cron_delete: "confirm",
339
462
  edit_block: "confirm",
340
463
  kill_process: "confirm",
341
- // 시스템 변경 — 기본 차단 (PDF 7.3절)
342
- execute_command: "deny",
343
- write_file: "deny"
464
+ // 시스템 변경 — 대화 기반 승인 (confirm)
465
+ execute_command: "confirm",
466
+ write_file: "confirm"
344
467
  };
345
468
  function checkPermission(toolName) {
346
469
  const level = toolPermissions[toolName];
@@ -366,17 +489,15 @@ var FilesystemTools = class {
366
489
  "- For reading files prefer read_file, for editing prefer edit_block, for searching prefer search_code.",
367
490
  "",
368
491
  "BEHAVIOR:",
369
- "- Safe, routine commands (ls, pwd, git status, echo): execute immediately without explanation.",
370
- "- Destructive or irreversible commands (rm -rf, sudo, shutdown, mkfs): explain what will happen and get user confirmation first.",
492
+ "- Execute commands directly when the user requests them. Do not ask for confirmation \u2014 the user has already decided.",
371
493
  "- If a command fails, analyze the error and suggest an alternative. Do not retry the identical command more than twice.",
372
494
  "",
373
495
  "SAFETY:",
374
- "- Commands run with the user's full permissions. Never execute commands that could damage the system, expose credentials, or modify security settings without explicit user request.",
375
- "- Avoid piping untrusted input into shells. Use absolute paths when possible. Quote paths containing spaces."
496
+ "- Commands run with the user's full permissions. Use absolute paths when possible. Quote paths containing spaces."
376
497
  ].join("\n"),
377
498
  {
378
499
  command: z.string().describe("The shell command to execute. Use absolute paths when possible. Quote paths containing spaces."),
379
- timeout_ms: z.number().optional().default(3e4).describe("Maximum execution time in milliseconds (default: 30000). Increase for long-running builds or downloads."),
500
+ timeout_ms: z.number().optional().default(12e4).describe("Maximum execution time in milliseconds (default: 120000). Increase for very long-running builds or downloads."),
380
501
  background: z.boolean().optional().default(false).describe("Run in background without waiting for completion. Use for servers or long-running processes.")
381
502
  },
382
503
  async ({ command, timeout_ms, background }) => {
@@ -863,11 +984,11 @@ var BrowserTools = class {
863
984
  headless: z2.boolean().optional().default(false).describe("Run without visible window (managed mode only). Use for background tasks."),
864
985
  cdpUrl: z2.string().optional().describe("Chrome DevTools Protocol URL for remote-cdp mode (e.g. http://localhost:9222)"),
865
986
  profile: z2.string().optional().describe("Browser profile name for persistent sessions \u2014 preserves cookies, logins, and history across restarts (managed mode only)"),
866
- allowInternal: z2.boolean().optional().default(false).describe("Allow navigation to localhost and internal network URLs")
987
+ allowInternal: z2.boolean().optional().default(true).describe("Allow navigation to localhost and internal network URLs (default: true for local agent)")
867
988
  },
868
989
  ({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
869
990
  if (this.browser) {
870
- return { content: [{ type: "text", text: "Browser is already running. Call browser_stop first." }] };
991
+ await this.cleanup();
871
992
  }
872
993
  if (mode === "remote-cdp") {
873
994
  if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
@@ -1404,7 +1525,11 @@ var DeviceTools = class {
1404
1525
  "Capture a photo from the device's camera and return it as base64 image data.",
1405
1526
  "",
1406
1527
  "Platform-specific: macOS (imagesnap), Windows (ffmpeg/dshow), Linux (fswebcam).",
1407
- "Requires a connected camera with OS permissions granted. If output_path is provided, the file is also saved to disk."
1528
+ "If output_path is provided, the file is also saved to disk.",
1529
+ "",
1530
+ "PERMISSIONS (macOS): Camera permission is needed. If it fails, macOS may show a native Allow/Deny dialog \u2014 ask the user to click Allow.",
1531
+ "If still denied, use execute_command to open Camera settings:",
1532
+ " open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'"
1408
1533
  ].join("\n"),
1409
1534
  {
1410
1535
  output_path: z4.string().optional().describe("File path to save the captured photo. If omitted, returns image data only (temp file auto-cleaned).")
@@ -1422,11 +1547,10 @@ var DeviceTools = class {
1422
1547
  await execAsync3(cmd);
1423
1548
  } catch (err) {
1424
1549
  const e = err;
1550
+ const hint = p === "mac" ? "\n\n\u{1F527} FIX: Camera permission may be needed. Try:\n1. Retry \u2014 macOS may show a native Allow/Deny dialog.\n2. If denied, run via execute_command: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'\nAsk the user to toggle ON for 'imagesnap' (or their terminal app), then retry." : "";
1425
1551
  return {
1426
- content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
1427
- Cause: ${e.message}
1428
-
1429
- Please check if a camera is connected.` }],
1552
+ content: [{ type: "text", text: `\u274C Camera capture failed.
1553
+ Cause: ${e.message}${hint}` }],
1430
1554
  isError: true
1431
1555
  };
1432
1556
  }
@@ -1625,6 +1749,13 @@ var APP_BLACKLIST = /* @__PURE__ */ new Set([
1625
1749
  ]);
1626
1750
  var consecutiveFailures = 0;
1627
1751
  var MAX_CONSECUTIVE_FAILURES = 2;
1752
+ var PERM_FIX_HINT = [
1753
+ "\n\n\u{1F527} PERMISSION FIX \u2014 run these via execute_command:",
1754
+ "1. Check status: peekaboo permissions --json-output",
1755
+ "2. Screen Recording: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'",
1756
+ "3. Accessibility: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'",
1757
+ "Toggle ON for 'peekaboo' in the opened panel, then retry."
1758
+ ].join("\n");
1628
1759
  async function peekaboo(args) {
1629
1760
  try {
1630
1761
  const { stdout } = await execa("peekaboo", [...args, "--json-output"]);
@@ -1632,11 +1763,14 @@ async function peekaboo(args) {
1632
1763
  return JSON.parse(stdout);
1633
1764
  } catch (err) {
1634
1765
  consecutiveFailures++;
1766
+ const msg = err.message?.toLowerCase() ?? "";
1767
+ const isPermError = msg.includes("permission") || msg.includes("accessibility") || msg.includes("screen recording") || msg.includes("not trusted") || msg.includes("not allowed") || msg.includes("denied");
1768
+ const hint = isPermError ? PERM_FIX_HINT : "";
1635
1769
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1636
1770
  consecutiveFailures = 0;
1637
- throw new Error(`peekaboo failed ${MAX_CONSECUTIVE_FAILURES} times in a row. Auto-stopped for safety. Last error: ${err.message}`);
1771
+ throw new Error(`peekaboo failed ${MAX_CONSECUTIVE_FAILURES} times in a row. Auto-stopped for safety. Last error: ${err.message}${hint}`);
1638
1772
  }
1639
- throw err;
1773
+ throw new Error(`${err.message}${hint}`);
1640
1774
  }
1641
1775
  }
1642
1776
  function checkBlacklist(app) {
@@ -1654,6 +1788,13 @@ var DesktopTools = class {
1654
1788
  "WORKFLOW: List running apps \u2192 capture accessibility tree \u2192 find target element by role/label \u2192 interact using element ID or label (click, type, scroll).",
1655
1789
  "Pass the returned snapshotId to subsequent interaction calls for 240x speed improvement (cached lookup vs. full re-scan).",
1656
1790
  "",
1791
+ "PERMISSIONS: Desktop tools require macOS Accessibility + Screen Recording permissions for 'peekaboo'.",
1792
+ "If a tool fails with permission error, use execute_command to:",
1793
+ " 1. peekaboo permissions --json-output (check which are missing)",
1794
+ " 2. open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'",
1795
+ " 3. open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'",
1796
+ "Ask the user to toggle ON for 'peekaboo', then retry.",
1797
+ "",
1657
1798
  "SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger an automatic safety stop."
1658
1799
  ].join("\n"),
1659
1800
  {
@@ -47,9 +47,9 @@ var toolPermissions = {
47
47
  cron_delete: "confirm",
48
48
  edit_block: "confirm",
49
49
  kill_process: "confirm",
50
- // 시스템 변경 — 기본 차단 (PDF 7.3절)
51
- execute_command: "deny",
52
- write_file: "deny"
50
+ // 시스템 변경 — 대화 기반 승인 (confirm)
51
+ execute_command: "confirm",
52
+ write_file: "confirm"
53
53
  };
54
54
  function checkPermission(toolName) {
55
55
  const level = toolPermissions[toolName];
@@ -75,17 +75,15 @@ var FilesystemTools = class {
75
75
  "- For reading files prefer read_file, for editing prefer edit_block, for searching prefer search_code.",
76
76
  "",
77
77
  "BEHAVIOR:",
78
- "- Safe, routine commands (ls, pwd, git status, echo): execute immediately without explanation.",
79
- "- Destructive or irreversible commands (rm -rf, sudo, shutdown, mkfs): explain what will happen and get user confirmation first.",
78
+ "- Execute commands directly when the user requests them. Do not ask for confirmation \u2014 the user has already decided.",
80
79
  "- If a command fails, analyze the error and suggest an alternative. Do not retry the identical command more than twice.",
81
80
  "",
82
81
  "SAFETY:",
83
- "- Commands run with the user's full permissions. Never execute commands that could damage the system, expose credentials, or modify security settings without explicit user request.",
84
- "- Avoid piping untrusted input into shells. Use absolute paths when possible. Quote paths containing spaces."
82
+ "- Commands run with the user's full permissions. Use absolute paths when possible. Quote paths containing spaces."
85
83
  ].join("\n"),
86
84
  {
87
85
  command: z.string().describe("The shell command to execute. Use absolute paths when possible. Quote paths containing spaces."),
88
- timeout_ms: z.number().optional().default(3e4).describe("Maximum execution time in milliseconds (default: 30000). Increase for long-running builds or downloads."),
86
+ timeout_ms: z.number().optional().default(12e4).describe("Maximum execution time in milliseconds (default: 120000). Increase for very long-running builds or downloads."),
89
87
  background: z.boolean().optional().default(false).describe("Run in background without waiting for completion. Use for servers or long-running processes.")
90
88
  },
91
89
  async ({ command, timeout_ms, background }) => {
@@ -572,11 +570,11 @@ var BrowserTools = class {
572
570
  headless: z2.boolean().optional().default(false).describe("Run without visible window (managed mode only). Use for background tasks."),
573
571
  cdpUrl: z2.string().optional().describe("Chrome DevTools Protocol URL for remote-cdp mode (e.g. http://localhost:9222)"),
574
572
  profile: z2.string().optional().describe("Browser profile name for persistent sessions \u2014 preserves cookies, logins, and history across restarts (managed mode only)"),
575
- allowInternal: z2.boolean().optional().default(false).describe("Allow navigation to localhost and internal network URLs")
573
+ allowInternal: z2.boolean().optional().default(true).describe("Allow navigation to localhost and internal network URLs (default: true for local agent)")
576
574
  },
577
575
  ({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
578
576
  if (this.browser) {
579
- return { content: [{ type: "text", text: "Browser is already running. Call browser_stop first." }] };
577
+ await this.cleanup();
580
578
  }
581
579
  if (mode === "remote-cdp") {
582
580
  if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
@@ -1113,7 +1111,11 @@ var DeviceTools = class {
1113
1111
  "Capture a photo from the device's camera and return it as base64 image data.",
1114
1112
  "",
1115
1113
  "Platform-specific: macOS (imagesnap), Windows (ffmpeg/dshow), Linux (fswebcam).",
1116
- "Requires a connected camera with OS permissions granted. If output_path is provided, the file is also saved to disk."
1114
+ "If output_path is provided, the file is also saved to disk.",
1115
+ "",
1116
+ "PERMISSIONS (macOS): Camera permission is needed. If it fails, macOS may show a native Allow/Deny dialog \u2014 ask the user to click Allow.",
1117
+ "If still denied, use execute_command to open Camera settings:",
1118
+ " open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'"
1117
1119
  ].join("\n"),
1118
1120
  {
1119
1121
  output_path: z4.string().optional().describe("File path to save the captured photo. If omitted, returns image data only (temp file auto-cleaned).")
@@ -1131,11 +1133,10 @@ var DeviceTools = class {
1131
1133
  await execAsync3(cmd);
1132
1134
  } catch (err) {
1133
1135
  const e = err;
1136
+ const hint = p === "mac" ? "\n\n\u{1F527} FIX: Camera permission may be needed. Try:\n1. Retry \u2014 macOS may show a native Allow/Deny dialog.\n2. If denied, run via execute_command: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'\nAsk the user to toggle ON for 'imagesnap' (or their terminal app), then retry." : "";
1134
1137
  return {
1135
- content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
1136
- Cause: ${e.message}
1137
-
1138
- Please check if a camera is connected.` }],
1138
+ content: [{ type: "text", text: `\u274C Camera capture failed.
1139
+ Cause: ${e.message}${hint}` }],
1139
1140
  isError: true
1140
1141
  };
1141
1142
  }
@@ -1334,6 +1335,13 @@ var APP_BLACKLIST = /* @__PURE__ */ new Set([
1334
1335
  ]);
1335
1336
  var consecutiveFailures = 0;
1336
1337
  var MAX_CONSECUTIVE_FAILURES = 2;
1338
+ var PERM_FIX_HINT = [
1339
+ "\n\n\u{1F527} PERMISSION FIX \u2014 run these via execute_command:",
1340
+ "1. Check status: peekaboo permissions --json-output",
1341
+ "2. Screen Recording: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'",
1342
+ "3. Accessibility: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'",
1343
+ "Toggle ON for 'peekaboo' in the opened panel, then retry."
1344
+ ].join("\n");
1337
1345
  async function peekaboo(args) {
1338
1346
  try {
1339
1347
  const { stdout } = await execa("peekaboo", [...args, "--json-output"]);
@@ -1341,11 +1349,14 @@ async function peekaboo(args) {
1341
1349
  return JSON.parse(stdout);
1342
1350
  } catch (err) {
1343
1351
  consecutiveFailures++;
1352
+ const msg = err.message?.toLowerCase() ?? "";
1353
+ const isPermError = msg.includes("permission") || msg.includes("accessibility") || msg.includes("screen recording") || msg.includes("not trusted") || msg.includes("not allowed") || msg.includes("denied");
1354
+ const hint = isPermError ? PERM_FIX_HINT : "";
1344
1355
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1345
1356
  consecutiveFailures = 0;
1346
- throw new Error(`peekaboo failed ${MAX_CONSECUTIVE_FAILURES} times in a row. Auto-stopped for safety. Last error: ${err.message}`);
1357
+ throw new Error(`peekaboo failed ${MAX_CONSECUTIVE_FAILURES} times in a row. Auto-stopped for safety. Last error: ${err.message}${hint}`);
1347
1358
  }
1348
- throw err;
1359
+ throw new Error(`${err.message}${hint}`);
1349
1360
  }
1350
1361
  }
1351
1362
  function checkBlacklist(app) {
@@ -1363,6 +1374,13 @@ var DesktopTools = class {
1363
1374
  "WORKFLOW: List running apps \u2192 capture accessibility tree \u2192 find target element by role/label \u2192 interact using element ID or label (click, type, scroll).",
1364
1375
  "Pass the returned snapshotId to subsequent interaction calls for 240x speed improvement (cached lookup vs. full re-scan).",
1365
1376
  "",
1377
+ "PERMISSIONS: Desktop tools require macOS Accessibility + Screen Recording permissions for 'peekaboo'.",
1378
+ "If a tool fails with permission error, use execute_command to:",
1379
+ " 1. peekaboo permissions --json-output (check which are missing)",
1380
+ " 2. open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'",
1381
+ " 3. open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'",
1382
+ "Ask the user to toggle ON for 'peekaboo', then retry.",
1383
+ "",
1366
1384
  "SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger an automatic safety stop."
1367
1385
  ].join("\n"),
1368
1386
  {
@@ -48,9 +48,9 @@ var toolPermissions = {
48
48
  cron_delete: "confirm",
49
49
  edit_block: "confirm",
50
50
  kill_process: "confirm",
51
- // 시스템 변경 — 기본 차단 (PDF 7.3절)
52
- execute_command: "deny",
53
- write_file: "deny"
51
+ // 시스템 변경 — 대화 기반 승인 (confirm)
52
+ execute_command: "confirm",
53
+ write_file: "confirm"
54
54
  };
55
55
  function checkPermission(toolName) {
56
56
  const level = toolPermissions[toolName];
@@ -76,17 +76,15 @@ var FilesystemTools = class {
76
76
  "- For reading files prefer read_file, for editing prefer edit_block, for searching prefer search_code.",
77
77
  "",
78
78
  "BEHAVIOR:",
79
- "- Safe, routine commands (ls, pwd, git status, echo): execute immediately without explanation.",
80
- "- Destructive or irreversible commands (rm -rf, sudo, shutdown, mkfs): explain what will happen and get user confirmation first.",
79
+ "- Execute commands directly when the user requests them. Do not ask for confirmation \u2014 the user has already decided.",
81
80
  "- If a command fails, analyze the error and suggest an alternative. Do not retry the identical command more than twice.",
82
81
  "",
83
82
  "SAFETY:",
84
- "- Commands run with the user's full permissions. Never execute commands that could damage the system, expose credentials, or modify security settings without explicit user request.",
85
- "- Avoid piping untrusted input into shells. Use absolute paths when possible. Quote paths containing spaces."
83
+ "- Commands run with the user's full permissions. Use absolute paths when possible. Quote paths containing spaces."
86
84
  ].join("\n"),
87
85
  {
88
86
  command: z.string().describe("The shell command to execute. Use absolute paths when possible. Quote paths containing spaces."),
89
- timeout_ms: z.number().optional().default(3e4).describe("Maximum execution time in milliseconds (default: 30000). Increase for long-running builds or downloads."),
87
+ timeout_ms: z.number().optional().default(12e4).describe("Maximum execution time in milliseconds (default: 120000). Increase for very long-running builds or downloads."),
90
88
  background: z.boolean().optional().default(false).describe("Run in background without waiting for completion. Use for servers or long-running processes.")
91
89
  },
92
90
  async ({ command, timeout_ms, background }) => {
@@ -573,11 +571,11 @@ var BrowserTools = class {
573
571
  headless: z2.boolean().optional().default(false).describe("Run without visible window (managed mode only). Use for background tasks."),
574
572
  cdpUrl: z2.string().optional().describe("Chrome DevTools Protocol URL for remote-cdp mode (e.g. http://localhost:9222)"),
575
573
  profile: z2.string().optional().describe("Browser profile name for persistent sessions \u2014 preserves cookies, logins, and history across restarts (managed mode only)"),
576
- allowInternal: z2.boolean().optional().default(false).describe("Allow navigation to localhost and internal network URLs")
574
+ allowInternal: z2.boolean().optional().default(true).describe("Allow navigation to localhost and internal network URLs (default: true for local agent)")
577
575
  },
578
576
  ({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
579
577
  if (this.browser) {
580
- return { content: [{ type: "text", text: "Browser is already running. Call browser_stop first." }] };
578
+ await this.cleanup();
581
579
  }
582
580
  if (mode === "remote-cdp") {
583
581
  if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
@@ -1114,7 +1112,11 @@ var DeviceTools = class {
1114
1112
  "Capture a photo from the device's camera and return it as base64 image data.",
1115
1113
  "",
1116
1114
  "Platform-specific: macOS (imagesnap), Windows (ffmpeg/dshow), Linux (fswebcam).",
1117
- "Requires a connected camera with OS permissions granted. If output_path is provided, the file is also saved to disk."
1115
+ "If output_path is provided, the file is also saved to disk.",
1116
+ "",
1117
+ "PERMISSIONS (macOS): Camera permission is needed. If it fails, macOS may show a native Allow/Deny dialog \u2014 ask the user to click Allow.",
1118
+ "If still denied, use execute_command to open Camera settings:",
1119
+ " open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'"
1118
1120
  ].join("\n"),
1119
1121
  {
1120
1122
  output_path: z4.string().optional().describe("File path to save the captured photo. If omitted, returns image data only (temp file auto-cleaned).")
@@ -1132,11 +1134,10 @@ var DeviceTools = class {
1132
1134
  await execAsync3(cmd);
1133
1135
  } catch (err) {
1134
1136
  const e = err;
1137
+ const hint = p === "mac" ? "\n\n\u{1F527} FIX: Camera permission may be needed. Try:\n1. Retry \u2014 macOS may show a native Allow/Deny dialog.\n2. If denied, run via execute_command: open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'\nAsk the user to toggle ON for 'imagesnap' (or their terminal app), then retry." : "";
1135
1138
  return {
1136
- content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
1137
- Cause: ${e.message}
1138
-
1139
- Please check if a camera is connected.` }],
1139
+ content: [{ type: "text", text: `\u274C Camera capture failed.
1140
+ Cause: ${e.message}${hint}` }],
1140
1141
  isError: true
1141
1142
  };
1142
1143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "junis",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "One-line device control for AI agents",
5
5
  "type": "module",
6
6
  "bin": {