@runcofactor/cli 0.10.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/README.md +125 -0
- package/dist/api-catalog-GFGCLVS4.js +401 -0
- package/dist/api-catalog-GFGCLVS4.js.map +1 -0
- package/dist/attachment-SJC7D7FY.js +276 -0
- package/dist/attachment-SJC7D7FY.js.map +1 -0
- package/dist/auth-EN6NQCJY.js +31 -0
- package/dist/auth-EN6NQCJY.js.map +1 -0
- package/dist/bin.js +7 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-3CZU7XCS.js +272 -0
- package/dist/chunk-3CZU7XCS.js.map +1 -0
- package/dist/chunk-4NRDONSC.js +1048 -0
- package/dist/chunk-4NRDONSC.js.map +1 -0
- package/dist/chunk-4PNOV7OV.js +51980 -0
- package/dist/chunk-4PNOV7OV.js.map +1 -0
- package/dist/chunk-52IO7ACJ.js +2941 -0
- package/dist/chunk-52IO7ACJ.js.map +1 -0
- package/dist/chunk-6LEZCKKV.js +663 -0
- package/dist/chunk-6LEZCKKV.js.map +1 -0
- package/dist/chunk-A4OFTUVV.js +170 -0
- package/dist/chunk-A4OFTUVV.js.map +1 -0
- package/dist/chunk-B2VISN2V.js +2459 -0
- package/dist/chunk-B2VISN2V.js.map +1 -0
- package/dist/chunk-CLAWT7BJ.js +271 -0
- package/dist/chunk-CLAWT7BJ.js.map +1 -0
- package/dist/chunk-GKY2KXO5.js +281 -0
- package/dist/chunk-GKY2KXO5.js.map +1 -0
- package/dist/chunk-I4QLI3JO.js +413 -0
- package/dist/chunk-I4QLI3JO.js.map +1 -0
- package/dist/chunk-IMPZAX6L.js +180 -0
- package/dist/chunk-IMPZAX6L.js.map +1 -0
- package/dist/chunk-J6SH7GQT.js +241 -0
- package/dist/chunk-J6SH7GQT.js.map +1 -0
- package/dist/chunk-JDBCZ3Z2.js +298 -0
- package/dist/chunk-JDBCZ3Z2.js.map +1 -0
- package/dist/chunk-JICSYFRJ.js +46 -0
- package/dist/chunk-JICSYFRJ.js.map +1 -0
- package/dist/chunk-JWHYXEA2.js +32 -0
- package/dist/chunk-JWHYXEA2.js.map +1 -0
- package/dist/chunk-N7HFALX3.js +88 -0
- package/dist/chunk-N7HFALX3.js.map +1 -0
- package/dist/chunk-Q4BO4Z5V.js +178 -0
- package/dist/chunk-Q4BO4Z5V.js.map +1 -0
- package/dist/chunk-QFDOWABX.js +195 -0
- package/dist/chunk-QFDOWABX.js.map +1 -0
- package/dist/chunk-SJJW77WN.js +605 -0
- package/dist/chunk-SJJW77WN.js.map +1 -0
- package/dist/chunk-VKVL7WBN.js +50 -0
- package/dist/chunk-VKVL7WBN.js.map +1 -0
- package/dist/chunk-WOHL4FTB.js +353 -0
- package/dist/chunk-WOHL4FTB.js.map +1 -0
- package/dist/chunk-XQXVG7RA.js +426 -0
- package/dist/chunk-XQXVG7RA.js.map +1 -0
- package/dist/chunk-ZD3KHESI.js +94 -0
- package/dist/chunk-ZD3KHESI.js.map +1 -0
- package/dist/credential-COLTBV6D.js +450 -0
- package/dist/credential-COLTBV6D.js.map +1 -0
- package/dist/credential-template-EEPYFG7R.js +291 -0
- package/dist/credential-template-EEPYFG7R.js.map +1 -0
- package/dist/debug-workflow-KOVH5GOP.js +259 -0
- package/dist/debug-workflow-KOVH5GOP.js.map +1 -0
- package/dist/dist-44PZR7BQ.js +423 -0
- package/dist/dist-44PZR7BQ.js.map +1 -0
- package/dist/docs-3SSORHB5.js +366 -0
- package/dist/docs-3SSORHB5.js.map +1 -0
- package/dist/doctor-OTSIWV6G.js +132 -0
- package/dist/doctor-OTSIWV6G.js.map +1 -0
- package/dist/feature-flag-T2LRZME3.js +283 -0
- package/dist/feature-flag-T2LRZME3.js.map +1 -0
- package/dist/file-type-S3UUBVCT.js +4205 -0
- package/dist/file-type-S3UUBVCT.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +982 -0
- package/dist/index.js.map +1 -0
- package/dist/init-TGWRECT2.js +208 -0
- package/dist/init-TGWRECT2.js.map +1 -0
- package/dist/install-DXSNWZKO.js +295 -0
- package/dist/install-DXSNWZKO.js.map +1 -0
- package/dist/issues-XIZOPX6T.js +564 -0
- package/dist/issues-XIZOPX6T.js.map +1 -0
- package/dist/knobs-NBINZIUB.js +295 -0
- package/dist/knobs-NBINZIUB.js.map +1 -0
- package/dist/list-3TDHK637.js +59 -0
- package/dist/list-3TDHK637.js.map +1 -0
- package/dist/local-HICVD6PU.js +100 -0
- package/dist/local-HICVD6PU.js.map +1 -0
- package/dist/login-UOIUXVUK.js +315 -0
- package/dist/login-UOIUXVUK.js.map +1 -0
- package/dist/mcp-54IJEOUA.js +1956 -0
- package/dist/mcp-54IJEOUA.js.map +1 -0
- package/dist/pdf-extract-ABWV3L5X.js +13 -0
- package/dist/pdf-extract-ABWV3L5X.js.map +1 -0
- package/dist/pdfjs-DQ2YA5S3.js +41561 -0
- package/dist/pdfjs-DQ2YA5S3.js.map +1 -0
- package/dist/property-UXLOLPTD.js +701 -0
- package/dist/property-UXLOLPTD.js.map +1 -0
- package/dist/property-skill-guide.md +189 -0
- package/dist/psql-BYUUNLGG.js +115 -0
- package/dist/psql-BYUUNLGG.js.map +1 -0
- package/dist/record-4TR2UIIS.js +233 -0
- package/dist/record-4TR2UIIS.js.map +1 -0
- package/dist/redis-35VBUYW7.js +116 -0
- package/dist/redis-35VBUYW7.js.map +1 -0
- package/dist/release-OOLTUBUT.js +354 -0
- package/dist/release-OOLTUBUT.js.map +1 -0
- package/dist/sandbox-XGSUU6G7.js +2046 -0
- package/dist/sandbox-XGSUU6G7.js.map +1 -0
- package/dist/scenarios-GWLJEGHA.js +280 -0
- package/dist/scenarios-GWLJEGHA.js.map +1 -0
- package/dist/session-MYFNZPRP.js +2414 -0
- package/dist/session-MYFNZPRP.js.map +1 -0
- package/dist/skill-KM34LDMO.js +1839 -0
- package/dist/skill-KM34LDMO.js.map +1 -0
- package/dist/skills/debug-run/SKILL.md +262 -0
- package/dist/skills/debug-run/history/1.0.0.md +244 -0
- package/dist/skills/debug-run/history/1.0.1.md +245 -0
- package/dist/skills/debug-run/history/1.0.2.md +254 -0
- package/dist/skills/debug-run/history/1.1.0.md +262 -0
- package/dist/skills/debug-run/version.json +3 -0
- package/dist/spec-RA3CFEKG.js +951 -0
- package/dist/spec-RA3CFEKG.js.map +1 -0
- package/dist/src-LB4HBTJQ.js +1516 -0
- package/dist/src-LB4HBTJQ.js.map +1 -0
- package/dist/src-N3TPKGTY.js +244 -0
- package/dist/src-N3TPKGTY.js.map +1 -0
- package/dist/src-UVDX5UP6.js +17 -0
- package/dist/src-UVDX5UP6.js.map +1 -0
- package/dist/start-PSEQSQA3.js +151 -0
- package/dist/start-PSEQSQA3.js.map +1 -0
- package/dist/tag-YY6YAABQ.js +22 -0
- package/dist/tag-YY6YAABQ.js.map +1 -0
- package/dist/task-IQC3YR5K.js +922 -0
- package/dist/task-IQC3YR5K.js.map +1 -0
- package/dist/test-suite-DZNJFXDW.js +394 -0
- package/dist/test-suite-DZNJFXDW.js.map +1 -0
- package/dist/trace-HXIW7U2D.js +14 -0
- package/dist/trace-HXIW7U2D.js.map +1 -0
- package/dist/triage-ORG45R5W.js +16 -0
- package/dist/triage-ORG45R5W.js.map +1 -0
- package/dist/upgrade-L2VYMP2V.js +61 -0
- package/dist/upgrade-L2VYMP2V.js.map +1 -0
- package/dist/workflow-XYFH5LTP.js +3708 -0
- package/dist/workflow-XYFH5LTP.js.map +1 -0
- package/dist/workflow-cache-NQJQFWMV.js +253 -0
- package/dist/workflow-cache-NQJQFWMV.js.map +1 -0
- package/dist/workflow-debug-BGD4UIXU.js +927 -0
- package/dist/workflow-debug-BGD4UIXU.js.map +1 -0
- package/dist/workflow-proposals-MTUDX46O.js +382 -0
- package/dist/workflow-proposals-MTUDX46O.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,2414 @@
|
|
|
1
|
+
import { createRequire as __cr } from "module"; const require = __cr(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
LocalBrowserHost,
|
|
4
|
+
USER_PAUSED_ERROR
|
|
5
|
+
} from "./chunk-I4QLI3JO.js";
|
|
6
|
+
import {
|
|
7
|
+
INTERACTION_CAPTURE_SCRIPT
|
|
8
|
+
} from "./chunk-IMPZAX6L.js";
|
|
9
|
+
import {
|
|
10
|
+
callTool,
|
|
11
|
+
createSession,
|
|
12
|
+
deleteAllSessions,
|
|
13
|
+
deleteSession,
|
|
14
|
+
getSession,
|
|
15
|
+
getSessionStorageState,
|
|
16
|
+
listSessions,
|
|
17
|
+
resolveTargetSession,
|
|
18
|
+
resumeCloud,
|
|
19
|
+
swapSessionContext,
|
|
20
|
+
takeControl
|
|
21
|
+
} from "./chunk-J6SH7GQT.js";
|
|
22
|
+
import {
|
|
23
|
+
downloadStorageState,
|
|
24
|
+
selectCredential,
|
|
25
|
+
uploadStorageState
|
|
26
|
+
} from "./chunk-WOHL4FTB.js";
|
|
27
|
+
import "./chunk-SJJW77WN.js";
|
|
28
|
+
import "./chunk-3CZU7XCS.js";
|
|
29
|
+
import {
|
|
30
|
+
getArgValue,
|
|
31
|
+
hasFlag,
|
|
32
|
+
resolveConfig
|
|
33
|
+
} from "./chunk-A4OFTUVV.js";
|
|
34
|
+
import {
|
|
35
|
+
BrowserToolExecutor,
|
|
36
|
+
PlaywrightClient,
|
|
37
|
+
captureElementAtPoint,
|
|
38
|
+
dispatchBrowserTool,
|
|
39
|
+
dispatchBrowserToolWithTimeout
|
|
40
|
+
} from "./chunk-4PNOV7OV.js";
|
|
41
|
+
import "./chunk-JWHYXEA2.js";
|
|
42
|
+
import "./chunk-6LEZCKKV.js";
|
|
43
|
+
import {
|
|
44
|
+
consoleLogger
|
|
45
|
+
} from "./chunk-ZD3KHESI.js";
|
|
46
|
+
import "./chunk-VKVL7WBN.js";
|
|
47
|
+
|
|
48
|
+
// src/session/index.ts
|
|
49
|
+
import fs3 from "fs";
|
|
50
|
+
import os3 from "os";
|
|
51
|
+
import path3 from "path";
|
|
52
|
+
import process2 from "process";
|
|
53
|
+
|
|
54
|
+
// src/session/claude-settings.ts
|
|
55
|
+
import fs from "fs";
|
|
56
|
+
import path from "path";
|
|
57
|
+
import os from "os";
|
|
58
|
+
function findClaudeSettingsDir(startDir = process.cwd()) {
|
|
59
|
+
let dir = path.resolve(startDir);
|
|
60
|
+
const root = path.parse(dir).root;
|
|
61
|
+
while (dir !== root) {
|
|
62
|
+
const candidate = path.join(dir, ".claude");
|
|
63
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
64
|
+
return candidate;
|
|
65
|
+
}
|
|
66
|
+
dir = path.dirname(dir);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function getScreenshotDirPath() {
|
|
71
|
+
return path.join(os.homedir(), ".canary", "screenshots");
|
|
72
|
+
}
|
|
73
|
+
function getScreenshotReadPattern() {
|
|
74
|
+
return `Read(${getScreenshotDirPath()}/**)`;
|
|
75
|
+
}
|
|
76
|
+
function hasScreenshotPermission(settingsDir) {
|
|
77
|
+
const filePath = path.join(settingsDir, "settings.local.json");
|
|
78
|
+
if (!fs.existsSync(filePath)) return false;
|
|
79
|
+
try {
|
|
80
|
+
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
81
|
+
const allow = content?.permissions?.allow ?? [];
|
|
82
|
+
const pattern = getScreenshotReadPattern();
|
|
83
|
+
return allow.includes(pattern);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function addScreenshotPermission(settingsDir) {
|
|
89
|
+
const filePath = path.join(settingsDir, "settings.local.json");
|
|
90
|
+
const pattern = getScreenshotReadPattern();
|
|
91
|
+
let content = {};
|
|
92
|
+
if (fs.existsSync(filePath)) {
|
|
93
|
+
try {
|
|
94
|
+
content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Failed to parse ${filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!content.permissions || typeof content.permissions !== "object") {
|
|
102
|
+
content.permissions = { allow: [], deny: [], ask: [] };
|
|
103
|
+
}
|
|
104
|
+
const permissions = content.permissions;
|
|
105
|
+
if (!Array.isArray(permissions.allow)) {
|
|
106
|
+
permissions.allow = [];
|
|
107
|
+
}
|
|
108
|
+
const allow = permissions.allow;
|
|
109
|
+
if (allow.includes(pattern)) {
|
|
110
|
+
return { added: null, alreadyPresent: true };
|
|
111
|
+
}
|
|
112
|
+
allow.push(pattern);
|
|
113
|
+
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + "\n", "utf-8");
|
|
114
|
+
return { added: pattern, alreadyPresent: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/session/credentials.ts
|
|
118
|
+
async function resolveCredential(argv, credentialArg) {
|
|
119
|
+
const { apiUrl, token } = await resolveConfig(argv);
|
|
120
|
+
const credential = await selectCredential(apiUrl, token, credentialArg);
|
|
121
|
+
if (!credential) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
console.log(`Fetching credential "${credential.name}"...`);
|
|
125
|
+
let storageStatePath;
|
|
126
|
+
if (credential.storageStateS3Key) {
|
|
127
|
+
storageStatePath = await downloadStorageState({
|
|
128
|
+
apiUrl,
|
|
129
|
+
token,
|
|
130
|
+
propertyId: credential.propertyId,
|
|
131
|
+
credentialId: credential.id,
|
|
132
|
+
prefix: "session-ss"
|
|
133
|
+
});
|
|
134
|
+
if (storageStatePath) {
|
|
135
|
+
console.log("Downloaded storage state.");
|
|
136
|
+
} else {
|
|
137
|
+
console.warn("Warning: Could not download storage state. Starting without auth.");
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
console.log("Credential has no storage state. Starting without auth.");
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
storageStatePath,
|
|
144
|
+
credentialName: credential.name
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/session/daemon.ts
|
|
149
|
+
import { createServer } from "http";
|
|
150
|
+
import fs2 from "fs/promises";
|
|
151
|
+
import path2 from "path";
|
|
152
|
+
import os2 from "os";
|
|
153
|
+
|
|
154
|
+
// src/session/controller-badge.ts
|
|
155
|
+
var BADGE_ID = "__canary_controller_badge__";
|
|
156
|
+
var STORAGE_KEY = "__canaryUserPaused";
|
|
157
|
+
var INTERACTIVE_BADGE_SCRIPT = `(() => {
|
|
158
|
+
function initBadge() {
|
|
159
|
+
const BADGE_ID = '${BADGE_ID}';
|
|
160
|
+
const STORAGE_KEY = '${STORAGE_KEY}';
|
|
161
|
+
|
|
162
|
+
// Restore pause state from sessionStorage (survives same-origin nav)
|
|
163
|
+
if (typeof window.__canaryUserPaused === 'undefined') {
|
|
164
|
+
try {
|
|
165
|
+
window.__canaryUserPaused = sessionStorage.getItem(STORAGE_KEY) === 'true';
|
|
166
|
+
} catch { window.__canaryUserPaused = false; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Ensure keyframes exist
|
|
170
|
+
if (!document.getElementById('canary-badge-keyframes')) {
|
|
171
|
+
const style = document.createElement('style');
|
|
172
|
+
style.id = 'canary-badge-keyframes';
|
|
173
|
+
style.textContent = [
|
|
174
|
+
'@keyframes canary-badge-pulse{0%,100%{opacity:.75}50%{opacity:.4}}',
|
|
175
|
+
'#' + BADGE_ID + '{transition:background .2s,opacity .2s}',
|
|
176
|
+
'#' + BADGE_ID + ':hover{opacity:1!important}',
|
|
177
|
+
].join('\\n');
|
|
178
|
+
document.head.appendChild(style);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let el = document.getElementById(BADGE_ID);
|
|
182
|
+
if (!el) {
|
|
183
|
+
el = document.createElement('div');
|
|
184
|
+
el.id = BADGE_ID;
|
|
185
|
+
el.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;'
|
|
186
|
+
+ 'padding:6px 12px;border-radius:6px;font:600 12px/1.2 system-ui,sans-serif;'
|
|
187
|
+
+ 'cursor:pointer;user-select:none;opacity:.75;color:#fff;'
|
|
188
|
+
+ 'display:flex;align-items:center;gap:6px;box-shadow:0 2px 8px rgba(0,0,0,.2);';
|
|
189
|
+
document.body.appendChild(el);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function render() {
|
|
193
|
+
if (!el) return;
|
|
194
|
+
var paused = !!window.__canaryUserPaused;
|
|
195
|
+
if (paused) {
|
|
196
|
+
el.style.background = '#4DA6FF';
|
|
197
|
+
el.style.animation = 'canary-badge-pulse 2s ease-in-out infinite';
|
|
198
|
+
el.innerHTML = '<span style="font-size:8px;vertical-align:middle;">\\u25CF</span> You'
|
|
199
|
+
+ '<span style="margin-left:4px;padding:2px 6px;background:rgba(255,255,255,.25);'
|
|
200
|
+
+ 'border-radius:3px;font-size:10px;">Resume Agent</span>';
|
|
201
|
+
el.title = 'Click to resume agent control';
|
|
202
|
+
} else {
|
|
203
|
+
el.style.background = '#FFBD0F';
|
|
204
|
+
el.style.animation = '';
|
|
205
|
+
el.innerHTML = 'Agent';
|
|
206
|
+
el.title = 'Click to take control of the browser';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
el.onclick = function(e) {
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
window.__canaryUserPaused = !window.__canaryUserPaused;
|
|
214
|
+
try { sessionStorage.setItem(STORAGE_KEY, String(window.__canaryUserPaused)); } catch {}
|
|
215
|
+
render();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
render();
|
|
219
|
+
|
|
220
|
+
// Re-inject if the page removes the badge (e.g. framework DOM cleanup)
|
|
221
|
+
try {
|
|
222
|
+
var observer = new MutationObserver(function() {
|
|
223
|
+
if (!document.getElementById(BADGE_ID)) {
|
|
224
|
+
observer.disconnect();
|
|
225
|
+
initBadge();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Wait for body to exist (addInitScript runs before DOM is ready)
|
|
233
|
+
if (document.body) {
|
|
234
|
+
initBadge();
|
|
235
|
+
} else if (document.readyState === 'loading') {
|
|
236
|
+
document.addEventListener('DOMContentLoaded', initBadge);
|
|
237
|
+
} else {
|
|
238
|
+
initBadge();
|
|
239
|
+
}
|
|
240
|
+
})()`;
|
|
241
|
+
function staticBadgeScript(controller) {
|
|
242
|
+
const color = controller === "cli" ? "#4DA6FF" : "#FFBD0F";
|
|
243
|
+
const label = controller === "cli" ? "CLI" : "Cloud";
|
|
244
|
+
return `(() => {
|
|
245
|
+
let el = document.getElementById('${BADGE_ID}');
|
|
246
|
+
if (!el) {
|
|
247
|
+
el = document.createElement('div');
|
|
248
|
+
el.id = '${BADGE_ID}';
|
|
249
|
+
el.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;'
|
|
250
|
+
+ 'padding:6px 12px;border-radius:6px;font:600 12px/1.2 system-ui,sans-serif;'
|
|
251
|
+
+ 'pointer-events:none;opacity:.7;color:#fff;'
|
|
252
|
+
+ 'box-shadow:0 2px 8px rgba(0,0,0,.2);';
|
|
253
|
+
document.body.appendChild(el);
|
|
254
|
+
}
|
|
255
|
+
el.style.background = '${color}';
|
|
256
|
+
el.style.animation = '';
|
|
257
|
+
el.style.cursor = 'default';
|
|
258
|
+
el.style.pointerEvents = 'none';
|
|
259
|
+
el.onclick = null;
|
|
260
|
+
el.textContent = '${label}';
|
|
261
|
+
el.title = '';
|
|
262
|
+
})()`;
|
|
263
|
+
}
|
|
264
|
+
async function updateControllerBadge(client, controller, options) {
|
|
265
|
+
try {
|
|
266
|
+
const interactive = options?.interactive ?? false;
|
|
267
|
+
if (interactive) {
|
|
268
|
+
await client.evaluate(INTERACTIVE_BADGE_SCRIPT);
|
|
269
|
+
} else {
|
|
270
|
+
await client.evaluate(staticBadgeScript(controller));
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
options?.logger?.debug?.("[badge] updateControllerBadge failed", {
|
|
274
|
+
controller,
|
|
275
|
+
interactive: options?.interactive ?? false,
|
|
276
|
+
error: err instanceof Error ? err.message : String(err)
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/session/user-takeover.ts
|
|
282
|
+
var PAUSE_POLL_INTERVAL_MS = 500;
|
|
283
|
+
var MAX_PAUSE_EVENTS = 500;
|
|
284
|
+
var EVALUATE_TIMEOUT_MS = 3e3;
|
|
285
|
+
var MAX_STRING_LENGTH = 200;
|
|
286
|
+
function truncate(s, maxLen = MAX_STRING_LENGTH) {
|
|
287
|
+
if (typeof s !== "string") return void 0;
|
|
288
|
+
return s.length > maxLen ? s.slice(0, maxLen) : s;
|
|
289
|
+
}
|
|
290
|
+
function sanitizeEvent(raw) {
|
|
291
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
292
|
+
const evt = raw;
|
|
293
|
+
const ts = typeof evt.ts === "number" ? evt.ts : Date.now();
|
|
294
|
+
switch (evt.type) {
|
|
295
|
+
case "click":
|
|
296
|
+
if (typeof evt.x !== "number" || typeof evt.y !== "number") return null;
|
|
297
|
+
return {
|
|
298
|
+
type: "click",
|
|
299
|
+
ts,
|
|
300
|
+
x: evt.x,
|
|
301
|
+
y: evt.y,
|
|
302
|
+
tagName: truncate(evt.tagName, 50) ?? "UNKNOWN",
|
|
303
|
+
id: truncate(evt.id, 100),
|
|
304
|
+
testId: truncate(evt.testId, 100),
|
|
305
|
+
ariaLabel: truncate(evt.ariaLabel),
|
|
306
|
+
role: truncate(evt.role, 50),
|
|
307
|
+
textContent: truncate(evt.textContent),
|
|
308
|
+
className: truncate(evt.className)
|
|
309
|
+
};
|
|
310
|
+
case "input":
|
|
311
|
+
return {
|
|
312
|
+
type: "input",
|
|
313
|
+
ts,
|
|
314
|
+
tagName: truncate(evt.tagName, 50) ?? "INPUT",
|
|
315
|
+
id: truncate(evt.id, 100),
|
|
316
|
+
inputType: truncate(evt.inputType, 50),
|
|
317
|
+
ariaLabel: truncate(evt.ariaLabel),
|
|
318
|
+
value: truncate(evt.value) ?? "",
|
|
319
|
+
isFinal: evt.isFinal === true
|
|
320
|
+
};
|
|
321
|
+
case "change":
|
|
322
|
+
return {
|
|
323
|
+
type: "change",
|
|
324
|
+
ts,
|
|
325
|
+
tagName: truncate(evt.tagName, 50) ?? "INPUT",
|
|
326
|
+
id: truncate(evt.id, 100),
|
|
327
|
+
inputType: truncate(evt.inputType, 50),
|
|
328
|
+
ariaLabel: truncate(evt.ariaLabel),
|
|
329
|
+
value: truncate(evt.value) ?? ""
|
|
330
|
+
};
|
|
331
|
+
case "keydown":
|
|
332
|
+
if (typeof evt.key !== "string") return null;
|
|
333
|
+
return { type: "keydown", ts, key: evt.key.slice(0, 20) };
|
|
334
|
+
case "navigation":
|
|
335
|
+
if (typeof evt.url !== "string") return null;
|
|
336
|
+
try {
|
|
337
|
+
const parsed = new URL(evt.url);
|
|
338
|
+
const safeUrl = `${parsed.origin}${parsed.pathname}`.slice(0, 2048);
|
|
339
|
+
return {
|
|
340
|
+
type: "navigation",
|
|
341
|
+
ts,
|
|
342
|
+
url: safeUrl,
|
|
343
|
+
navigationType: truncate(evt.navigationType, 50)
|
|
344
|
+
};
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
default:
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function timeoutReject(ms = EVALUATE_TIMEOUT_MS) {
|
|
353
|
+
return new Promise(
|
|
354
|
+
(_, reject) => setTimeout(() => reject(new Error("evaluate timed out")), ms)
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
async function discardCapturedEvents(session) {
|
|
358
|
+
if (session.mode !== "headed") return;
|
|
359
|
+
const page = await session.client.getPageForReplay();
|
|
360
|
+
if (!page) return;
|
|
361
|
+
try {
|
|
362
|
+
await Promise.race([
|
|
363
|
+
page.evaluate(() => {
|
|
364
|
+
const w = window;
|
|
365
|
+
if (typeof w.__canaryFlushAndDiscard === "function") {
|
|
366
|
+
w.__canaryFlushAndDiscard();
|
|
367
|
+
} else {
|
|
368
|
+
w.__canaryRecordedEvents = [];
|
|
369
|
+
}
|
|
370
|
+
}),
|
|
371
|
+
timeoutReject()
|
|
372
|
+
]);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
consoleLogger.debug?.(`[takeover:${session.id}] discardCapturedEvents failed`, {
|
|
375
|
+
error: err instanceof Error ? err.message : String(err)
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async function drainAndSanitize(session) {
|
|
380
|
+
const page = await session.client.getPageForReplay();
|
|
381
|
+
if (!page) return [];
|
|
382
|
+
const raw = await Promise.race([
|
|
383
|
+
page.evaluate(() => {
|
|
384
|
+
const w = window;
|
|
385
|
+
const evts = w.__canaryRecordedEvents ?? [];
|
|
386
|
+
w.__canaryRecordedEvents = [];
|
|
387
|
+
return evts;
|
|
388
|
+
}),
|
|
389
|
+
timeoutReject()
|
|
390
|
+
]);
|
|
391
|
+
if (!Array.isArray(raw)) return [];
|
|
392
|
+
const events = [];
|
|
393
|
+
for (const e of raw) {
|
|
394
|
+
const sanitized = sanitizeEvent(e);
|
|
395
|
+
if (sanitized) events.push(sanitized);
|
|
396
|
+
}
|
|
397
|
+
return events;
|
|
398
|
+
}
|
|
399
|
+
async function checkPauseAndDrainEvents(session) {
|
|
400
|
+
if (session.mode !== "headed") return { paused: false, events: [] };
|
|
401
|
+
if (session.userPaused) return { paused: true, events: [] };
|
|
402
|
+
const page = await session.client.getPageForReplay();
|
|
403
|
+
if (!page) return { paused: false, events: [] };
|
|
404
|
+
try {
|
|
405
|
+
const raw = await Promise.race([
|
|
406
|
+
page.evaluate(() => {
|
|
407
|
+
const w = window;
|
|
408
|
+
const paused2 = !!w.__canaryUserPaused;
|
|
409
|
+
const evts = w.__canaryRecordedEvents ?? [];
|
|
410
|
+
w.__canaryRecordedEvents = [];
|
|
411
|
+
return { paused: paused2, events: evts };
|
|
412
|
+
}),
|
|
413
|
+
timeoutReject()
|
|
414
|
+
]);
|
|
415
|
+
const paused = raw?.paused === true;
|
|
416
|
+
if (paused) session.userPaused = true;
|
|
417
|
+
const events = [];
|
|
418
|
+
if (Array.isArray(raw?.events)) {
|
|
419
|
+
for (const e of raw.events) {
|
|
420
|
+
const sanitized = sanitizeEvent(e);
|
|
421
|
+
if (sanitized) events.push(sanitized);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { paused, events };
|
|
425
|
+
} catch (err) {
|
|
426
|
+
consoleLogger.debug?.(`[takeover:${session.id}] checkPauseAndDrain failed`, {
|
|
427
|
+
error: err instanceof Error ? err.message : String(err)
|
|
428
|
+
});
|
|
429
|
+
return { paused: session.userPaused, events: [] };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function drainUserEvents(session) {
|
|
433
|
+
if (session.mode !== "headed") return [];
|
|
434
|
+
try {
|
|
435
|
+
return await drainAndSanitize(session);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
consoleLogger.debug?.(`[takeover:${session.id}] drainUserEvents failed`, {
|
|
438
|
+
error: err instanceof Error ? err.message : String(err)
|
|
439
|
+
});
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function startPauseEnrichment(session) {
|
|
444
|
+
if (session.userPausePollTimer) return;
|
|
445
|
+
session.pauseEnrichedEvents = [];
|
|
446
|
+
session.userPausePollTimer = setInterval(async () => {
|
|
447
|
+
try {
|
|
448
|
+
if (session.pauseEnrichedEvents.length >= MAX_PAUSE_EVENTS) {
|
|
449
|
+
consoleLogger.debug?.(`[takeover:${session.id}] Event cap reached, skipping drain`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const events = await drainUserEvents(session);
|
|
453
|
+
for (const evt of events) {
|
|
454
|
+
if (session.pauseEnrichedEvents.length >= MAX_PAUSE_EVENTS) break;
|
|
455
|
+
if (evt.type === "click" && !evt.ariaLabel && !evt.textContent) {
|
|
456
|
+
try {
|
|
457
|
+
const page = await session.client.getPageForReplay();
|
|
458
|
+
if (page) {
|
|
459
|
+
const info = await captureElementAtPoint(page, evt.x, evt.y);
|
|
460
|
+
if (info) {
|
|
461
|
+
evt.elementInfo = info;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
consoleLogger.debug?.(`[takeover:${session.id}] captureElementAtPoint failed`, {
|
|
466
|
+
error: err instanceof Error ? err.message : String(err),
|
|
467
|
+
x: evt.x,
|
|
468
|
+
y: evt.y
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
session.pauseEnrichedEvents.push(evt);
|
|
473
|
+
}
|
|
474
|
+
} catch (err) {
|
|
475
|
+
consoleLogger.debug?.(`[takeover:${session.id}] Pause poll error`, {
|
|
476
|
+
error: err instanceof Error ? err.message : String(err)
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}, PAUSE_POLL_INTERVAL_MS);
|
|
480
|
+
consoleLogger.info(`[takeover:${session.id}] Pause enrichment started`);
|
|
481
|
+
}
|
|
482
|
+
async function stopPauseEnrichment(session) {
|
|
483
|
+
if (session.userPausePollTimer) {
|
|
484
|
+
clearInterval(session.userPausePollTimer);
|
|
485
|
+
session.userPausePollTimer = null;
|
|
486
|
+
}
|
|
487
|
+
const remaining = await drainUserEvents(session);
|
|
488
|
+
for (const evt of remaining) {
|
|
489
|
+
if (session.pauseEnrichedEvents.length >= MAX_PAUSE_EVENTS) break;
|
|
490
|
+
session.pauseEnrichedEvents.push(evt);
|
|
491
|
+
}
|
|
492
|
+
if (session.pauseEnrichedEvents.length > 0) {
|
|
493
|
+
try {
|
|
494
|
+
const result = await dispatchBrowserTool(session.executor, "browser_snapshot", {});
|
|
495
|
+
session.pauseResumeSnapshot = typeof result === "string" ? result : result.text ?? null;
|
|
496
|
+
} catch (err) {
|
|
497
|
+
consoleLogger.debug?.(`[takeover:${session.id}] Resume snapshot failed`, {
|
|
498
|
+
error: err instanceof Error ? err.message : String(err)
|
|
499
|
+
});
|
|
500
|
+
session.pauseResumeSnapshot = null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
session.userPaused = false;
|
|
504
|
+
consoleLogger.info(
|
|
505
|
+
`[takeover:${session.id}] Pause enrichment stopped, ${session.pauseEnrichedEvents.length} events captured`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
function cleanupPauseTimer(session) {
|
|
509
|
+
if (session.userPausePollTimer) {
|
|
510
|
+
clearInterval(session.userPausePollTimer);
|
|
511
|
+
session.userPausePollTimer = null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function injectCaptureScript(client) {
|
|
515
|
+
const page = await client.getPageForReplay();
|
|
516
|
+
if (!page) return;
|
|
517
|
+
try {
|
|
518
|
+
await page.addInitScript(INTERACTION_CAPTURE_SCRIPT);
|
|
519
|
+
await page.evaluate(INTERACTION_CAPTURE_SCRIPT);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
consoleLogger.warn("[capture] Failed to inject interaction capture script", {
|
|
522
|
+
error: err instanceof Error ? err.message : String(err)
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function formatUserActionsSummary(events, snapshot) {
|
|
527
|
+
const lines = ["[User Actions]"];
|
|
528
|
+
lines.push("The user performed the following actions since your last tool call:");
|
|
529
|
+
let actionNum = 0;
|
|
530
|
+
for (const evt of events) {
|
|
531
|
+
switch (evt.type) {
|
|
532
|
+
case "click": {
|
|
533
|
+
actionNum++;
|
|
534
|
+
const info = evt.elementInfo;
|
|
535
|
+
const target = evt.ariaLabel ? `"${evt.ariaLabel}"` : evt.textContent ? `"${evt.textContent.slice(0, 60)}"` : info?.ariaLabel ? `"${info.ariaLabel}"` : info?.textContent ? `"${String(info.textContent).slice(0, 60)}"` : evt.testId ? `[data-testid="${evt.testId}"]` : evt.tagName;
|
|
536
|
+
lines.push(`${actionNum}. Clicked ${target} (${evt.tagName})`);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
case "input": {
|
|
540
|
+
if (!evt.isFinal) continue;
|
|
541
|
+
actionNum++;
|
|
542
|
+
const field = evt.ariaLabel ? `"${evt.ariaLabel}"` : evt.id ? `#${evt.id}` : evt.tagName;
|
|
543
|
+
const displayValue = evt.inputType === "password" ? "***" : `"${evt.value}"`;
|
|
544
|
+
lines.push(`${actionNum}. Typed ${displayValue} in ${field}`);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case "change": {
|
|
548
|
+
actionNum++;
|
|
549
|
+
const changeTarget = evt.ariaLabel ?? evt.id ?? evt.tagName;
|
|
550
|
+
lines.push(`${actionNum}. Changed ${changeTarget} to "${evt.value}"`);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case "keydown": {
|
|
554
|
+
if (evt.key === "Enter" || evt.key === "Escape") {
|
|
555
|
+
actionNum++;
|
|
556
|
+
lines.push(`${actionNum}. Pressed ${evt.key}`);
|
|
557
|
+
}
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
case "navigation": {
|
|
561
|
+
actionNum++;
|
|
562
|
+
lines.push(`${actionNum}. Navigated to ${evt.url}`);
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (actionNum === 0) return "";
|
|
568
|
+
if (snapshot) {
|
|
569
|
+
lines.push("");
|
|
570
|
+
lines.push("Current page state after user actions:");
|
|
571
|
+
lines.push(snapshot);
|
|
572
|
+
}
|
|
573
|
+
lines.push("---");
|
|
574
|
+
return lines.join("\n");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// src/session/cdp-proxy/mcp-client.ts
|
|
578
|
+
import { spawn } from "child_process";
|
|
579
|
+
import { createInterface } from "readline";
|
|
580
|
+
var CHROME_DEVTOOLS_MCP_VERSION = "0.21.0";
|
|
581
|
+
var CALL_TIMEOUT_MS = 6e4;
|
|
582
|
+
var INITIALIZE_TIMEOUT_MS = 3e4;
|
|
583
|
+
var SHUTDOWN_GRACE_MS = 5e3;
|
|
584
|
+
var ChromeDevtoolsMcpClient = class {
|
|
585
|
+
child = null;
|
|
586
|
+
pending = /* @__PURE__ */ new Map();
|
|
587
|
+
nextId = 1;
|
|
588
|
+
initialized = false;
|
|
589
|
+
logger;
|
|
590
|
+
constructor(logger) {
|
|
591
|
+
this.logger = logger ?? {
|
|
592
|
+
info: (m) => console.log(`[cdp-proxy] ${m}`),
|
|
593
|
+
warn: (m) => console.warn(`[cdp-proxy] ${m}`),
|
|
594
|
+
error: (m) => console.error(`[cdp-proxy] ${m}`)
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async start() {
|
|
598
|
+
if (this.child) throw new Error("ChromeDevtoolsMcpClient already started");
|
|
599
|
+
const child = spawn(
|
|
600
|
+
"npx",
|
|
601
|
+
["-y", `chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION}`, "--autoConnect"],
|
|
602
|
+
{
|
|
603
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
604
|
+
shell: process.platform === "win32",
|
|
605
|
+
windowsHide: true
|
|
606
|
+
}
|
|
607
|
+
);
|
|
608
|
+
this.child = child;
|
|
609
|
+
child.on("exit", (code, signal) => {
|
|
610
|
+
this.logger.info(`chrome-devtools-mcp exited (code=${code} signal=${signal})`);
|
|
611
|
+
for (const [, p] of this.pending) {
|
|
612
|
+
clearTimeout(p.timer);
|
|
613
|
+
p.reject(new Error(`chrome-devtools-mcp subprocess exited before responding to ${p.method}`));
|
|
614
|
+
}
|
|
615
|
+
this.pending.clear();
|
|
616
|
+
this.child = null;
|
|
617
|
+
});
|
|
618
|
+
child.stderr.on("data", (chunk) => {
|
|
619
|
+
const line = chunk.toString().trimEnd();
|
|
620
|
+
if (line) this.logger.info(`[cdp-mcp stderr] ${line}`);
|
|
621
|
+
});
|
|
622
|
+
const rl = createInterface({ input: child.stdout });
|
|
623
|
+
rl.on("line", (line) => this.onLine(line));
|
|
624
|
+
await this.request("initialize", {
|
|
625
|
+
protocolVersion: "2024-11-05",
|
|
626
|
+
capabilities: {},
|
|
627
|
+
clientInfo: { name: "canary-cli", version: "0.0.0" }
|
|
628
|
+
}, INITIALIZE_TIMEOUT_MS);
|
|
629
|
+
this.notify("notifications/initialized", {});
|
|
630
|
+
this.initialized = true;
|
|
631
|
+
}
|
|
632
|
+
async callTool(name, args) {
|
|
633
|
+
if (!this.initialized) throw new Error("ChromeDevtoolsMcpClient not initialized");
|
|
634
|
+
const result = await this.request("tools/call", { name, arguments: args });
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
async listTools() {
|
|
638
|
+
if (!this.initialized) throw new Error("ChromeDevtoolsMcpClient not initialized");
|
|
639
|
+
const result = await this.request("tools/list", {});
|
|
640
|
+
return result.tools ?? [];
|
|
641
|
+
}
|
|
642
|
+
async shutdown() {
|
|
643
|
+
const child = this.child;
|
|
644
|
+
if (!child) return;
|
|
645
|
+
try {
|
|
646
|
+
child.stdin.end();
|
|
647
|
+
} catch {
|
|
648
|
+
}
|
|
649
|
+
await new Promise((resolve) => {
|
|
650
|
+
let done = false;
|
|
651
|
+
const finish = () => {
|
|
652
|
+
if (done) return;
|
|
653
|
+
done = true;
|
|
654
|
+
resolve();
|
|
655
|
+
};
|
|
656
|
+
child.once("exit", finish);
|
|
657
|
+
setTimeout(() => {
|
|
658
|
+
if (!done) {
|
|
659
|
+
try {
|
|
660
|
+
child.kill("SIGTERM");
|
|
661
|
+
} catch {
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}, SHUTDOWN_GRACE_MS);
|
|
665
|
+
setTimeout(() => {
|
|
666
|
+
if (!done) {
|
|
667
|
+
try {
|
|
668
|
+
child.kill("SIGKILL");
|
|
669
|
+
} catch {
|
|
670
|
+
}
|
|
671
|
+
finish();
|
|
672
|
+
}
|
|
673
|
+
}, SHUTDOWN_GRACE_MS * 2);
|
|
674
|
+
});
|
|
675
|
+
this.child = null;
|
|
676
|
+
this.initialized = false;
|
|
677
|
+
}
|
|
678
|
+
request(method, params, timeoutMs = CALL_TIMEOUT_MS) {
|
|
679
|
+
const child = this.child;
|
|
680
|
+
if (!child) return Promise.reject(new Error("chrome-devtools-mcp subprocess not running"));
|
|
681
|
+
const id = this.nextId++;
|
|
682
|
+
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
683
|
+
return new Promise((resolve, reject) => {
|
|
684
|
+
const timer = setTimeout(() => {
|
|
685
|
+
if (this.pending.delete(id)) {
|
|
686
|
+
reject(new Error(`Timeout waiting for ${method} (id=${id}) after ${timeoutMs}ms`));
|
|
687
|
+
}
|
|
688
|
+
}, timeoutMs);
|
|
689
|
+
this.pending.set(id, { resolve, reject, timer, method });
|
|
690
|
+
try {
|
|
691
|
+
child.stdin.write(payload + "\n");
|
|
692
|
+
} catch (err) {
|
|
693
|
+
this.pending.delete(id);
|
|
694
|
+
clearTimeout(timer);
|
|
695
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
notify(method, params) {
|
|
700
|
+
const child = this.child;
|
|
701
|
+
if (!child) return;
|
|
702
|
+
try {
|
|
703
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
|
704
|
+
} catch (err) {
|
|
705
|
+
this.logger.warn(`notify ${method} failed: ${String(err)}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
onLine(line) {
|
|
709
|
+
const trimmed = line.trim();
|
|
710
|
+
if (!trimmed) return;
|
|
711
|
+
let msg;
|
|
712
|
+
try {
|
|
713
|
+
msg = JSON.parse(trimmed);
|
|
714
|
+
} catch {
|
|
715
|
+
this.logger.warn(`non-JSON line from chrome-devtools-mcp: ${trimmed.slice(0, 200)}`);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (typeof msg.id === "number" && this.pending.has(msg.id)) {
|
|
719
|
+
const p = this.pending.get(msg.id);
|
|
720
|
+
this.pending.delete(msg.id);
|
|
721
|
+
clearTimeout(p.timer);
|
|
722
|
+
if (msg.error) {
|
|
723
|
+
p.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
|
|
724
|
+
} else {
|
|
725
|
+
p.resolve(msg.result);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// src/session/cdp-proxy/translators.ts
|
|
732
|
+
function textFromMcp(result) {
|
|
733
|
+
if (!result.content) return "";
|
|
734
|
+
return result.content.filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
735
|
+
}
|
|
736
|
+
function imagesFromMcp(result) {
|
|
737
|
+
if (!result.content) return void 0;
|
|
738
|
+
const images = result.content.filter((c) => c.type === "image" && typeof c.data === "string").map((c) => ({ data: c.data, mimeType: c.mimeType ?? "image/png" }));
|
|
739
|
+
return images.length > 0 ? images : void 0;
|
|
740
|
+
}
|
|
741
|
+
function toResult(result) {
|
|
742
|
+
const text = textFromMcp(result);
|
|
743
|
+
const images = imagesFromMcp(result);
|
|
744
|
+
if (result.isError) {
|
|
745
|
+
return { ok: false, error: text || "chrome-devtools-mcp returned an error with no message" };
|
|
746
|
+
}
|
|
747
|
+
return { ok: true, text, images };
|
|
748
|
+
}
|
|
749
|
+
function normalizeUid(ref) {
|
|
750
|
+
if (!ref) return ref;
|
|
751
|
+
return ref.startsWith("uid=") ? ref.slice(4) : ref;
|
|
752
|
+
}
|
|
753
|
+
function notSupportedInAttachedMode(canaryTool) {
|
|
754
|
+
return {
|
|
755
|
+
ok: false,
|
|
756
|
+
error: `${canaryTool} is not supported in attached mode (real Chrome). Use a fresh session (--credential) if you need this.`
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
var translators = {
|
|
760
|
+
browser_navigate: async (mcp, args) => {
|
|
761
|
+
const url = args.url;
|
|
762
|
+
return toResult(await mcp.callTool("navigate_page", { url }));
|
|
763
|
+
},
|
|
764
|
+
browser_navigate_back: async (mcp) => {
|
|
765
|
+
return toResult(await mcp.callTool("navigate_page", { type: "back" }));
|
|
766
|
+
},
|
|
767
|
+
browser_snapshot: async (mcp, args) => {
|
|
768
|
+
const mode = args.mode;
|
|
769
|
+
if (mode === "screenshot") {
|
|
770
|
+
return toResult(await mcp.callTool("take_screenshot", {}));
|
|
771
|
+
}
|
|
772
|
+
const snap = await mcp.callTool("take_snapshot", {});
|
|
773
|
+
const snapResult = toResult(snap);
|
|
774
|
+
if (!snapResult.ok) return snapResult;
|
|
775
|
+
const search = args.search;
|
|
776
|
+
let filteredText = snapResult.text;
|
|
777
|
+
if (search) {
|
|
778
|
+
const terms = search.split("|").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
779
|
+
if (terms.length > 0) {
|
|
780
|
+
const lines = filteredText.split("\n");
|
|
781
|
+
const matching = lines.filter((line) => {
|
|
782
|
+
const low = line.toLowerCase();
|
|
783
|
+
return terms.some((t) => low.includes(t));
|
|
784
|
+
});
|
|
785
|
+
filteredText = matching.length > 0 ? `# Filtered snapshot (search=${JSON.stringify(search)}, ${matching.length} matches)
|
|
786
|
+
${matching.join("\n")}` : `# No matches for search=${JSON.stringify(search)}
|
|
787
|
+
|
|
788
|
+
${filteredText}`;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (mode === "both" || mode === void 0) {
|
|
792
|
+
const shot = await mcp.callTool("take_screenshot", {});
|
|
793
|
+
const shotResult = toResult(shot);
|
|
794
|
+
return {
|
|
795
|
+
ok: true,
|
|
796
|
+
text: filteredText,
|
|
797
|
+
images: shotResult.ok ? shotResult.images : void 0
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
return { ok: true, text: filteredText };
|
|
801
|
+
},
|
|
802
|
+
browser_screenshot: async (mcp, args) => {
|
|
803
|
+
return toResult(
|
|
804
|
+
await mcp.callTool("take_screenshot", {
|
|
805
|
+
uid: normalizeUid(args.ref),
|
|
806
|
+
fullPage: args.fullPage
|
|
807
|
+
})
|
|
808
|
+
);
|
|
809
|
+
},
|
|
810
|
+
browser_evaluate: async (mcp, args) => {
|
|
811
|
+
const fn = args.function;
|
|
812
|
+
return toResult(await mcp.callTool("evaluate_script", { function: fn }));
|
|
813
|
+
},
|
|
814
|
+
browser_console_messages: async (mcp, args) => {
|
|
815
|
+
const res = await mcp.callTool("list_console_messages", {});
|
|
816
|
+
const result = toResult(res);
|
|
817
|
+
if (!result.ok) return result;
|
|
818
|
+
if (args.onlyErrors) {
|
|
819
|
+
const filtered = result.text.split("\n").filter((l) => /error/i.test(l)).join("\n");
|
|
820
|
+
return { ok: true, text: filtered || "(no errors)" };
|
|
821
|
+
}
|
|
822
|
+
return result;
|
|
823
|
+
},
|
|
824
|
+
browser_network_requests: async (mcp) => {
|
|
825
|
+
return toResult(await mcp.callTool("list_network_requests", {}));
|
|
826
|
+
},
|
|
827
|
+
browser_click: async (mcp, args) => {
|
|
828
|
+
const ref = normalizeUid(args.ref);
|
|
829
|
+
const x = args.x;
|
|
830
|
+
const y = args.y;
|
|
831
|
+
if (ref) {
|
|
832
|
+
return toResult(
|
|
833
|
+
await mcp.callTool("click", {
|
|
834
|
+
uid: ref,
|
|
835
|
+
dblClick: args.doubleClick
|
|
836
|
+
})
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
if (typeof x === "number" && typeof y === "number") {
|
|
840
|
+
const dbl = args.doubleClick === true;
|
|
841
|
+
const fn = `() => { const el = document.elementFromPoint(${x}, ${y}); if (!el) throw new Error('No element at (${x},${y})'); el.${dbl ? "click(); el.click" : "click"}(); return el.outerHTML.slice(0, 200); }`;
|
|
842
|
+
return toResult(await mcp.callTool("evaluate_script", { function: fn }));
|
|
843
|
+
}
|
|
844
|
+
return { ok: false, error: "browser_click requires either ref or (x, y)" };
|
|
845
|
+
},
|
|
846
|
+
browser_hover: async (mcp, args) => {
|
|
847
|
+
return toResult(await mcp.callTool("hover", { uid: normalizeUid(args.ref) }));
|
|
848
|
+
},
|
|
849
|
+
browser_drag: async (mcp, args) => {
|
|
850
|
+
const startRef = normalizeUid(args.startRef);
|
|
851
|
+
const endRef = normalizeUid(args.endRef);
|
|
852
|
+
if (!startRef || !endRef) {
|
|
853
|
+
return {
|
|
854
|
+
ok: false,
|
|
855
|
+
error: "browser_drag in attached mode requires startRef and endRef (coordinate drag not supported)."
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
return toResult(await mcp.callTool("drag", { from_uid: startRef, to_uid: endRef }));
|
|
859
|
+
},
|
|
860
|
+
browser_type: async (mcp, args) => {
|
|
861
|
+
const ref = normalizeUid(args.ref);
|
|
862
|
+
const text = args.text;
|
|
863
|
+
const submit = args.submit;
|
|
864
|
+
if (ref) {
|
|
865
|
+
const fill = await mcp.callTool("fill", { uid: ref, value: text });
|
|
866
|
+
const fillResult = toResult(fill);
|
|
867
|
+
if (!fillResult.ok || !submit) return fillResult;
|
|
868
|
+
const press = await mcp.callTool("press_key", { key: "Enter" });
|
|
869
|
+
const pressResult = toResult(press);
|
|
870
|
+
if (!pressResult.ok) return pressResult;
|
|
871
|
+
return { ok: true, text: `${fillResult.text}
|
|
872
|
+
${pressResult.text}`.trim() };
|
|
873
|
+
}
|
|
874
|
+
const typed = await mcp.callTool("type_text", {
|
|
875
|
+
text,
|
|
876
|
+
submitKey: submit ? "Enter" : void 0
|
|
877
|
+
});
|
|
878
|
+
return toResult(typed);
|
|
879
|
+
},
|
|
880
|
+
browser_press_key: async (mcp, args) => {
|
|
881
|
+
return toResult(await mcp.callTool("press_key", { key: args.key }));
|
|
882
|
+
},
|
|
883
|
+
browser_fill_form: async (mcp, args) => {
|
|
884
|
+
const fields = args.fields;
|
|
885
|
+
if (!Array.isArray(fields)) return { ok: false, error: "browser_fill_form requires fields[]" };
|
|
886
|
+
const elements = fields.map((f) => ({ uid: normalizeUid(f.ref), value: f.value }));
|
|
887
|
+
return toResult(await mcp.callTool("fill_form", { elements }));
|
|
888
|
+
},
|
|
889
|
+
browser_select_option: async (mcp, args) => {
|
|
890
|
+
return toResult(
|
|
891
|
+
await mcp.callTool("fill", { uid: normalizeUid(args.ref), value: args.value })
|
|
892
|
+
);
|
|
893
|
+
},
|
|
894
|
+
browser_file_upload: async (mcp, args) => {
|
|
895
|
+
const paths = args.paths;
|
|
896
|
+
const ref = normalizeUid(args.ref);
|
|
897
|
+
if (!paths || paths.length === 0 || !ref) {
|
|
898
|
+
return { ok: false, error: "browser_file_upload in attached mode requires ref and a single path" };
|
|
899
|
+
}
|
|
900
|
+
return toResult(await mcp.callTool("upload_file", { uid: ref, filePath: paths[0] }));
|
|
901
|
+
},
|
|
902
|
+
browser_scroll: async (mcp, args) => {
|
|
903
|
+
const direction = args.direction;
|
|
904
|
+
const amount = args.amount ?? 3;
|
|
905
|
+
const pixels = amount * 300;
|
|
906
|
+
let dx = 0;
|
|
907
|
+
let dy = 0;
|
|
908
|
+
if (direction === "down") dy = pixels;
|
|
909
|
+
else if (direction === "up") dy = -pixels;
|
|
910
|
+
else if (direction === "right") dx = pixels;
|
|
911
|
+
else if (direction === "left") dx = -pixels;
|
|
912
|
+
const fn = `() => { window.scrollBy(${dx}, ${dy}); return { scrollX: window.scrollX, scrollY: window.scrollY }; }`;
|
|
913
|
+
return toResult(await mcp.callTool("evaluate_script", { function: fn }));
|
|
914
|
+
},
|
|
915
|
+
browser_handle_dialog: async (mcp, args) => {
|
|
916
|
+
return toResult(
|
|
917
|
+
await mcp.callTool("handle_dialog", {
|
|
918
|
+
action: args.action,
|
|
919
|
+
promptText: args.promptText
|
|
920
|
+
})
|
|
921
|
+
);
|
|
922
|
+
},
|
|
923
|
+
browser_dismiss_overlay: async (mcp) => {
|
|
924
|
+
return toResult(await mcp.callTool("press_key", { key: "Escape" }));
|
|
925
|
+
},
|
|
926
|
+
browser_wait_for: async (mcp, args) => {
|
|
927
|
+
const text = args.text;
|
|
928
|
+
const timeSec = args.timeSec;
|
|
929
|
+
const timeout = args.timeout;
|
|
930
|
+
if (text) {
|
|
931
|
+
return toResult(await mcp.callTool("wait_for", { text: [text], timeout }));
|
|
932
|
+
}
|
|
933
|
+
if (typeof timeSec === "number") {
|
|
934
|
+
const fn = `async () => { await new Promise(r => setTimeout(r, ${Math.round(timeSec * 1e3)})); return 'waited ${timeSec}s'; }`;
|
|
935
|
+
return toResult(await mcp.callTool("evaluate_script", { function: fn }));
|
|
936
|
+
}
|
|
937
|
+
return { ok: false, error: "browser_wait_for in attached mode requires text or time" };
|
|
938
|
+
},
|
|
939
|
+
browser_close: async () => {
|
|
940
|
+
return { ok: true, text: "browser_close is a no-op in attached mode (the user owns the browser)." };
|
|
941
|
+
},
|
|
942
|
+
browser_resize: async (mcp, args) => {
|
|
943
|
+
return toResult(
|
|
944
|
+
await mcp.callTool("resize_page", {
|
|
945
|
+
width: args.width,
|
|
946
|
+
height: args.height
|
|
947
|
+
})
|
|
948
|
+
);
|
|
949
|
+
},
|
|
950
|
+
browser_tabs: async (mcp, args) => {
|
|
951
|
+
const action = args.action;
|
|
952
|
+
const index = args.index;
|
|
953
|
+
switch (action) {
|
|
954
|
+
case "list":
|
|
955
|
+
return toResult(await mcp.callTool("list_pages", {}));
|
|
956
|
+
case "new":
|
|
957
|
+
return toResult(await mcp.callTool("new_page", { url: "about:blank" }));
|
|
958
|
+
case "close":
|
|
959
|
+
if (typeof index !== "number")
|
|
960
|
+
return { ok: false, error: "browser_tabs close requires an index" };
|
|
961
|
+
return toResult(await mcp.callTool("close_page", { pageId: index }));
|
|
962
|
+
case "select":
|
|
963
|
+
if (typeof index !== "number")
|
|
964
|
+
return { ok: false, error: "browser_tabs select requires an index" };
|
|
965
|
+
return toResult(await mcp.callTool("select_page", { pageId: index, bringToFront: true }));
|
|
966
|
+
default:
|
|
967
|
+
return { ok: false, error: `browser_tabs: unknown action "${String(action)}"` };
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
browser_list_downloads: async () => notSupportedInAttachedMode("browser_list_downloads"),
|
|
971
|
+
browser_read_download: async () => notSupportedInAttachedMode("browser_read_download"),
|
|
972
|
+
browser_pdf_read: async () => notSupportedInAttachedMode("browser_pdf_read")
|
|
973
|
+
};
|
|
974
|
+
async function translateAndCall(mcp, toolName, args) {
|
|
975
|
+
const translator = translators[toolName];
|
|
976
|
+
if (!translator) {
|
|
977
|
+
return { ok: false, error: `Unknown browser tool: ${toolName}` };
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
return await translator(mcp, args);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// src/session/cdp-proxy/index.ts
|
|
987
|
+
var CdpProxyClient = class {
|
|
988
|
+
mcp;
|
|
989
|
+
constructor(logger) {
|
|
990
|
+
this.mcp = new ChromeDevtoolsMcpClient(logger);
|
|
991
|
+
}
|
|
992
|
+
async start() {
|
|
993
|
+
await this.mcp.start();
|
|
994
|
+
}
|
|
995
|
+
async callByCanaryName(toolName, args) {
|
|
996
|
+
const fullName = toolName.startsWith("browser_") ? toolName : `browser_${toolName}`;
|
|
997
|
+
return translateAndCall(this.mcp, fullName, args);
|
|
998
|
+
}
|
|
999
|
+
async shutdown() {
|
|
1000
|
+
await this.mcp.shutdown();
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
// src/session/daemon.ts
|
|
1005
|
+
var PIDFILE_DIR = path2.join(os2.homedir(), ".config", "canary-cli");
|
|
1006
|
+
var PIDFILE_PATH = path2.join(PIDFILE_DIR, "daemon.json");
|
|
1007
|
+
var IDLE_TIMEOUT_MS = 6e4;
|
|
1008
|
+
var SESSION_IDLE_TIMEOUT_MS = (() => {
|
|
1009
|
+
const env = process.env.CANARY_SESSION_IDLE_TIMEOUT_MS;
|
|
1010
|
+
if (env) {
|
|
1011
|
+
const n = Number(env);
|
|
1012
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
1013
|
+
}
|
|
1014
|
+
return 60 * 60 * 1e3;
|
|
1015
|
+
})();
|
|
1016
|
+
var SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1017
|
+
function isAttached(s) {
|
|
1018
|
+
return s.kind === "attached";
|
|
1019
|
+
}
|
|
1020
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
1021
|
+
var nextId = 1;
|
|
1022
|
+
var idleTimer = null;
|
|
1023
|
+
var idleSweepTimer = null;
|
|
1024
|
+
function touch(s) {
|
|
1025
|
+
s.lastActivityAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1026
|
+
}
|
|
1027
|
+
function formatIdle(ms) {
|
|
1028
|
+
const seconds = Math.floor(ms / 1e3);
|
|
1029
|
+
if (seconds < 60) return `${seconds}s`;
|
|
1030
|
+
const minutes = Math.floor(seconds / 60);
|
|
1031
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
1032
|
+
const hours = Math.floor(minutes / 60);
|
|
1033
|
+
return `${hours}h ${minutes % 60}m`;
|
|
1034
|
+
}
|
|
1035
|
+
function resetIdleTimer() {
|
|
1036
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1037
|
+
if (sessions.size === 0) {
|
|
1038
|
+
idleTimer = setTimeout(async () => {
|
|
1039
|
+
if (sessions.size === 0) {
|
|
1040
|
+
await removePidfile();
|
|
1041
|
+
process.exit(0);
|
|
1042
|
+
}
|
|
1043
|
+
}, IDLE_TIMEOUT_MS);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function generateId() {
|
|
1047
|
+
return `s${nextId++}`;
|
|
1048
|
+
}
|
|
1049
|
+
async function getSessionUrl(session) {
|
|
1050
|
+
if (isAttached(session)) {
|
|
1051
|
+
return void 0;
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
return await session.client.getCurrentUrl();
|
|
1055
|
+
} catch {
|
|
1056
|
+
return void 0;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
async function toSessionInfo(s) {
|
|
1060
|
+
if (isAttached(s)) {
|
|
1061
|
+
return {
|
|
1062
|
+
id: s.id,
|
|
1063
|
+
name: s.name,
|
|
1064
|
+
mode: "attached",
|
|
1065
|
+
credentialName: void 0,
|
|
1066
|
+
url: void 0,
|
|
1067
|
+
startedAt: s.startedAt,
|
|
1068
|
+
lastActivityAt: s.lastActivityAt,
|
|
1069
|
+
lock: s.lock
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
id: s.id,
|
|
1074
|
+
name: s.name,
|
|
1075
|
+
mode: s.mode,
|
|
1076
|
+
credentialName: s.credentialName,
|
|
1077
|
+
url: await getSessionUrl(s),
|
|
1078
|
+
startedAt: s.startedAt,
|
|
1079
|
+
lastActivityAt: s.lastActivityAt,
|
|
1080
|
+
lock: s.lock,
|
|
1081
|
+
cloudRelay: s.localBrowserSessionId ? { localBrowserSessionId: s.localBrowserSessionId, connected: !!s.relayHost } : void 0
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
async function readBody(req) {
|
|
1085
|
+
const chunks = [];
|
|
1086
|
+
for await (const chunk of req) {
|
|
1087
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1088
|
+
}
|
|
1089
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
1090
|
+
}
|
|
1091
|
+
function parseJson(body) {
|
|
1092
|
+
if (!body.trim()) return {};
|
|
1093
|
+
try {
|
|
1094
|
+
return JSON.parse(body);
|
|
1095
|
+
} catch {
|
|
1096
|
+
return {};
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function json(res, status, data) {
|
|
1100
|
+
const body = JSON.stringify(data);
|
|
1101
|
+
res.writeHead(status, {
|
|
1102
|
+
"Content-Type": "application/json",
|
|
1103
|
+
"Content-Length": Buffer.byteLength(body)
|
|
1104
|
+
});
|
|
1105
|
+
res.end(body);
|
|
1106
|
+
}
|
|
1107
|
+
function parseRoute(url) {
|
|
1108
|
+
const [pathname] = url.split("?");
|
|
1109
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
1110
|
+
return { path: segments, query: {} };
|
|
1111
|
+
}
|
|
1112
|
+
async function setupCloudRelay(session, apiUrl, token) {
|
|
1113
|
+
const res = await fetch(`${apiUrl}/local-browser/sessions`, {
|
|
1114
|
+
method: "POST",
|
|
1115
|
+
headers: {
|
|
1116
|
+
"Content-Type": "application/json",
|
|
1117
|
+
Authorization: `Bearer ${token}`
|
|
1118
|
+
},
|
|
1119
|
+
body: JSON.stringify({ browserMode: "playwright" })
|
|
1120
|
+
});
|
|
1121
|
+
if (!res.ok) {
|
|
1122
|
+
const text = await res.text();
|
|
1123
|
+
throw new Error(`Failed to create cloud session: ${res.status} ${text}`);
|
|
1124
|
+
}
|
|
1125
|
+
const cloudSession = await res.json();
|
|
1126
|
+
if (!cloudSession.ok) {
|
|
1127
|
+
throw new Error(`Cloud session creation failed: ${cloudSession.error}`);
|
|
1128
|
+
}
|
|
1129
|
+
session.localBrowserSessionId = cloudSession.sessionId;
|
|
1130
|
+
const host = new LocalBrowserHost({
|
|
1131
|
+
apiUrl,
|
|
1132
|
+
wsToken: cloudSession.wsToken,
|
|
1133
|
+
sessionId: cloudSession.sessionId,
|
|
1134
|
+
browserMode: "playwright",
|
|
1135
|
+
existingClient: session.client,
|
|
1136
|
+
skipBrowserLaunch: true,
|
|
1137
|
+
shouldExecute: () => session.lock.controller === "cloud",
|
|
1138
|
+
onLog: (level, message, data) => {
|
|
1139
|
+
const fn = level === "error" ? consoleLogger.error : level === "warn" ? consoleLogger.warn : consoleLogger.info;
|
|
1140
|
+
fn(`[relay:${session.id}] ${message}`, data);
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
await host.start();
|
|
1144
|
+
session.relayHost = host;
|
|
1145
|
+
}
|
|
1146
|
+
async function handleHealth(res) {
|
|
1147
|
+
json(res, 200, { ok: true, sessions: sessions.size });
|
|
1148
|
+
}
|
|
1149
|
+
async function handleCreateSession(body, res) {
|
|
1150
|
+
const params = parseJson(body);
|
|
1151
|
+
const id = generateId();
|
|
1152
|
+
const name = params.name ?? id;
|
|
1153
|
+
if (params.attachRealChrome) {
|
|
1154
|
+
const conflicts = [];
|
|
1155
|
+
if (params.headless) conflicts.push("headless");
|
|
1156
|
+
if (params.credentialName) conflicts.push("credential");
|
|
1157
|
+
if (params.storageStatePath) conflicts.push("storage-state");
|
|
1158
|
+
if (params.url) conflicts.push("url");
|
|
1159
|
+
if (params.cloudRelay) conflicts.push("cloud-relay");
|
|
1160
|
+
if (conflicts.length > 0) {
|
|
1161
|
+
json(res, 400, {
|
|
1162
|
+
ok: false,
|
|
1163
|
+
error: `--attach-real-chrome cannot be combined with: ${conflicts.join(", ")} (attached mode uses the live browser's profile and tabs).`
|
|
1164
|
+
});
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
for (const existing of sessions.values()) {
|
|
1168
|
+
if (existing.kind === "attached") {
|
|
1169
|
+
json(res, 409, {
|
|
1170
|
+
ok: false,
|
|
1171
|
+
error: `Another attached session is already active ("${existing.id}"). Stop it first with: canary session stop ${existing.id}`
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
try {
|
|
1177
|
+
const cdpProxy = new CdpProxyClient();
|
|
1178
|
+
await cdpProxy.start();
|
|
1179
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1180
|
+
const session = {
|
|
1181
|
+
kind: "attached",
|
|
1182
|
+
id,
|
|
1183
|
+
name,
|
|
1184
|
+
mode: "attached",
|
|
1185
|
+
startedAt: now,
|
|
1186
|
+
lastActivityAt: now,
|
|
1187
|
+
lock: { controller: "cli", acquiredAt: now, cloudPaused: false },
|
|
1188
|
+
cdpProxy
|
|
1189
|
+
};
|
|
1190
|
+
sessions.set(id, session);
|
|
1191
|
+
if (idleTimer) {
|
|
1192
|
+
clearTimeout(idleTimer);
|
|
1193
|
+
idleTimer = null;
|
|
1194
|
+
}
|
|
1195
|
+
json(res, 201, {
|
|
1196
|
+
ok: true,
|
|
1197
|
+
data: await toSessionInfo(session)
|
|
1198
|
+
});
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
json(res, 500, {
|
|
1201
|
+
ok: false,
|
|
1202
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
const mode = params.headless ? "headless" : "headed";
|
|
1208
|
+
try {
|
|
1209
|
+
const client = new PlaywrightClient({ logger: consoleLogger });
|
|
1210
|
+
await client.connect({
|
|
1211
|
+
browserMode: mode,
|
|
1212
|
+
storageStatePath: params.storageStatePath,
|
|
1213
|
+
cursorOverlay: mode === "headed"
|
|
1214
|
+
});
|
|
1215
|
+
const executor = new BrowserToolExecutor(client, {
|
|
1216
|
+
autoSnapshotAfterAction: true,
|
|
1217
|
+
includeScreenshotWithSnapshot: true,
|
|
1218
|
+
clickValidation: true,
|
|
1219
|
+
logger: consoleLogger
|
|
1220
|
+
});
|
|
1221
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1222
|
+
const session = {
|
|
1223
|
+
kind: "playwright",
|
|
1224
|
+
id,
|
|
1225
|
+
name,
|
|
1226
|
+
mode,
|
|
1227
|
+
credentialName: params.credentialName,
|
|
1228
|
+
client,
|
|
1229
|
+
executor,
|
|
1230
|
+
startedAt: now,
|
|
1231
|
+
lastActivityAt: now,
|
|
1232
|
+
lock: { controller: "cli", acquiredAt: now, cloudPaused: false },
|
|
1233
|
+
userPaused: false,
|
|
1234
|
+
userPausePollTimer: null,
|
|
1235
|
+
pauseEnrichedEvents: [],
|
|
1236
|
+
pauseResumeSnapshot: null
|
|
1237
|
+
};
|
|
1238
|
+
if (mode === "headed") {
|
|
1239
|
+
await injectCaptureScript(client);
|
|
1240
|
+
const page = await client.getPageForReplay();
|
|
1241
|
+
if (page) {
|
|
1242
|
+
try {
|
|
1243
|
+
await page.addInitScript(INTERACTIVE_BADGE_SCRIPT);
|
|
1244
|
+
} catch {
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
await updateControllerBadge(client, "cli", { interactive: true });
|
|
1248
|
+
}
|
|
1249
|
+
sessions.set(id, session);
|
|
1250
|
+
if (idleTimer) {
|
|
1251
|
+
clearTimeout(idleTimer);
|
|
1252
|
+
idleTimer = null;
|
|
1253
|
+
}
|
|
1254
|
+
if (params.cloudRelay) {
|
|
1255
|
+
try {
|
|
1256
|
+
await setupCloudRelay(session, params.cloudRelay.apiUrl, params.cloudRelay.token);
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
consoleLogger.warn(`Cloud relay setup failed: ${err}`);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const result = {
|
|
1262
|
+
ok: true,
|
|
1263
|
+
data: await toSessionInfo(session)
|
|
1264
|
+
};
|
|
1265
|
+
json(res, 201, result);
|
|
1266
|
+
if (params.url) {
|
|
1267
|
+
client.navigate(params.url).catch((err) => {
|
|
1268
|
+
consoleLogger.warn(`Initial navigation to ${params.url} failed: ${err}`);
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
const result = {
|
|
1273
|
+
ok: false,
|
|
1274
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1275
|
+
};
|
|
1276
|
+
json(res, 500, result);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
async function handleListSessions(res) {
|
|
1280
|
+
const list = await Promise.all(Array.from(sessions.values()).map(toSessionInfo));
|
|
1281
|
+
json(res, 200, { ok: true, data: list });
|
|
1282
|
+
}
|
|
1283
|
+
async function handleGetSession(sessionId, res) {
|
|
1284
|
+
const session = sessions.get(sessionId);
|
|
1285
|
+
if (!session) {
|
|
1286
|
+
json(res, 404, {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
error: `Session "${sessionId}" not found`
|
|
1289
|
+
});
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
json(res, 200, {
|
|
1293
|
+
ok: true,
|
|
1294
|
+
data: await toSessionInfo(session)
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
async function teardownSession(session) {
|
|
1298
|
+
if (isAttached(session)) {
|
|
1299
|
+
try {
|
|
1300
|
+
await session.cdpProxy.shutdown();
|
|
1301
|
+
} catch {
|
|
1302
|
+
}
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
cleanupPauseTimer(session);
|
|
1306
|
+
if (session.relayHost) {
|
|
1307
|
+
try {
|
|
1308
|
+
await session.relayHost.stop();
|
|
1309
|
+
} catch {
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
try {
|
|
1313
|
+
await session.client.disconnect();
|
|
1314
|
+
} catch {
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
async function stopSession(sessionId, reason) {
|
|
1318
|
+
const session = sessions.get(sessionId);
|
|
1319
|
+
if (!session) return false;
|
|
1320
|
+
if (reason === "idle") {
|
|
1321
|
+
const idleMs = Date.now() - Date.parse(session.lastActivityAt);
|
|
1322
|
+
consoleLogger.info(
|
|
1323
|
+
`[daemon] auto-stopping session ${session.id} (${session.name}) \u2014 idle ${formatIdle(idleMs)}`
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
await teardownSession(session);
|
|
1327
|
+
sessions.delete(sessionId);
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
async function handleDeleteSession(sessionId, res) {
|
|
1331
|
+
const stopped = await stopSession(sessionId, "manual");
|
|
1332
|
+
if (!stopped) {
|
|
1333
|
+
json(res, 404, {
|
|
1334
|
+
ok: false,
|
|
1335
|
+
error: `Session "${sessionId}" not found`
|
|
1336
|
+
});
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
json(res, 200, { ok: true });
|
|
1340
|
+
resetIdleTimer();
|
|
1341
|
+
}
|
|
1342
|
+
async function handleDeleteAllSessions(res) {
|
|
1343
|
+
const ids = Array.from(sessions.keys());
|
|
1344
|
+
for (const id of ids) {
|
|
1345
|
+
await stopSession(id, "all");
|
|
1346
|
+
}
|
|
1347
|
+
json(res, 200, { ok: true });
|
|
1348
|
+
resetIdleTimer();
|
|
1349
|
+
}
|
|
1350
|
+
async function sweepIdleSessions() {
|
|
1351
|
+
const now = Date.now();
|
|
1352
|
+
const toStop = [];
|
|
1353
|
+
for (const [id, s] of sessions) {
|
|
1354
|
+
if (s.lock.controller === "cloud") continue;
|
|
1355
|
+
const idleMs = now - Date.parse(s.lastActivityAt);
|
|
1356
|
+
if (idleMs >= SESSION_IDLE_TIMEOUT_MS) toStop.push(id);
|
|
1357
|
+
}
|
|
1358
|
+
for (const id of toStop) {
|
|
1359
|
+
try {
|
|
1360
|
+
await stopSession(id, "idle");
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
consoleLogger.warn(`[daemon] idle-stop failed for ${id}: ${err}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (toStop.length > 0) resetIdleTimer();
|
|
1366
|
+
}
|
|
1367
|
+
async function handleSwapContext(sessionId, body, res) {
|
|
1368
|
+
const session = sessions.get(sessionId);
|
|
1369
|
+
if (!session) {
|
|
1370
|
+
json(res, 404, {
|
|
1371
|
+
ok: false,
|
|
1372
|
+
error: `Session "${sessionId}" not found`
|
|
1373
|
+
});
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (isAttached(session)) {
|
|
1377
|
+
json(res, 400, {
|
|
1378
|
+
ok: false,
|
|
1379
|
+
error: "swap-context is not supported in attached mode (real Chrome owns the profile)."
|
|
1380
|
+
});
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
touch(session);
|
|
1384
|
+
const params = parseJson(body);
|
|
1385
|
+
try {
|
|
1386
|
+
await session.client.swapContext({
|
|
1387
|
+
storageStatePath: params.storageStatePath
|
|
1388
|
+
});
|
|
1389
|
+
if (params.credentialName) {
|
|
1390
|
+
session.credentialName = params.credentialName;
|
|
1391
|
+
}
|
|
1392
|
+
json(res, 200, {
|
|
1393
|
+
ok: true,
|
|
1394
|
+
data: await toSessionInfo(session)
|
|
1395
|
+
});
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
json(res, 500, {
|
|
1398
|
+
ok: false,
|
|
1399
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
async function handleGetStorageState(sessionId, res) {
|
|
1404
|
+
const session = sessions.get(sessionId);
|
|
1405
|
+
if (!session) {
|
|
1406
|
+
json(res, 404, {
|
|
1407
|
+
ok: false,
|
|
1408
|
+
error: `Session "${sessionId}" not found`
|
|
1409
|
+
});
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (isAttached(session)) {
|
|
1413
|
+
json(res, 400, {
|
|
1414
|
+
ok: false,
|
|
1415
|
+
error: "storage-state is not supported in attached mode."
|
|
1416
|
+
});
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
try {
|
|
1420
|
+
const storageState = await session.client.getStorageState();
|
|
1421
|
+
json(res, 200, { ok: true, data: storageState });
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
json(res, 500, {
|
|
1424
|
+
ok: false,
|
|
1425
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
async function handleToolCall(sessionId, toolName, body, res) {
|
|
1430
|
+
const session = sessions.get(sessionId);
|
|
1431
|
+
if (!session) {
|
|
1432
|
+
json(res, 404, {
|
|
1433
|
+
ok: false,
|
|
1434
|
+
error: `Session "${sessionId}" not found`
|
|
1435
|
+
});
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
if (session.lock.controller === "cloud") {
|
|
1439
|
+
json(res, 423, {
|
|
1440
|
+
ok: false,
|
|
1441
|
+
error: "Session is controlled by cloud. Use take-control to force CLI control."
|
|
1442
|
+
});
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
touch(session);
|
|
1446
|
+
if (isAttached(session)) {
|
|
1447
|
+
const args2 = parseJson(body);
|
|
1448
|
+
const fullToolName2 = toolName.startsWith("browser_") ? toolName : `browser_${toolName}`;
|
|
1449
|
+
try {
|
|
1450
|
+
const result = await session.cdpProxy.callByCanaryName(fullToolName2, args2);
|
|
1451
|
+
if (!result.ok) {
|
|
1452
|
+
json(res, 500, { ok: false, error: result.error });
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
json(res, 200, {
|
|
1456
|
+
ok: true,
|
|
1457
|
+
text: result.text,
|
|
1458
|
+
images: result.images
|
|
1459
|
+
});
|
|
1460
|
+
} catch (err) {
|
|
1461
|
+
json(res, 500, {
|
|
1462
|
+
ok: false,
|
|
1463
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const { paused, events: drainedEvents } = await checkPauseAndDrainEvents(session);
|
|
1469
|
+
if (paused) {
|
|
1470
|
+
startPauseEnrichment(session);
|
|
1471
|
+
json(res, 423, {
|
|
1472
|
+
ok: false,
|
|
1473
|
+
error: 'User has taken control of the browser. They will resume when ready. Do not retry \u2014 wait for the user to click "Resume Agent" in the browser.',
|
|
1474
|
+
errorCode: USER_PAUSED_ERROR
|
|
1475
|
+
});
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (session.userPausePollTimer || session.pauseEnrichedEvents.length > 0) {
|
|
1479
|
+
await stopPauseEnrichment(session);
|
|
1480
|
+
}
|
|
1481
|
+
const userEvents = [
|
|
1482
|
+
...session.pauseEnrichedEvents,
|
|
1483
|
+
...drainedEvents
|
|
1484
|
+
];
|
|
1485
|
+
session.pauseEnrichedEvents = [];
|
|
1486
|
+
const pauseSnapshot = session.pauseResumeSnapshot;
|
|
1487
|
+
session.pauseResumeSnapshot = null;
|
|
1488
|
+
const args = parseJson(body);
|
|
1489
|
+
const fullToolName = toolName.startsWith("browser_") ? toolName : `browser_${toolName}`;
|
|
1490
|
+
try {
|
|
1491
|
+
const result = await dispatchBrowserToolWithTimeout(session.executor, fullToolName, args);
|
|
1492
|
+
await discardCapturedEvents(session);
|
|
1493
|
+
let text;
|
|
1494
|
+
let images;
|
|
1495
|
+
if (typeof result === "string") {
|
|
1496
|
+
text = result;
|
|
1497
|
+
} else {
|
|
1498
|
+
text = result.text ?? "";
|
|
1499
|
+
images = result.images;
|
|
1500
|
+
}
|
|
1501
|
+
if (userEvents.length > 0) {
|
|
1502
|
+
const summary = formatUserActionsSummary(userEvents, pauseSnapshot);
|
|
1503
|
+
if (summary) {
|
|
1504
|
+
text = summary + "\n" + text;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
json(res, 200, { ok: true, text, images });
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
await discardCapturedEvents(session);
|
|
1510
|
+
json(res, 500, {
|
|
1511
|
+
ok: false,
|
|
1512
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
async function handleGetLock(sessionId, res) {
|
|
1517
|
+
const session = sessions.get(sessionId);
|
|
1518
|
+
if (!session) {
|
|
1519
|
+
json(res, 404, { ok: false, error: `Session "${sessionId}" not found` });
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
json(res, 200, { ok: true, data: session.lock });
|
|
1523
|
+
}
|
|
1524
|
+
async function handleTakeControl(sessionId, res) {
|
|
1525
|
+
const session = sessions.get(sessionId);
|
|
1526
|
+
if (!session) {
|
|
1527
|
+
json(res, 404, { ok: false, error: `Session "${sessionId}" not found` });
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (isAttached(session)) {
|
|
1531
|
+
json(res, 400, {
|
|
1532
|
+
ok: false,
|
|
1533
|
+
error: "Cloud lock operations are not supported in attached mode."
|
|
1534
|
+
});
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
touch(session);
|
|
1538
|
+
const wasClouded = session.lock.controller === "cloud";
|
|
1539
|
+
session.lock = {
|
|
1540
|
+
controller: "cli",
|
|
1541
|
+
acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1542
|
+
cloudPaused: wasClouded
|
|
1543
|
+
};
|
|
1544
|
+
await updateControllerBadge(session.client, "cli");
|
|
1545
|
+
json(res, 200, { ok: true, data: await toSessionInfo(session) });
|
|
1546
|
+
}
|
|
1547
|
+
async function handleResumeCloud(sessionId, res) {
|
|
1548
|
+
const session = sessions.get(sessionId);
|
|
1549
|
+
if (!session) {
|
|
1550
|
+
json(res, 404, { ok: false, error: `Session "${sessionId}" not found` });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (isAttached(session)) {
|
|
1554
|
+
json(res, 400, {
|
|
1555
|
+
ok: false,
|
|
1556
|
+
error: "Cloud lock operations are not supported in attached mode."
|
|
1557
|
+
});
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (!session.lock.cloudPaused) {
|
|
1561
|
+
json(res, 409, { ok: false, error: "Cloud is not paused" });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
touch(session);
|
|
1565
|
+
session.lock = {
|
|
1566
|
+
controller: "cloud",
|
|
1567
|
+
acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1568
|
+
cloudPaused: false
|
|
1569
|
+
};
|
|
1570
|
+
await updateControllerBadge(session.client, "cloud");
|
|
1571
|
+
json(res, 200, { ok: true, data: await toSessionInfo(session) });
|
|
1572
|
+
}
|
|
1573
|
+
async function handleAcquireCloud(sessionId, res) {
|
|
1574
|
+
const session = sessions.get(sessionId);
|
|
1575
|
+
if (!session) {
|
|
1576
|
+
json(res, 404, { ok: false, error: `Session "${sessionId}" not found` });
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (isAttached(session)) {
|
|
1580
|
+
json(res, 400, {
|
|
1581
|
+
ok: false,
|
|
1582
|
+
error: "Cloud lock operations are not supported in attached mode."
|
|
1583
|
+
});
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
touch(session);
|
|
1587
|
+
session.lock = {
|
|
1588
|
+
controller: "cloud",
|
|
1589
|
+
acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1590
|
+
cloudPaused: false
|
|
1591
|
+
};
|
|
1592
|
+
await updateControllerBadge(session.client, "cloud");
|
|
1593
|
+
json(res, 200, { ok: true, data: await toSessionInfo(session) });
|
|
1594
|
+
}
|
|
1595
|
+
async function handleReleaseCloud(sessionId, res) {
|
|
1596
|
+
const session = sessions.get(sessionId);
|
|
1597
|
+
if (!session) {
|
|
1598
|
+
json(res, 404, { ok: false, error: `Session "${sessionId}" not found` });
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (isAttached(session)) {
|
|
1602
|
+
json(res, 400, {
|
|
1603
|
+
ok: false,
|
|
1604
|
+
error: "Cloud lock operations are not supported in attached mode."
|
|
1605
|
+
});
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
touch(session);
|
|
1609
|
+
session.lock = {
|
|
1610
|
+
controller: "cli",
|
|
1611
|
+
acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1612
|
+
cloudPaused: false
|
|
1613
|
+
};
|
|
1614
|
+
await updateControllerBadge(session.client, "cli");
|
|
1615
|
+
json(res, 200, { ok: true, data: await toSessionInfo(session) });
|
|
1616
|
+
}
|
|
1617
|
+
async function handleRequest(req, res) {
|
|
1618
|
+
const method = req.method ?? "GET";
|
|
1619
|
+
const { path: segments } = parseRoute(req.url ?? "/");
|
|
1620
|
+
const body = method === "POST" || method === "DELETE" ? await readBody(req) : "";
|
|
1621
|
+
try {
|
|
1622
|
+
if (method === "GET" && segments[0] === "health") {
|
|
1623
|
+
await handleHealth(res);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
if (method === "POST" && segments[0] === "sessions" && segments.length === 1) {
|
|
1627
|
+
await handleCreateSession(body, res);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (method === "GET" && segments[0] === "sessions" && segments.length === 1) {
|
|
1631
|
+
await handleListSessions(res);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (method === "GET" && segments[0] === "sessions" && segments.length === 2) {
|
|
1635
|
+
await handleGetSession(segments[1], res);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
if (method === "DELETE" && segments[0] === "sessions" && segments.length === 1) {
|
|
1639
|
+
await handleDeleteAllSessions(res);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (method === "DELETE" && segments[0] === "sessions" && segments.length === 2) {
|
|
1643
|
+
await handleDeleteSession(segments[1], res);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (method === "POST" && segments[0] === "sessions" && segments[2] === "swap-context" && segments.length === 3) {
|
|
1647
|
+
await handleSwapContext(segments[1], body, res);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (method === "GET" && segments[0] === "sessions" && segments[2] === "storage-state" && segments.length === 3) {
|
|
1651
|
+
await handleGetStorageState(segments[1], res);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if (method === "GET" && segments[0] === "sessions" && segments[2] === "lock" && segments.length === 3) {
|
|
1655
|
+
await handleGetLock(segments[1], res);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (method === "POST" && segments[0] === "sessions" && segments[2] === "take-control" && segments.length === 3) {
|
|
1659
|
+
await handleTakeControl(segments[1], res);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
if (method === "POST" && segments[0] === "sessions" && segments[2] === "resume-cloud" && segments.length === 3) {
|
|
1663
|
+
await handleResumeCloud(segments[1], res);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (method === "POST" && segments[0] === "sessions" && segments[2] === "acquire-cloud" && segments.length === 3) {
|
|
1667
|
+
await handleAcquireCloud(segments[1], res);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (method === "POST" && segments[0] === "sessions" && segments[2] === "release-cloud" && segments.length === 3) {
|
|
1671
|
+
await handleReleaseCloud(segments[1], res);
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
if (method === "POST" && segments[0] === "sessions" && segments[2] === "tools" && segments.length === 4) {
|
|
1675
|
+
await handleToolCall(segments[1], segments[3], body, res);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
json(res, 404, { ok: false, error: "Not found" });
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
json(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
async function writePidfile(port) {
|
|
1684
|
+
await fs2.mkdir(PIDFILE_DIR, { recursive: true, mode: 448 });
|
|
1685
|
+
const state = {
|
|
1686
|
+
pid: process.pid,
|
|
1687
|
+
port,
|
|
1688
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1689
|
+
};
|
|
1690
|
+
await fs2.writeFile(PIDFILE_PATH, JSON.stringify(state, null, 2), { mode: 384 });
|
|
1691
|
+
}
|
|
1692
|
+
async function removePidfile() {
|
|
1693
|
+
try {
|
|
1694
|
+
await fs2.unlink(PIDFILE_PATH);
|
|
1695
|
+
} catch {
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
async function cleanup() {
|
|
1699
|
+
if (idleSweepTimer) {
|
|
1700
|
+
clearInterval(idleSweepTimer);
|
|
1701
|
+
idleSweepTimer = null;
|
|
1702
|
+
}
|
|
1703
|
+
for (const session of sessions.values()) {
|
|
1704
|
+
await teardownSession(session);
|
|
1705
|
+
}
|
|
1706
|
+
sessions.clear();
|
|
1707
|
+
await removePidfile();
|
|
1708
|
+
}
|
|
1709
|
+
async function startDaemon() {
|
|
1710
|
+
const server = createServer(handleRequest);
|
|
1711
|
+
return new Promise((resolve, reject) => {
|
|
1712
|
+
server.listen(0, "127.0.0.1", async () => {
|
|
1713
|
+
const addr = server.address();
|
|
1714
|
+
if (!addr || typeof addr === "string") {
|
|
1715
|
+
reject(new Error("Failed to get server address"));
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const port = addr.port;
|
|
1719
|
+
await writePidfile(port);
|
|
1720
|
+
resetIdleTimer();
|
|
1721
|
+
idleSweepTimer = setInterval(() => {
|
|
1722
|
+
sweepIdleSessions().catch((err) => {
|
|
1723
|
+
consoleLogger.warn(`[daemon] idle sweep failed: ${err}`);
|
|
1724
|
+
});
|
|
1725
|
+
}, SESSION_IDLE_SWEEP_INTERVAL_MS);
|
|
1726
|
+
idleSweepTimer.unref?.();
|
|
1727
|
+
process.on("SIGINT", async () => {
|
|
1728
|
+
await cleanup();
|
|
1729
|
+
process.exit(0);
|
|
1730
|
+
});
|
|
1731
|
+
process.on("SIGTERM", async () => {
|
|
1732
|
+
await cleanup();
|
|
1733
|
+
process.exit(0);
|
|
1734
|
+
});
|
|
1735
|
+
process.on("exit", () => {
|
|
1736
|
+
removePidfile().catch(() => {
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
resolve({ port });
|
|
1740
|
+
});
|
|
1741
|
+
server.on("error", reject);
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
async function runDaemon() {
|
|
1745
|
+
const { Console } = await import("console");
|
|
1746
|
+
global.console = new Console({ stdout: process.stderr, stderr: process.stderr });
|
|
1747
|
+
const { port } = await startDaemon();
|
|
1748
|
+
process.stdout.write(`DAEMON_READY:${port}
|
|
1749
|
+
`);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// src/session/index.ts
|
|
1753
|
+
function getScreenshotDir() {
|
|
1754
|
+
return path3.join(os3.homedir(), ".canary", "screenshots");
|
|
1755
|
+
}
|
|
1756
|
+
function cleanupScreenshots() {
|
|
1757
|
+
const dir = getScreenshotDir();
|
|
1758
|
+
if (!fs3.existsSync(dir)) return;
|
|
1759
|
+
for (const file of fs3.readdirSync(dir)) {
|
|
1760
|
+
fs3.unlinkSync(path3.join(dir, file));
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
function cleanupStaleScreenshots() {
|
|
1764
|
+
const dir = getScreenshotDir();
|
|
1765
|
+
if (!fs3.existsSync(dir)) return;
|
|
1766
|
+
const cutoff = Date.now() - 60 * 60 * 1e3;
|
|
1767
|
+
for (const file of fs3.readdirSync(dir)) {
|
|
1768
|
+
const filePath = path3.join(dir, file);
|
|
1769
|
+
try {
|
|
1770
|
+
const stat = fs3.statSync(filePath);
|
|
1771
|
+
if (stat.isFile() && stat.mtimeMs < cutoff) {
|
|
1772
|
+
fs3.unlinkSync(filePath);
|
|
1773
|
+
}
|
|
1774
|
+
} catch {
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
function formatDuration(since) {
|
|
1779
|
+
const seconds = Math.floor((Date.now() - new Date(since).getTime()) / 1e3);
|
|
1780
|
+
if (seconds < 60) return `${seconds}s`;
|
|
1781
|
+
const minutes = Math.floor(seconds / 60);
|
|
1782
|
+
const secs = seconds % 60;
|
|
1783
|
+
if (minutes < 60) return `${minutes}m ${secs}s`;
|
|
1784
|
+
const hours = Math.floor(minutes / 60);
|
|
1785
|
+
const mins = minutes % 60;
|
|
1786
|
+
return `${hours}h ${mins}m`;
|
|
1787
|
+
}
|
|
1788
|
+
function printSessionTable(sessions2) {
|
|
1789
|
+
if (sessions2.length === 0) {
|
|
1790
|
+
console.log("No active sessions.");
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const header = ["ID", "NAME", "MODE", "CREDENTIAL", "URL", "UPTIME", "IDLE"];
|
|
1794
|
+
const rows = sessions2.map((s) => [
|
|
1795
|
+
s.id,
|
|
1796
|
+
s.name,
|
|
1797
|
+
s.mode,
|
|
1798
|
+
s.credentialName ?? "(none)",
|
|
1799
|
+
s.url ?? "(none)",
|
|
1800
|
+
formatDuration(s.startedAt),
|
|
1801
|
+
formatDuration(s.lastActivityAt)
|
|
1802
|
+
]);
|
|
1803
|
+
const widths = header.map(
|
|
1804
|
+
(h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))
|
|
1805
|
+
);
|
|
1806
|
+
const pad = (str, width) => str.padEnd(width);
|
|
1807
|
+
console.log(header.map((h, i) => pad(h, widths[i])).join(" "));
|
|
1808
|
+
for (const row of rows) {
|
|
1809
|
+
console.log(row.map((cell, i) => pad(cell, widths[i])).join(" "));
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
function printToolResult(result, jsonMode) {
|
|
1813
|
+
if (jsonMode) {
|
|
1814
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
if (!result.ok) {
|
|
1818
|
+
console.error(`Error: ${result.error}`);
|
|
1819
|
+
process2.exitCode = 1;
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
if (result.text) {
|
|
1823
|
+
console.log(result.text);
|
|
1824
|
+
}
|
|
1825
|
+
if (result.images?.length) {
|
|
1826
|
+
const screenshotDir = getScreenshotDir();
|
|
1827
|
+
fs3.mkdirSync(screenshotDir, { recursive: true });
|
|
1828
|
+
for (const img of result.images) {
|
|
1829
|
+
const ext = img.mimeType === "image/png" ? "png" : "jpg";
|
|
1830
|
+
const filePath = path3.join(screenshotDir, `screenshot-${Date.now()}.${ext}`);
|
|
1831
|
+
fs3.writeFileSync(filePath, Buffer.from(img.data, "base64"));
|
|
1832
|
+
console.log(`Screenshot saved: ${filePath}`);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
async function handleStart(argv) {
|
|
1837
|
+
cleanupStaleScreenshots();
|
|
1838
|
+
const headless = hasFlag(argv, "--headless");
|
|
1839
|
+
const name = getArgValue(argv, "--name");
|
|
1840
|
+
const url = getArgValue(argv, "--url");
|
|
1841
|
+
const storageStatePath = getArgValue(argv, "--storage-state");
|
|
1842
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
1843
|
+
const attachRealChrome = hasFlag(argv, "--attach-real-chrome");
|
|
1844
|
+
if (attachRealChrome) {
|
|
1845
|
+
const conflicts = [];
|
|
1846
|
+
if (headless) conflicts.push("--headless");
|
|
1847
|
+
if (storageStatePath) conflicts.push("--storage-state");
|
|
1848
|
+
if (url) conflicts.push("--url");
|
|
1849
|
+
if (argv.includes("--credential")) conflicts.push("--credential");
|
|
1850
|
+
if (argv.includes("--cloud-relay")) conflicts.push("--cloud-relay");
|
|
1851
|
+
if (conflicts.length > 0) {
|
|
1852
|
+
console.error(
|
|
1853
|
+
`Error: --attach-real-chrome cannot be combined with: ${conflicts.join(", ")}`
|
|
1854
|
+
);
|
|
1855
|
+
console.error("Attached mode uses the live browser's profile and tabs.");
|
|
1856
|
+
process2.exitCode = 1;
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
const result2 = await createSession({ attachRealChrome: true, name });
|
|
1860
|
+
if (jsonMode) {
|
|
1861
|
+
console.log(JSON.stringify(result2, null, 2));
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (!result2.ok) {
|
|
1865
|
+
console.error(`Error: ${result2.error}`);
|
|
1866
|
+
console.error(
|
|
1867
|
+
"Hint: in your real Chrome, open chrome://inspect/#remote-debugging and approve remote debugging (Chrome 144+)."
|
|
1868
|
+
);
|
|
1869
|
+
process2.exitCode = 1;
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const session2 = result2.data;
|
|
1873
|
+
console.log(`Session "${session2.id}" attached to your real Chrome (M144+).`);
|
|
1874
|
+
console.log(' Chrome is showing the "controlled by automated test software" banner.');
|
|
1875
|
+
console.log(` Stop with: canary session stop ${session2.id}`);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
const credentialIdx = argv.indexOf("--credential");
|
|
1879
|
+
let resolvedCred = null;
|
|
1880
|
+
if (credentialIdx !== -1) {
|
|
1881
|
+
const nextArg = argv[credentialIdx + 1];
|
|
1882
|
+
const credentialArg = nextArg && !nextArg.startsWith("--") ? nextArg : void 0;
|
|
1883
|
+
resolvedCred = await resolveCredential(argv, credentialArg);
|
|
1884
|
+
if (!resolvedCred) {
|
|
1885
|
+
process2.exitCode = 1;
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
let cloudRelay;
|
|
1890
|
+
if (hasFlag(argv, "--cloud-relay")) {
|
|
1891
|
+
const config = await resolveConfig(argv);
|
|
1892
|
+
cloudRelay = { apiUrl: config.apiUrl, token: config.token };
|
|
1893
|
+
}
|
|
1894
|
+
const result = await createSession({
|
|
1895
|
+
headless,
|
|
1896
|
+
name,
|
|
1897
|
+
url,
|
|
1898
|
+
storageStatePath: resolvedCred?.storageStatePath ?? storageStatePath,
|
|
1899
|
+
credentialName: resolvedCred?.credentialName,
|
|
1900
|
+
cloudRelay
|
|
1901
|
+
});
|
|
1902
|
+
if (jsonMode) {
|
|
1903
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (!result.ok) {
|
|
1907
|
+
console.error(`Error: ${result.error}`);
|
|
1908
|
+
process2.exitCode = 1;
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const session = result.data;
|
|
1912
|
+
console.log(`Session "${session.id}" started (${session.mode})`);
|
|
1913
|
+
if (session.credentialName) {
|
|
1914
|
+
console.log(` Credential: ${session.credentialName}`);
|
|
1915
|
+
}
|
|
1916
|
+
const displayUrl = url ?? session.url;
|
|
1917
|
+
if (displayUrl) {
|
|
1918
|
+
console.log(` URL: ${displayUrl}`);
|
|
1919
|
+
}
|
|
1920
|
+
if (cloudRelay) {
|
|
1921
|
+
console.log(` Cloud relay: connecting...`);
|
|
1922
|
+
}
|
|
1923
|
+
if (!jsonMode) {
|
|
1924
|
+
const settingsDir = findClaudeSettingsDir();
|
|
1925
|
+
if (settingsDir && !hasScreenshotPermission(settingsDir)) {
|
|
1926
|
+
console.log("");
|
|
1927
|
+
console.log(" Tip: Claude needs permission to read screenshots.");
|
|
1928
|
+
console.log(" Run: canary session allow-screenshots");
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
async function handleList(argv) {
|
|
1933
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
1934
|
+
const result = await listSessions();
|
|
1935
|
+
if (jsonMode) {
|
|
1936
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
if (!result.ok) {
|
|
1940
|
+
console.error(`Error: ${result.error}`);
|
|
1941
|
+
process2.exitCode = 1;
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
printSessionTable(result.data ?? []);
|
|
1945
|
+
}
|
|
1946
|
+
async function handleStatus(argv) {
|
|
1947
|
+
const sessionId = getArgValue(argv, "--session") || (argv[0] && !argv[0].startsWith("--") ? argv[0] : void 0);
|
|
1948
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
1949
|
+
try {
|
|
1950
|
+
const target = await resolveTargetSession(sessionId);
|
|
1951
|
+
const result = await getSession(target.id);
|
|
1952
|
+
if (jsonMode) {
|
|
1953
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (!result.ok || !result.data) {
|
|
1957
|
+
console.error(`Error: ${result.error}`);
|
|
1958
|
+
process2.exitCode = 1;
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
const s = result.data;
|
|
1962
|
+
console.log(`Session: ${s.id} (${s.name})`);
|
|
1963
|
+
console.log(` Mode: ${s.mode}`);
|
|
1964
|
+
console.log(` Credential: ${s.credentialName ?? "(none)"}`);
|
|
1965
|
+
console.log(` URL: ${s.url ?? "(none)"}`);
|
|
1966
|
+
console.log(` Controller: ${s.lock.controller}${s.lock.cloudPaused ? " (cloud paused)" : ""}`);
|
|
1967
|
+
if (s.cloudRelay) {
|
|
1968
|
+
console.log(` Cloud relay: ${s.cloudRelay.connected ? "connected" : "disconnected"}`);
|
|
1969
|
+
}
|
|
1970
|
+
console.log(` Uptime: ${formatDuration(s.startedAt)}`);
|
|
1971
|
+
console.log(` Idle: ${formatDuration(s.lastActivityAt)}`);
|
|
1972
|
+
console.log(` Started: ${s.startedAt}`);
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1975
|
+
process2.exitCode = 1;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
async function handleStop(argv) {
|
|
1979
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
1980
|
+
if (hasFlag(argv, "--all")) {
|
|
1981
|
+
const result = await deleteAllSessions();
|
|
1982
|
+
if (result.ok) cleanupScreenshots();
|
|
1983
|
+
if (jsonMode) {
|
|
1984
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1985
|
+
} else if (result.ok) {
|
|
1986
|
+
console.log("All sessions stopped.");
|
|
1987
|
+
} else {
|
|
1988
|
+
console.error(`Error: ${result.error}`);
|
|
1989
|
+
process2.exitCode = 1;
|
|
1990
|
+
}
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
const sessionId = getArgValue(argv, "--session") || (argv[0] && !argv[0].startsWith("--") ? argv[0] : void 0);
|
|
1994
|
+
try {
|
|
1995
|
+
const target = await resolveTargetSession(sessionId);
|
|
1996
|
+
const result = await deleteSession(target.id);
|
|
1997
|
+
if (result.ok) {
|
|
1998
|
+
const remaining = await listSessions();
|
|
1999
|
+
if (remaining.ok && (!remaining.data || remaining.data.length === 0)) {
|
|
2000
|
+
cleanupScreenshots();
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
if (jsonMode) {
|
|
2004
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2005
|
+
} else if (result.ok) {
|
|
2006
|
+
console.log(`Session "${target.id}" stopped.`);
|
|
2007
|
+
} else {
|
|
2008
|
+
console.error(`Error: ${result.error}`);
|
|
2009
|
+
process2.exitCode = 1;
|
|
2010
|
+
}
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2013
|
+
process2.exitCode = 1;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
async function handleLoadCredential(argv) {
|
|
2017
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
2018
|
+
const sessionId = getArgValue(argv, "--session") || (argv[0] && !argv[0].startsWith("--") ? argv[0] : void 0);
|
|
2019
|
+
let target;
|
|
2020
|
+
try {
|
|
2021
|
+
target = await resolveTargetSession(sessionId);
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2024
|
+
process2.exitCode = 1;
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const credentialIdx = argv.indexOf("--credential");
|
|
2028
|
+
const nextArg = credentialIdx !== -1 ? argv[credentialIdx + 1] : void 0;
|
|
2029
|
+
const credentialArg = nextArg && !nextArg.startsWith("--") ? nextArg : void 0;
|
|
2030
|
+
const storageStatePath = getArgValue(argv, "--storage-state");
|
|
2031
|
+
let resolvedStorageStatePath;
|
|
2032
|
+
let credentialName;
|
|
2033
|
+
if (storageStatePath) {
|
|
2034
|
+
resolvedStorageStatePath = storageStatePath;
|
|
2035
|
+
credentialName = "(local storage state)";
|
|
2036
|
+
} else {
|
|
2037
|
+
const resolvedCred = await resolveCredential(argv, credentialArg);
|
|
2038
|
+
if (!resolvedCred) {
|
|
2039
|
+
process2.exitCode = 1;
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
resolvedStorageStatePath = resolvedCred.storageStatePath;
|
|
2043
|
+
credentialName = resolvedCred.credentialName;
|
|
2044
|
+
}
|
|
2045
|
+
const result = await swapSessionContext(target.id, {
|
|
2046
|
+
storageStatePath: resolvedStorageStatePath,
|
|
2047
|
+
credentialName
|
|
2048
|
+
});
|
|
2049
|
+
if (jsonMode) {
|
|
2050
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (!result.ok) {
|
|
2054
|
+
console.error(`Error: ${result.error}`);
|
|
2055
|
+
process2.exitCode = 1;
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const session = result.data;
|
|
2059
|
+
console.log(`Credential loaded into session "${session.id}".`);
|
|
2060
|
+
if (session.credentialName) {
|
|
2061
|
+
console.log(` Credential: ${session.credentialName}`);
|
|
2062
|
+
}
|
|
2063
|
+
if (session.url) {
|
|
2064
|
+
console.log(` URL: ${session.url}`);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
async function handleSaveCredential(argv) {
|
|
2068
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
2069
|
+
const sessionId = getArgValue(argv, "--session") || (argv[0] && !argv[0].startsWith("--") ? argv[0] : void 0);
|
|
2070
|
+
let target;
|
|
2071
|
+
try {
|
|
2072
|
+
target = await resolveTargetSession(sessionId);
|
|
2073
|
+
} catch (err) {
|
|
2074
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2075
|
+
process2.exitCode = 1;
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
const { apiUrl, token } = await resolveConfig(argv);
|
|
2079
|
+
const credentialIdx = argv.indexOf("--credential");
|
|
2080
|
+
const nextArg = credentialIdx !== -1 ? argv[credentialIdx + 1] : void 0;
|
|
2081
|
+
const credentialHint = nextArg && !nextArg.startsWith("--") ? nextArg : void 0;
|
|
2082
|
+
const credential = await selectCredential(apiUrl, token, credentialHint ?? target.credentialName);
|
|
2083
|
+
if (!credential) {
|
|
2084
|
+
console.error("Could not resolve credential. Use --credential <name|id>.");
|
|
2085
|
+
process2.exitCode = 1;
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
const storageResult = await getSessionStorageState(target.id);
|
|
2089
|
+
if (!storageResult.ok || !storageResult.data) {
|
|
2090
|
+
const msg = storageResult.error ?? "Unknown error";
|
|
2091
|
+
if (jsonMode) {
|
|
2092
|
+
console.log(JSON.stringify({ ok: false, error: msg }));
|
|
2093
|
+
} else {
|
|
2094
|
+
console.error(`Failed to extract storage state: ${msg}`);
|
|
2095
|
+
}
|
|
2096
|
+
process2.exitCode = 1;
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
const uploadResult = await uploadStorageState({
|
|
2100
|
+
apiUrl,
|
|
2101
|
+
token,
|
|
2102
|
+
propertyId: credential.propertyId,
|
|
2103
|
+
credentialId: credential.id,
|
|
2104
|
+
storageState: storageResult.data
|
|
2105
|
+
});
|
|
2106
|
+
if (jsonMode) {
|
|
2107
|
+
console.log(JSON.stringify(uploadResult));
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (!uploadResult.ok) {
|
|
2111
|
+
console.error(`Failed to save: ${uploadResult.error ?? "Unknown error"}`);
|
|
2112
|
+
process2.exitCode = 1;
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
console.log(`Credential "${credential.name}" storage state saved.`);
|
|
2116
|
+
}
|
|
2117
|
+
function handleAllowScreenshots() {
|
|
2118
|
+
const settingsDir = findClaudeSettingsDir();
|
|
2119
|
+
if (!settingsDir) {
|
|
2120
|
+
console.error(
|
|
2121
|
+
"Error: No .claude/ directory found. Run this inside a project with Claude Code configured."
|
|
2122
|
+
);
|
|
2123
|
+
process2.exitCode = 1;
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
const result = addScreenshotPermission(settingsDir);
|
|
2127
|
+
if (result.alreadyPresent) {
|
|
2128
|
+
console.log("Screenshot read permission already configured.");
|
|
2129
|
+
console.log(` Pattern: ${getScreenshotReadPattern()}`);
|
|
2130
|
+
} else {
|
|
2131
|
+
console.log("Added screenshot read permission to .claude/settings.local.json:");
|
|
2132
|
+
console.log(` ${result.added}`);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
async function handleTakeControl2(argv) {
|
|
2136
|
+
const sessionId = getArgValue(argv, "--session") || (argv[0] && !argv[0].startsWith("--") ? argv[0] : void 0);
|
|
2137
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
2138
|
+
try {
|
|
2139
|
+
const target = await resolveTargetSession(sessionId);
|
|
2140
|
+
const result = await takeControl(target.id);
|
|
2141
|
+
if (jsonMode) {
|
|
2142
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (!result.ok) {
|
|
2146
|
+
console.error(`Error: ${result.error}`);
|
|
2147
|
+
process2.exitCode = 1;
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
const session = result.data;
|
|
2151
|
+
console.log(`CLI now controls session "${session.id}".`);
|
|
2152
|
+
if (session.lock.cloudPaused) {
|
|
2153
|
+
console.log(" Cloud execution paused. Resume with: canary session resume-cloud");
|
|
2154
|
+
}
|
|
2155
|
+
} catch (err) {
|
|
2156
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2157
|
+
process2.exitCode = 1;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
async function handleResumeCloud2(argv) {
|
|
2161
|
+
const sessionId = getArgValue(argv, "--session") || (argv[0] && !argv[0].startsWith("--") ? argv[0] : void 0);
|
|
2162
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
2163
|
+
try {
|
|
2164
|
+
const target = await resolveTargetSession(sessionId);
|
|
2165
|
+
const result = await resumeCloud(target.id);
|
|
2166
|
+
if (jsonMode) {
|
|
2167
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
if (!result.ok) {
|
|
2171
|
+
console.error(`Error: ${result.error}`);
|
|
2172
|
+
process2.exitCode = 1;
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
console.log(`Cloud execution resumed for session "${result.data.id}".`);
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2178
|
+
process2.exitCode = 1;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
async function handleToolCommand(toolName, argv) {
|
|
2182
|
+
const sessionIdOrName = getArgValue(argv, "--session");
|
|
2183
|
+
const jsonMode = hasFlag(argv, "--json");
|
|
2184
|
+
try {
|
|
2185
|
+
const target = await resolveTargetSession(sessionIdOrName);
|
|
2186
|
+
const args = buildToolArgs(toolName, argv);
|
|
2187
|
+
const result = await callTool(target.id, toolName, args);
|
|
2188
|
+
printToolResult(result, jsonMode);
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2191
|
+
process2.exitCode = 1;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
function buildToolArgs(toolName, argv) {
|
|
2195
|
+
const args = {};
|
|
2196
|
+
switch (toolName) {
|
|
2197
|
+
case "navigate":
|
|
2198
|
+
args.url = getArgValue(argv, "--url");
|
|
2199
|
+
break;
|
|
2200
|
+
case "snapshot":
|
|
2201
|
+
args.search = getArgValue(argv, "--search");
|
|
2202
|
+
args.expand = getArgValue(argv, "--expand");
|
|
2203
|
+
args.mode = getArgValue(argv, "--mode") ?? "tree";
|
|
2204
|
+
break;
|
|
2205
|
+
case "screenshot":
|
|
2206
|
+
args.fullPage = hasFlag(argv, "--full-page");
|
|
2207
|
+
args.label = getArgValue(argv, "--label");
|
|
2208
|
+
args.returnImage = true;
|
|
2209
|
+
break;
|
|
2210
|
+
case "evaluate":
|
|
2211
|
+
args.function = getArgValue(argv, "--js");
|
|
2212
|
+
break;
|
|
2213
|
+
case "click": {
|
|
2214
|
+
const ref = getArgValue(argv, "--ref");
|
|
2215
|
+
const x = getArgValue(argv, "--x");
|
|
2216
|
+
const y = getArgValue(argv, "--y");
|
|
2217
|
+
if (ref) args.ref = ref;
|
|
2218
|
+
if (x) args.x = parseInt(x, 10);
|
|
2219
|
+
if (y) args.y = parseInt(y, 10);
|
|
2220
|
+
args.element = getArgValue(argv, "--element") ?? "";
|
|
2221
|
+
break;
|
|
2222
|
+
}
|
|
2223
|
+
case "type":
|
|
2224
|
+
args.ref = getArgValue(argv, "--ref");
|
|
2225
|
+
args.text = getArgValue(argv, "--text");
|
|
2226
|
+
args.element = getArgValue(argv, "--element") ?? "";
|
|
2227
|
+
args.submit = hasFlag(argv, "--submit");
|
|
2228
|
+
break;
|
|
2229
|
+
case "fill_form": {
|
|
2230
|
+
const fieldsStr = getArgValue(argv, "--fields");
|
|
2231
|
+
if (fieldsStr) {
|
|
2232
|
+
try {
|
|
2233
|
+
args.fields = JSON.parse(fieldsStr);
|
|
2234
|
+
} catch {
|
|
2235
|
+
console.error("Error: --fields must be valid JSON");
|
|
2236
|
+
process2.exitCode = 1;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
break;
|
|
2240
|
+
}
|
|
2241
|
+
case "select_option":
|
|
2242
|
+
args.ref = getArgValue(argv, "--ref");
|
|
2243
|
+
args.value = getArgValue(argv, "--value");
|
|
2244
|
+
args.element = getArgValue(argv, "--element") ?? "";
|
|
2245
|
+
break;
|
|
2246
|
+
case "press_key":
|
|
2247
|
+
args.key = getArgValue(argv, "--key");
|
|
2248
|
+
break;
|
|
2249
|
+
case "scroll":
|
|
2250
|
+
args.direction = getArgValue(argv, "--direction") ?? "down";
|
|
2251
|
+
{
|
|
2252
|
+
const amount = getArgValue(argv, "--amount");
|
|
2253
|
+
if (amount) args.amount = parseInt(amount, 10);
|
|
2254
|
+
}
|
|
2255
|
+
break;
|
|
2256
|
+
case "hover":
|
|
2257
|
+
args.ref = getArgValue(argv, "--ref");
|
|
2258
|
+
args.element = getArgValue(argv, "--element") ?? "";
|
|
2259
|
+
break;
|
|
2260
|
+
case "tabs": {
|
|
2261
|
+
args.action = getArgValue(argv, "--action") ?? "list";
|
|
2262
|
+
const index = getArgValue(argv, "--index");
|
|
2263
|
+
if (index) args.index = parseInt(index, 10);
|
|
2264
|
+
break;
|
|
2265
|
+
}
|
|
2266
|
+
case "wait_for": {
|
|
2267
|
+
args.text = getArgValue(argv, "--text");
|
|
2268
|
+
const timeout = getArgValue(argv, "--timeout");
|
|
2269
|
+
if (timeout) args.timeout = parseInt(timeout, 10);
|
|
2270
|
+
break;
|
|
2271
|
+
}
|
|
2272
|
+
case "console_messages":
|
|
2273
|
+
args.onlyErrors = hasFlag(argv, "--only-errors");
|
|
2274
|
+
break;
|
|
2275
|
+
}
|
|
2276
|
+
return args;
|
|
2277
|
+
}
|
|
2278
|
+
var TOOL_ALIASES = {
|
|
2279
|
+
navigate: "navigate",
|
|
2280
|
+
back: "navigate_back",
|
|
2281
|
+
snapshot: "snapshot",
|
|
2282
|
+
screenshot: "screenshot",
|
|
2283
|
+
evaluate: "evaluate",
|
|
2284
|
+
click: "click",
|
|
2285
|
+
type: "type",
|
|
2286
|
+
fill: "fill_form",
|
|
2287
|
+
select: "select_option",
|
|
2288
|
+
press: "press_key",
|
|
2289
|
+
scroll: "scroll",
|
|
2290
|
+
hover: "hover",
|
|
2291
|
+
drag: "drag",
|
|
2292
|
+
tabs: "tabs",
|
|
2293
|
+
wait: "wait_for",
|
|
2294
|
+
console: "console_messages",
|
|
2295
|
+
network: "network_requests"
|
|
2296
|
+
};
|
|
2297
|
+
function printHelp() {
|
|
2298
|
+
console.log(
|
|
2299
|
+
[
|
|
2300
|
+
"Usage: canary session <subcommand> [options]",
|
|
2301
|
+
"",
|
|
2302
|
+
"Session lifecycle:",
|
|
2303
|
+
" start [options] Start a new browser session",
|
|
2304
|
+
" list List active sessions",
|
|
2305
|
+
" status [<id>] Show session details",
|
|
2306
|
+
" stop [<id>] Stop a session (or --all)",
|
|
2307
|
+
" load-credential Load a credential into a running session",
|
|
2308
|
+
" save-credential Save session login state back to its credential",
|
|
2309
|
+
" allow-screenshots Grant Claude auto-read access to session screenshots",
|
|
2310
|
+
"",
|
|
2311
|
+
"Control:",
|
|
2312
|
+
" take-control [<id>] Force CLI control (pauses cloud execution)",
|
|
2313
|
+
" resume-cloud [<id>] Resume paused cloud execution",
|
|
2314
|
+
"",
|
|
2315
|
+
"Browser actions:",
|
|
2316
|
+
" navigate --url <url> Navigate to URL",
|
|
2317
|
+
" back Navigate back",
|
|
2318
|
+
" snapshot [--search <text>] [--expand <ref>] [--mode tree|screenshot|both]",
|
|
2319
|
+
" screenshot [--full-page] [--label <text>]",
|
|
2320
|
+
" evaluate --js <expression> Evaluate JavaScript",
|
|
2321
|
+
" click --ref <ref> [--element <desc>]",
|
|
2322
|
+
" click --x <n> --y <n> [--element <desc>]",
|
|
2323
|
+
" type --ref <ref> --text <text> [--submit]",
|
|
2324
|
+
` fill --fields '[{"ref":"e1","value":"test"}]'`,
|
|
2325
|
+
" select --ref <ref> --value <val> [--element <desc>]",
|
|
2326
|
+
" press --key <key> Press keyboard key",
|
|
2327
|
+
" scroll --direction <dir> [--amount <n>]",
|
|
2328
|
+
" hover --ref <ref> [--element <desc>]",
|
|
2329
|
+
" tabs [--action list|new|close|select] [--index <n>]",
|
|
2330
|
+
" wait --text <text> [--timeout <ms>]",
|
|
2331
|
+
" console [--only-errors] Show console messages",
|
|
2332
|
+
" network Show network requests",
|
|
2333
|
+
"",
|
|
2334
|
+
"Start options:",
|
|
2335
|
+
" --credential [<name|id>] Use a credential (interactive if no value)",
|
|
2336
|
+
" --storage-state <path> Use a local storage state file",
|
|
2337
|
+
" --headless Run browser without visible UI",
|
|
2338
|
+
" --url <url> Navigate to URL after launch",
|
|
2339
|
+
" --name <label> Name the session",
|
|
2340
|
+
" --cloud-relay Connect session to cloud API for remote control",
|
|
2341
|
+
" --attach-real-chrome Attach to the user's running Chrome via chrome-devtools-mcp.",
|
|
2342
|
+
" Requires Chrome 144+ with remote debugging enabled at",
|
|
2343
|
+
" chrome://inspect/#remote-debugging. Uses the live profile \u2014",
|
|
2344
|
+
" not compatible with --headless/--credential/--storage-state/--url.",
|
|
2345
|
+
"",
|
|
2346
|
+
"Load-credential options:",
|
|
2347
|
+
" --credential [<name|id>] Use a credential (interactive if no value)",
|
|
2348
|
+
" --storage-state <path> Use a local storage state file",
|
|
2349
|
+
"",
|
|
2350
|
+
"Save-credential options:",
|
|
2351
|
+
" --credential [<name|id>] Target credential (defaults to session's credential)",
|
|
2352
|
+
"",
|
|
2353
|
+
"Common options:",
|
|
2354
|
+
" --session <id|name> Target a specific session (when multiple)",
|
|
2355
|
+
" --json Output raw JSON",
|
|
2356
|
+
" --env <env> Environment for credential resolution",
|
|
2357
|
+
" -h, --help Show this help"
|
|
2358
|
+
].join("\n")
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
async function runSession(argv) {
|
|
2362
|
+
const [subcommand, ...rest] = argv;
|
|
2363
|
+
if (!subcommand || subcommand === "help" || hasFlag(argv, "-h", "--help")) {
|
|
2364
|
+
printHelp();
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
if (subcommand === "daemon") {
|
|
2368
|
+
await runDaemon();
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
switch (subcommand) {
|
|
2372
|
+
case "start":
|
|
2373
|
+
await handleStart(rest);
|
|
2374
|
+
break;
|
|
2375
|
+
case "list":
|
|
2376
|
+
await handleList(rest);
|
|
2377
|
+
break;
|
|
2378
|
+
case "status":
|
|
2379
|
+
await handleStatus(rest);
|
|
2380
|
+
break;
|
|
2381
|
+
case "stop":
|
|
2382
|
+
await handleStop(rest);
|
|
2383
|
+
break;
|
|
2384
|
+
case "load-credential":
|
|
2385
|
+
await handleLoadCredential(rest);
|
|
2386
|
+
break;
|
|
2387
|
+
case "save-credential":
|
|
2388
|
+
await handleSaveCredential(rest);
|
|
2389
|
+
break;
|
|
2390
|
+
case "allow-screenshots":
|
|
2391
|
+
handleAllowScreenshots();
|
|
2392
|
+
break;
|
|
2393
|
+
case "take-control":
|
|
2394
|
+
await handleTakeControl2(rest);
|
|
2395
|
+
break;
|
|
2396
|
+
case "resume-cloud":
|
|
2397
|
+
await handleResumeCloud2(rest);
|
|
2398
|
+
break;
|
|
2399
|
+
default: {
|
|
2400
|
+
const toolName = TOOL_ALIASES[subcommand];
|
|
2401
|
+
if (toolName) {
|
|
2402
|
+
await handleToolCommand(toolName, rest);
|
|
2403
|
+
} else {
|
|
2404
|
+
console.error(`Unknown session subcommand: ${subcommand}`);
|
|
2405
|
+
printHelp();
|
|
2406
|
+
process2.exitCode = 1;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
export {
|
|
2412
|
+
runSession
|
|
2413
|
+
};
|
|
2414
|
+
//# sourceMappingURL=session-MYFNZPRP.js.map
|