granclaw 0.0.1-beta.24 → 0.0.1-beta.26
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.
|
@@ -2,26 +2,24 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* stealth.ts
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* automation fingerprints. Two levers, both applied when available:
|
|
5
|
+
* Two mechanisms for hiding automation fingerprints:
|
|
7
6
|
*
|
|
8
|
-
* 1.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 1. stealthArgv() — argv fragment for `agent-browser` launch.
|
|
8
|
+
* Adds --executable-path when a real Chrome/Chromium
|
|
9
|
+
* binary is available (real Chrome is less flagged
|
|
10
|
+
* than the Playwright-bundled build).
|
|
11
11
|
*
|
|
12
|
-
* 2.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* 2. injectStealthViaCdp() — connects to the running browser via CDP and
|
|
13
|
+
* calls Page.addScriptToEvaluateOnNewDocument with
|
|
14
|
+
* the stealth.js patches. Also fires Runtime.evaluate
|
|
15
|
+
* on the current page so the patches apply immediately,
|
|
16
|
+
* not just on the next navigation.
|
|
17
|
+
* Works in both headless (Docker) and headed (local)
|
|
18
|
+
* mode — no display or extension loader required.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Override hooks for users who want to pin a specific Chrome:
|
|
23
|
-
* GRANCLAW_STEALTH_DISABLED=1 → no flags at all
|
|
24
|
-
* GRANCLAW_CHROME_PATH=/abs/path/chrome → force a specific binary
|
|
20
|
+
* Override hooks:
|
|
21
|
+
* GRANCLAW_STEALTH_DISABLED=1 → both mechanisms are no-ops
|
|
22
|
+
* GRANCLAW_CHROME_PATH=/abs/path/chrome → force a specific binary for --executable-path
|
|
25
23
|
*/
|
|
26
24
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
27
25
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -29,19 +27,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
29
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
28
|
exports.STEALTH_EXTENSION_DIR = void 0;
|
|
31
29
|
exports.stealthArgv = stealthArgv;
|
|
30
|
+
exports.injectStealthViaCdp = injectStealthViaCdp;
|
|
31
|
+
exports.prewarmStealthDaemon = prewarmStealthDaemon;
|
|
32
32
|
exports.__resetStealthCacheForTests = __resetStealthCacheForTests;
|
|
33
33
|
const fs_1 = __importDefault(require("fs"));
|
|
34
34
|
const path_1 = __importDefault(require("path"));
|
|
35
|
+
const http_1 = __importDefault(require("http"));
|
|
36
|
+
const child_process_1 = require("child_process");
|
|
37
|
+
const util_1 = require("util");
|
|
38
|
+
const ws_1 = require("ws");
|
|
39
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
40
|
+
// ── Extension dir (source of stealth.js) ──────────────────────────────────────
|
|
35
41
|
/**
|
|
36
42
|
* Resolve the stealth-extension directory across three layouts:
|
|
37
43
|
*
|
|
38
44
|
* - dev (tsx src): packages/backend/src/browser/ → ../../assets/stealth-extension
|
|
39
45
|
* - backend standalone: packages/backend/dist/browser/ → ../../assets/stealth-extension
|
|
40
|
-
* - cli bundled publish: packages/cli/dist/backend/browser/ →
|
|
41
|
-
* OR ../assets/stealth-extension
|
|
42
|
-
*
|
|
43
|
-
* We probe both candidates and take the first that exists so the helper is
|
|
44
|
-
* layout-agnostic. Callers fall back gracefully when none resolve.
|
|
46
|
+
* - cli bundled publish: packages/cli/dist/backend/browser/ → ../assets/stealth-extension
|
|
45
47
|
*/
|
|
46
48
|
function resolveExtensionDir() {
|
|
47
49
|
const candidates = [
|
|
@@ -56,17 +58,14 @@ function resolveExtensionDir() {
|
|
|
56
58
|
}
|
|
57
59
|
const STEALTH_EXTENSION_DIR = resolveExtensionDir();
|
|
58
60
|
exports.STEALTH_EXTENSION_DIR = STEALTH_EXTENSION_DIR;
|
|
59
|
-
|
|
60
|
-
* Probe a list of well-known Chrome install paths and return the first that
|
|
61
|
-
* exists. Resolved once per process to keep the per-launch cost at zero.
|
|
62
|
-
*/
|
|
61
|
+
// ── Chrome binary detection ───────────────────────────────────────────────────
|
|
63
62
|
let cachedChromePath;
|
|
64
63
|
function detectChromePath() {
|
|
65
64
|
if (cachedChromePath !== undefined)
|
|
66
65
|
return cachedChromePath;
|
|
67
|
-
// GRANCLAW_CHROME_PATH takes priority; fall back to
|
|
68
|
-
//
|
|
69
|
-
//
|
|
66
|
+
// GRANCLAW_CHROME_PATH takes priority; fall back to AGENT_BROWSER_CHROME_PATH
|
|
67
|
+
// so Docker containers (AGENT_BROWSER_CHROME_PATH=/usr/bin/chromium) automatically
|
|
68
|
+
// use the same binary that agent-browser itself is configured to use.
|
|
70
69
|
const override = process.env.GRANCLAW_CHROME_PATH || process.env.AGENT_BROWSER_CHROME_PATH;
|
|
71
70
|
if (override && fs_1.default.existsSync(override)) {
|
|
72
71
|
cachedChromePath = override;
|
|
@@ -106,35 +105,174 @@ function detectChromePath() {
|
|
|
106
105
|
cachedChromePath = null;
|
|
107
106
|
return null;
|
|
108
107
|
}
|
|
108
|
+
// ── argv builder ──────────────────────────────────────────────────────────────
|
|
109
109
|
/**
|
|
110
|
-
* Return the argv fragment
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* The caller splices the result into its own argv — e.g.:
|
|
115
|
-
*
|
|
116
|
-
* const argv = ['--session', agentId, ...stealthArgv(), 'open', url];
|
|
110
|
+
* Return the argv fragment for the `agent-browser` command that boots the
|
|
111
|
+
* daemon. Only --executable-path is emitted here; the stealth JS patches are
|
|
112
|
+
* applied separately via injectStealthViaCdp() after the session is up.
|
|
117
113
|
*/
|
|
118
114
|
function stealthArgv() {
|
|
119
|
-
// Re-enabled 2026-04-14: UA/deviceMemory patches added, AGENT_BROWSER_CHROME_PATH
|
|
120
|
-
// wired up so Docker containers automatically use the in-place Chromium.
|
|
121
|
-
// Set GRANCLAW_STEALTH_DISABLED=1 to opt out entirely.
|
|
122
115
|
if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
|
|
123
116
|
return [];
|
|
124
117
|
const argv = [];
|
|
125
|
-
if (STEALTH_EXTENSION_DIR) {
|
|
126
|
-
argv.push('--extension', STEALTH_EXTENSION_DIR);
|
|
127
|
-
}
|
|
128
118
|
const chrome = detectChromePath();
|
|
129
119
|
if (chrome) {
|
|
130
120
|
argv.push('--executable-path', chrome);
|
|
131
121
|
}
|
|
132
122
|
return argv;
|
|
133
123
|
}
|
|
124
|
+
// ── CDP stealth injection ─────────────────────────────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* Poll for the browser-level CDP WebSocket URL.
|
|
127
|
+
*
|
|
128
|
+
* @param retries How many attempts to make (default 8, ~2.4 s). Pass 1 for
|
|
129
|
+
* a fast single-shot check used by the background watcher.
|
|
130
|
+
*/
|
|
131
|
+
async function discoverCdpUrl(sessionId, workspaceDir, retries = 8) {
|
|
132
|
+
const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
|
|
133
|
+
for (let i = 0; i < retries; i++) {
|
|
134
|
+
try {
|
|
135
|
+
const { stdout } = await execFileAsync(bin, ['--session', sessionId, 'get', 'cdp-url'], {
|
|
136
|
+
cwd: workspaceDir,
|
|
137
|
+
timeout: 2000,
|
|
138
|
+
});
|
|
139
|
+
const url = stdout.trim();
|
|
140
|
+
if (url.startsWith('ws://') || url.startsWith('wss://'))
|
|
141
|
+
return url;
|
|
142
|
+
}
|
|
143
|
+
catch { /* daemon not ready yet */ }
|
|
144
|
+
if (i < retries - 1)
|
|
145
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Fetch all page targets from the browser's /json/list endpoint.
|
|
151
|
+
*/
|
|
152
|
+
async function fetchPageTargets(browserCdpUrl) {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
const match = /^wss?:\/\/([^/]+)\//.exec(browserCdpUrl);
|
|
155
|
+
if (!match) {
|
|
156
|
+
resolve([]);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const host = match[1];
|
|
160
|
+
const req = http_1.default.get(`http://${host}/json/list`, { timeout: 3000 }, (res) => {
|
|
161
|
+
let body = '';
|
|
162
|
+
res.on('data', (c) => { body += c.toString(); });
|
|
163
|
+
res.on('end', () => {
|
|
164
|
+
try {
|
|
165
|
+
const targets = JSON.parse(body);
|
|
166
|
+
resolve(targets
|
|
167
|
+
.filter((t) => t.type === 'page' && t.webSocketDebuggerUrl)
|
|
168
|
+
.map((t) => ({ webSocketDebuggerUrl: t.webSocketDebuggerUrl })));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
resolve([]);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
req.on('error', () => resolve([]));
|
|
176
|
+
req.on('timeout', () => { req.destroy(); resolve([]); });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
134
179
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
180
|
+
* Open a CDP WebSocket to a single page target and:
|
|
181
|
+
* 1. Register the stealth script for all future navigations via
|
|
182
|
+
* Page.addScriptToEvaluateOnNewDocument.
|
|
183
|
+
* 2. Run it immediately on the current document via Runtime.evaluate so
|
|
184
|
+
* patches are live without a reload.
|
|
137
185
|
*/
|
|
186
|
+
function injectIntoPage(wsUrl, script) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const cleanup = (ws) => { try {
|
|
189
|
+
ws.close();
|
|
190
|
+
}
|
|
191
|
+
catch { /* ignore */ } resolve(); };
|
|
192
|
+
const timer = setTimeout(() => cleanup(ws), 5000);
|
|
193
|
+
const ws = new ws_1.WebSocket(wsUrl);
|
|
194
|
+
ws.on('open', () => {
|
|
195
|
+
ws.send(JSON.stringify({
|
|
196
|
+
id: 1,
|
|
197
|
+
method: 'Page.addScriptToEvaluateOnNewDocument',
|
|
198
|
+
params: { source: script },
|
|
199
|
+
}));
|
|
200
|
+
ws.send(JSON.stringify({
|
|
201
|
+
id: 2,
|
|
202
|
+
method: 'Runtime.evaluate',
|
|
203
|
+
params: { expression: script, returnByValue: false },
|
|
204
|
+
}));
|
|
205
|
+
// Allow a short window for Chrome to ack, then close.
|
|
206
|
+
setTimeout(() => { clearTimeout(timer); cleanup(ws); }, 400);
|
|
207
|
+
});
|
|
208
|
+
ws.on('error', () => { clearTimeout(timer); resolve(); });
|
|
209
|
+
ws.on('close', () => { clearTimeout(timer); resolve(); });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Inject the stealth script into every open page of the agent's browser
|
|
214
|
+
* session via CDP. Call this after the agent-browser daemon has started.
|
|
215
|
+
*
|
|
216
|
+
* Best-effort: any error is swallowed so a CDP failure never breaks the
|
|
217
|
+
* caller's main flow.
|
|
218
|
+
*/
|
|
219
|
+
async function injectStealthViaCdp(sessionId, workspaceDir) {
|
|
220
|
+
if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
|
|
221
|
+
return;
|
|
222
|
+
if (!STEALTH_EXTENSION_DIR)
|
|
223
|
+
return;
|
|
224
|
+
const scriptPath = path_1.default.join(STEALTH_EXTENSION_DIR, 'stealth.js');
|
|
225
|
+
if (!fs_1.default.existsSync(scriptPath))
|
|
226
|
+
return;
|
|
227
|
+
try {
|
|
228
|
+
const script = fs_1.default.readFileSync(scriptPath, 'utf-8');
|
|
229
|
+
const cdpUrl = await discoverCdpUrl(sessionId, workspaceDir);
|
|
230
|
+
if (!cdpUrl)
|
|
231
|
+
return;
|
|
232
|
+
const pages = await fetchPageTargets(cdpUrl);
|
|
233
|
+
await Promise.all(pages.map((p) => injectIntoPage(p.webSocketDebuggerUrl, script)));
|
|
234
|
+
console.log(`[stealth] injected into ${pages.length} page(s) for session "${sessionId}"`);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
console.warn(`[stealth] CDP injection failed for "${sessionId}":`, err);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// ── Daemon pre-warm ───────────────────────────────────────────────────────────
|
|
241
|
+
/**
|
|
242
|
+
* Pre-warm the agent's browser daemon and register stealth before any
|
|
243
|
+
* agent navigation. Call this once at agent process startup for agents
|
|
244
|
+
* that have the browser tool enabled.
|
|
245
|
+
*
|
|
246
|
+
* Steps:
|
|
247
|
+
* 1. Start the daemon with `agent-browser open about:blank` (no-op if
|
|
248
|
+
* already running — agent-browser reuses the existing daemon).
|
|
249
|
+
* 2. Inject Page.addScriptToEvaluateOnNewDocument via CDP so every
|
|
250
|
+
* subsequent navigation the agent makes will have stealth running
|
|
251
|
+
* before the page's own scripts.
|
|
252
|
+
*
|
|
253
|
+
* This is a one-time setup. If the agent later kills its daemon via
|
|
254
|
+
* `browser close --all`, the next daemon start won't have stealth until
|
|
255
|
+
* the live view relay re-attaches (which also injects stealth).
|
|
256
|
+
*/
|
|
257
|
+
async function prewarmStealthDaemon(sessionId, workspaceDir) {
|
|
258
|
+
if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
|
|
259
|
+
return;
|
|
260
|
+
try {
|
|
261
|
+
const bin = process.env.AGENT_BROWSER_BIN ?? 'agent-browser';
|
|
262
|
+
// Boot the daemon (or no-op if already running) and land on about:blank.
|
|
263
|
+
// Use execFileAsync with a short timeout — we don't care about the result,
|
|
264
|
+
// only that the daemon is now up.
|
|
265
|
+
await execFileAsync(bin, ['--session', sessionId, ...stealthArgv(), 'open', 'about:blank'], { cwd: workspaceDir, timeout: 15000 });
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Daemon failed to start (no Chrome, wrong env, etc.) — skip silently.
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Daemon is up. Register stealth for all future navigations.
|
|
272
|
+
await injectStealthViaCdp(sessionId, workspaceDir);
|
|
273
|
+
}
|
|
274
|
+
// ── Test helpers ──────────────────────────────────────────────────────────────
|
|
275
|
+
/** @internal */
|
|
138
276
|
function __resetStealthCacheForTests() {
|
|
139
277
|
cachedChromePath = undefined;
|
|
140
278
|
}
|
|
@@ -25,6 +25,7 @@ exports.stopAndRemoveAgent = stopAndRemoveAgent;
|
|
|
25
25
|
const child_process_1 = require("child_process");
|
|
26
26
|
const path_1 = __importDefault(require("path"));
|
|
27
27
|
const config_js_1 = require("../config.js");
|
|
28
|
+
const stealth_js_1 = require("../browser/stealth.js");
|
|
28
29
|
const secrets_vault_js_1 = require("../secrets-vault.js");
|
|
29
30
|
exports.BASE_AGENT_PORT = Number(process.env.AGENT_BASE_PORT ?? 3100);
|
|
30
31
|
/**
|
|
@@ -87,6 +88,13 @@ function startNewAgent(agent) {
|
|
|
87
88
|
const managed = { config: agent, wsPort, bbPort: null, pid: child.pid };
|
|
88
89
|
registry.set(agent.id, managed);
|
|
89
90
|
console.log(`[orchestrator] agent "${agent.id}" started on ws port ${wsPort} (pid ${child.pid})`);
|
|
91
|
+
// Pre-warm the browser daemon for browser-capable agents so stealth is
|
|
92
|
+
// registered via Page.addScriptToEvaluateOnNewDocument before the agent's
|
|
93
|
+
// first navigation. Fire-and-forget — never blocks agent startup.
|
|
94
|
+
if (agent.allowedTools?.includes('browser')) {
|
|
95
|
+
const workspaceDir = path_1.default.resolve(config_js_1.REPO_ROOT, agent.workspaceDir);
|
|
96
|
+
void (0, stealth_js_1.prewarmStealthDaemon)(agent.id, workspaceDir);
|
|
97
|
+
}
|
|
90
98
|
return managed;
|
|
91
99
|
}
|
|
92
100
|
/**
|
|
@@ -39,6 +39,9 @@ const http_1 = __importDefault(require("http"));
|
|
|
39
39
|
const ws_1 = require("ws");
|
|
40
40
|
const child_process_1 = require("child_process");
|
|
41
41
|
const util_1 = require("util");
|
|
42
|
+
const stealth_js_1 = require("../browser/stealth.js");
|
|
43
|
+
const fs_1 = __importDefault(require("fs"));
|
|
44
|
+
const path_1 = __importDefault(require("path"));
|
|
42
45
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
43
46
|
const TAB_POLL_INTERVAL_MS = 2000;
|
|
44
47
|
/**
|
|
@@ -261,6 +264,57 @@ function pickCdpPageForTab(pages, activeUrl) {
|
|
|
261
264
|
const real = pages.filter((p) => !isInert(p.url));
|
|
262
265
|
return real.length > 0 ? real[real.length - 1] : pages[pages.length - 1];
|
|
263
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Lazily read stealth.js once and cache it. Returns null if the extension
|
|
269
|
+
* directory is missing (e.g. the package wasn't installed with assets).
|
|
270
|
+
*/
|
|
271
|
+
let cachedStealthScript;
|
|
272
|
+
function readStealthScript() {
|
|
273
|
+
if (cachedStealthScript !== undefined)
|
|
274
|
+
return cachedStealthScript;
|
|
275
|
+
if (!stealth_js_1.STEALTH_EXTENSION_DIR) {
|
|
276
|
+
cachedStealthScript = null;
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const scriptPath = path_1.default.join(stealth_js_1.STEALTH_EXTENSION_DIR, 'stealth.js');
|
|
280
|
+
try {
|
|
281
|
+
cachedStealthScript = fs_1.default.readFileSync(scriptPath, 'utf-8');
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
cachedStealthScript = null;
|
|
285
|
+
}
|
|
286
|
+
return cachedStealthScript;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Inject the stealth patches on an already-open CDP WebSocket. Uses the
|
|
290
|
+
* stream's cdpMessageId counter so IDs stay monotonically increasing.
|
|
291
|
+
*
|
|
292
|
+
* Two commands:
|
|
293
|
+
* - Page.addScriptToEvaluateOnNewDocument — persists across navigations
|
|
294
|
+
* - Runtime.evaluate — applies to the current document immediately
|
|
295
|
+
*
|
|
296
|
+
* Best-effort: silently skipped when stealth is disabled or the script is unavailable.
|
|
297
|
+
*/
|
|
298
|
+
function injectStealthOnPage(chromeWs, stream) {
|
|
299
|
+
if (process.env.GRANCLAW_STEALTH_DISABLED === '1')
|
|
300
|
+
return;
|
|
301
|
+
const script = readStealthScript();
|
|
302
|
+
if (!script)
|
|
303
|
+
return;
|
|
304
|
+
try {
|
|
305
|
+
chromeWs.send(JSON.stringify({
|
|
306
|
+
id: ++stream.cdpMessageId,
|
|
307
|
+
method: 'Page.addScriptToEvaluateOnNewDocument',
|
|
308
|
+
params: { source: script },
|
|
309
|
+
}));
|
|
310
|
+
chromeWs.send(JSON.stringify({
|
|
311
|
+
id: ++stream.cdpMessageId,
|
|
312
|
+
method: 'Runtime.evaluate',
|
|
313
|
+
params: { expression: script, returnByValue: false },
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
catch { /* ws closed between open and send — ignore */ }
|
|
317
|
+
}
|
|
264
318
|
/**
|
|
265
319
|
* Attach to a specific CDP page target and begin screencasting. Called on
|
|
266
320
|
* initial attach and again whenever we need to rebind to a new tab.
|
|
@@ -278,6 +332,9 @@ function attachPageCdp(stream, page) {
|
|
|
278
332
|
catch { }
|
|
279
333
|
return;
|
|
280
334
|
}
|
|
335
|
+
// Inject stealth patches on every page attach — works headlessly via CDP,
|
|
336
|
+
// no display or extension loader needed.
|
|
337
|
+
injectStealthOnPage(chromeWs, stream);
|
|
281
338
|
chromeWs.send(JSON.stringify({
|
|
282
339
|
id: ++stream.cdpMessageId,
|
|
283
340
|
method: 'Page.startScreencast',
|
|
@@ -1411,6 +1411,9 @@ function createServer() {
|
|
|
1411
1411
|
res.status(500).json({ error: `Failed to launch browser: ${message}` });
|
|
1412
1412
|
return;
|
|
1413
1413
|
}
|
|
1414
|
+
// Inject stealth patches via CDP now that the daemon is up.
|
|
1415
|
+
// Fire-and-forget — a CDP failure must never block the browser-open response.
|
|
1416
|
+
void (0, stealth_js_1.injectStealthViaCdp)(req.params.id, workspaceDir);
|
|
1414
1417
|
headedBrowsers.set(req.params.id, { url });
|
|
1415
1418
|
console.log(`[browser] launched headed browser for "${req.params.id}" with profile → ${url}`);
|
|
1416
1419
|
res.status(201).json({ url, profileDir });
|