@snutils/snu 0.1.10 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,8 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.StandaloneDispatcher = void 0;
36
+ exports.StandaloneDispatcher = exports.HELPER_CONNECT_GUIDANCE = void 0;
37
+ exports.resolveMappedFileName = resolveMappedFileName;
37
38
  const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
38
40
  const path = __importStar(require("path"));
39
41
  const crypto = __importStar(require("crypto"));
40
42
  const pendingRegistry_js_1 = require("./pendingRegistry.js");
@@ -42,6 +44,49 @@ const policy_js_1 = require("./policy.js");
42
44
  const config_js_1 = require("./config.js");
43
45
  const canonical_js_1 = require("./canonical.js");
44
46
  const FOLDERRECORDTABLES = ['sp_widget', 'sp_header_footer', 'sys_ui_page'];
47
+ // Human labels + the environment variable each gate is *actually* read from in
48
+ // config.ts. Deriving the variable name from the camelCase gate key produces
49
+ // SNU_ALLOW_RESTREQUEST, which nothing reads, so an agent told to set it hits
50
+ // the same wall twice and starts looking for a way around the gate. Keep these
51
+ // tables in step with resolveStandaloneConfig().
52
+ const GATE_LABELS = {
53
+ backgroundScripts: 'Background Scripts',
54
+ deleteRecords: 'Delete Records',
55
+ createArtifacts: 'Create Artifacts',
56
+ browserDebugger: 'Browser Debugger',
57
+ restRequest: 'REST Request API',
58
+ };
59
+ const REST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
60
+ // Mirrors codeForRest() in the VS Code agent so both hosts report the same
61
+ // code for the same HTTP failure.
62
+ function codeForRestStatus(status, message) {
63
+ if (status === 404)
64
+ return 'E_NOT_FOUND';
65
+ if (status === 409)
66
+ return 'E_REFERENCE_INTEGRITY';
67
+ if (status === 401 || status === 403)
68
+ return 'E_ACL';
69
+ const lower = (message || '').toLowerCase();
70
+ if (lower.includes('cannot delete') || lower.includes('referenc') || lower.includes('cascade')) {
71
+ return 'E_REFERENCE_INTEGRITY';
72
+ }
73
+ return 'E_COMMAND_FAILED';
74
+ }
75
+ const GATE_ENV_VARS = {
76
+ backgroundScripts: 'SNU_ALLOW_BACKGROUND_SCRIPTS',
77
+ deleteRecords: 'SNU_ALLOW_DELETE_RECORDS',
78
+ createArtifacts: 'SNU_ALLOW_CREATE_ARTIFACTS',
79
+ browserDebugger: 'SNU_ALLOW_BROWSER_DEBUGGER',
80
+ restRequest: 'SNU_ALLOW_REST_REQUEST',
81
+ };
82
+ // Steps a user must take to (re)connect the browser helper tab. Rendered by
83
+ // the CLI as a friendly block and relayed verbatim by the MCP server so
84
+ // agents guide the user instead of reporting a raw error.
85
+ exports.HELPER_CONNECT_GUIDANCE = [
86
+ 'Open your ServiceNow instance in the browser (with the SN Utils extension installed).',
87
+ 'On that page, type /token in the SN Utils slash palette: this opens the helper tab and connects it.',
88
+ 'Keep the helper tab open, then retry.',
89
+ ];
45
90
  const FIELDTYPES = {
46
91
  script: { extension: '.js' },
47
92
  script_plain: { extension: '.js' },
@@ -141,6 +186,61 @@ function resolveFieldExtension(tableName, fieldName, cwd) {
141
186
  ext = '.js';
142
187
  return ext;
143
188
  }
189
+ // Canonical filename resolution against a folder's _map.json, shared by
190
+ // pull_records and browser save pushes. Mirrors the VS Code extension: an
191
+ // existing mapping for the sys_id wins (so a record renamed on the instance
192
+ // keeps its stable local filename, reported via `renamedTo`), and a name
193
+ // collision with another sys_id gets a short sys_id suffix.
194
+ function resolveMappedFileName(mapPath, rawName, sysId) {
195
+ let nameToSysId = {};
196
+ if (fs.existsSync(mapPath)) {
197
+ try {
198
+ nameToSysId = JSON.parse(fs.readFileSync(mapPath, 'utf8')) || {};
199
+ }
200
+ catch { }
201
+ }
202
+ const computed = String(rawName).replace(/[^a-z0-9._\-+]+/gi, '').replace(/\./g, '-') || sysId;
203
+ let cleanName = computed;
204
+ let renamedTo;
205
+ const existingKey = Object.keys(nameToSysId).find((k) => nameToSysId[k] === sysId);
206
+ if (existingKey) {
207
+ cleanName = existingKey;
208
+ if (existingKey !== computed)
209
+ renamedTo = computed;
210
+ }
211
+ else if (nameToSysId[cleanName] && nameToSysId[cleanName] !== sysId) {
212
+ cleanName = cleanName + ('-' + sysId.slice(0, 2) + sysId.slice(-2)).toUpperCase();
213
+ }
214
+ nameToSysId[cleanName] = sysId;
215
+ return { cleanName, renamedTo, map: nameToSysId };
216
+ }
217
+ function writeMapFile(mapPath, map) {
218
+ fs.mkdirSync(path.dirname(mapPath), { recursive: true });
219
+ fs.writeFileSync(mapPath, JSON.stringify(map, null, 4), 'utf8');
220
+ }
221
+ // Extension for a browser save push, derived from the payload's fieldType the
222
+ // same way the VS Code extension does it (saveFieldAsFile).
223
+ function extensionForBrowserSave(fieldType, fieldName, tableName, cleanName) {
224
+ let ext = FIELDTYPES[fieldType]?.extension;
225
+ if (fieldType.includes('xml'))
226
+ ext = '.xml';
227
+ else if (fieldType.includes('html'))
228
+ ext = '.html';
229
+ else if (fieldType.includes('json'))
230
+ ext = '.json';
231
+ else if (fieldType.includes('css') || fieldType === 'properties' || fieldName === 'css')
232
+ ext = '.scss';
233
+ else if (cleanName.lastIndexOf('-') > -1 && tableName === 'ecc_agent_script_file') {
234
+ const suffix = cleanName.substring(cleanName.lastIndexOf('-') + 1);
235
+ if (suffix.length < 5)
236
+ ext = '.' + suffix;
237
+ }
238
+ else if (fieldType.includes('string') || fieldType === 'conditions')
239
+ ext = '.txt';
240
+ else if (fieldName === 'PowerShell')
241
+ ext = '.ps1';
242
+ return ext || '.js';
243
+ }
144
244
  class StandaloneDispatcher {
145
245
  cwd;
146
246
  ws;
@@ -174,6 +274,133 @@ class StandaloneDispatcher {
174
274
  }
175
275
  return null;
176
276
  }
277
+ // Browser save-icon pushes (action: 'saveFieldAsFile') arrive on the port
278
+ // 1978 socket whether VS Code or this daemon is listening; VS Code writes
279
+ // the field into the sync workspace, and historically the daemon dropped
280
+ // the message silently. Mirror the VS Code behavior so a daemon-only setup
281
+ // still lands pushes on disk. One-way by design: the daemon has no file
282
+ // watcher, so local edits flow back via agent commands or VS Code.
283
+ async handleBrowserFieldSave(msg) {
284
+ const echo = (payload) => {
285
+ try {
286
+ this.ws.sendToBrowser(payload);
287
+ }
288
+ catch { }
289
+ };
290
+ try {
291
+ const instanceName = sanitizePathComponent(String(msg?.instance?.name || ''));
292
+ const table = sanitizePathComponent(String(msg?.table || ''));
293
+ const sysId = String(msg?.sys_id || '');
294
+ const rawName = String(msg?.name || '');
295
+ const field = String(msg?.field || '');
296
+ const content = typeof msg?.content === 'string' ? msg.content : String(msg?.content ?? '');
297
+ if (!sysId || !field)
298
+ throw new Error('Save push is missing sys_id or field');
299
+ // Refuse to scatter files when the daemon was clearly started outside a
300
+ // sync workspace.
301
+ const resolvedCwd = path.resolve(this.cwd);
302
+ if (resolvedCwd === path.resolve(os.homedir()) || resolvedCwd === path.parse(resolvedCwd).root) {
303
+ console.warn(`[snu] Ignored a save push from the browser: ${resolvedCwd} does not look like a ScriptSync workspace. Start snu from your sync folder.`);
304
+ return;
305
+ }
306
+ const scope = await this.resolveScopeFolderForSave(msg, instanceName);
307
+ // Variable fields arrive as inputs.<var>.script
308
+ const fieldName = field.split('.').length === 3 ? 'variable-' + field.split('.')[2] : field;
309
+ const isFolderRecordTable = FOLDERRECORDTABLES.includes(table);
310
+ const mapPath = safeJoinUnderRoot(this.cwd, instanceName, scope, table, '_map.json');
311
+ const { cleanName, renamedTo, map } = resolveMappedFileName(mapPath, rawName, sysId);
312
+ writeMapFile(mapPath, map);
313
+ if (renamedTo) {
314
+ console.log(`[snu] Record ${table}/${sysId} is named '${renamedTo}' on the instance but keeps local file name '${cleanName}' (rename tracked in _map.json).`);
315
+ }
316
+ const ext = extensionForBrowserSave(String(msg?.fieldType || 'script'), fieldName, table, cleanName);
317
+ const targetPath = isFolderRecordTable
318
+ ? safeJoinUnderRoot(this.cwd, instanceName, scope, table, cleanName, `${fieldName}${ext}`)
319
+ : safeJoinUnderRoot(this.cwd, instanceName, scope, table, `${cleanName}.${fieldName}${ext}`);
320
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
321
+ fs.writeFileSync(targetPath, content, 'utf8');
322
+ console.log(`[snu] Saved ${path.relative(this.cwd, targetPath)} (pushed from ${instanceName})`);
323
+ // Same success echo VS Code sends: the helper tab logs the push as
324
+ // delivered when it sees contentLength.
325
+ echo({ ...msg, result: '', contentLength: content.length, send: false });
326
+ }
327
+ catch (e) {
328
+ const message = e?.message || String(e);
329
+ console.warn('[snu] Failed to save field pushed from browser:', message);
330
+ echo({ error: `Standalone snu could not save the pushed field: ${message}`, send: false, response: { result: {} } });
331
+ }
332
+ }
333
+ // Resolve the scope folder name for a browser save push. The payload's
334
+ // `scope` is a sys_scope sys_id ('global' for global, '' when the form has
335
+ // no sys_scope field). Mirrors the VS Code extension: scopes.json first,
336
+ // then ask the instance, falling back to no_scope / unknown_scope.
337
+ async resolveScopeFolderForSave(msg, instanceName) {
338
+ const scopeVal = typeof msg?.scope === 'string' ? msg.scope.trim() : '';
339
+ if (scopeVal === 'global')
340
+ return 'global';
341
+ if (!scopeVal) {
342
+ const fromRecord = await this.queryScopeFromInstance(msg, `/api/now/table/${msg.table}/${msg.sys_id}`, { sysparm_fields: 'sys_scope.scope', sysparm_exclude_reference_link: 'true' }, 'sys_scope.scope');
343
+ return fromRecord || 'no_scope';
344
+ }
345
+ if (/^[0-9a-f]{32}$/i.test(scopeVal)) {
346
+ const scopesPath = path.join(this.cwd, instanceName, 'scopes.json');
347
+ try {
348
+ const scopes = JSON.parse(fs.readFileSync(scopesPath, 'utf8')) || {};
349
+ const hit = Object.keys(scopes).find((k) => scopes[k] === scopeVal);
350
+ if (hit)
351
+ return hit;
352
+ }
353
+ catch { }
354
+ const fromScope = await this.queryScopeFromInstance(msg, `/api/now/table/sys_scope/${scopeVal}`, { sysparm_fields: 'scope' }, 'scope');
355
+ if (fromScope) {
356
+ // Best-effort persist name -> sys_id so the next save skips the round-trip.
357
+ try {
358
+ let scopes = {};
359
+ try {
360
+ scopes = JSON.parse(fs.readFileSync(scopesPath, 'utf8')) || {};
361
+ }
362
+ catch { }
363
+ if (scopes[fromScope] !== scopeVal) {
364
+ scopes[fromScope] = scopeVal;
365
+ fs.mkdirSync(path.dirname(scopesPath), { recursive: true });
366
+ fs.writeFileSync(scopesPath, JSON.stringify(scopes, null, 4), 'utf8');
367
+ }
368
+ }
369
+ catch { }
370
+ return fromScope;
371
+ }
372
+ return 'unknown_scope';
373
+ }
374
+ // Already a scope name (e.g. flow action saves carry the name directly).
375
+ if (/^[a-z0-9_.\-]+$/i.test(scopeVal))
376
+ return scopeVal;
377
+ return 'unknown_scope';
378
+ }
379
+ async queryScopeFromInstance(msg, endpoint, queryParams, resultField) {
380
+ if (!this.ws.hasBrowserClient())
381
+ return undefined;
382
+ try {
383
+ const correlationId = crypto.randomUUID();
384
+ const pendingPromise = this.pending.register({ id: correlationId, command: 'resolve_scope_for_save', timeoutMs: 15_000 });
385
+ this.ws.sendToBrowser({
386
+ action: 'agentRestApi',
387
+ agentRequestId: correlationId,
388
+ endpoint,
389
+ method: 'GET',
390
+ queryParams,
391
+ instance: msg.instance,
392
+ appName: 'SN Utils CLI',
393
+ });
394
+ const res = await pendingPromise;
395
+ if (res?.success === false)
396
+ return undefined;
397
+ const value = res?.data?.result?.[resultField];
398
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
399
+ }
400
+ catch {
401
+ return undefined;
402
+ }
403
+ }
177
404
  listInstanceFolders() {
178
405
  try {
179
406
  const entries = fs.readdirSync(this.cwd, { withFileTypes: true });
@@ -384,10 +611,13 @@ class StandaloneDispatcher {
384
611
  },
385
612
  };
386
613
  }
387
- // Check browser connection for all instance/remote commands
614
+ // Check browser connection for all instance/remote commands. Not a
615
+ // failure of the tool: a user-actionable setup state, so ship guidance
616
+ // steps in details for the CLI and MCP surfaces to render.
388
617
  if (!this.ws.hasBrowserClient()) {
389
- throw Object.assign(new Error('Browser helper disconnected. Open the SN Utils helper tab via /token in ServiceNow.'), {
618
+ throw Object.assign(new Error('ServiceNow is not connected: the SN Utils helper tab is not open.'), {
390
619
  code: 'E_BROWSER_DISCONNECTED',
620
+ details: { guidance: exports.HELPER_CONNECT_GUIDANCE },
391
621
  });
392
622
  }
393
623
  const inst = this.resolveInstance(req.instance);
@@ -397,16 +627,21 @@ class StandaloneDispatcher {
397
627
  for (const gateName of policy.gates) {
398
628
  // A. Check Host Gate (Fail-closed standalone authority)
399
629
  if (!this.config.gates[gateName]) {
400
- const label = gateName === 'backgroundScripts' ? 'Background Scripts' : gateName === 'deleteRecords' ? 'Delete Records' : gateName;
401
- throw Object.assign(new Error(`${label} is disabled in standalone host config. Pass --allow-${gateName.replace(/[A-Z]/g, (l) => '-' + l.toLowerCase())} or set SNU_ALLOW_${gateName.toUpperCase()}=1.`), { code: 'E_DISABLED' });
630
+ const label = GATE_LABELS[gateName] || gateName;
631
+ const envVar = GATE_ENV_VARS[gateName];
632
+ throw Object.assign(new Error(`${label} is disabled in this snu host config, so ${req.command} cannot run. ` +
633
+ `Turn it on with ${envVar}=1 in the MCP server's env block, or "${gateName}": true in ~/.sn-scriptsync/settings.json, then restart snu. ` +
634
+ `This is the user's decision to make: ask them to enable it rather than routing around it through the browser UI.`), { code: 'E_DISABLED', details: { gate: gateName, envVar, source: 'host' } });
402
635
  }
403
636
  // B. Check Helper Instance Gate (if instanceSecurityGates capability is present)
404
637
  const helperState = this.ws.getHelperState();
405
638
  if (helperState.capabilities?.instanceSecurityGates && instanceOrigin) {
406
639
  const helperGateMode = this.ws.getInstanceGate(instanceUrl, gateName);
407
640
  if (helperGateMode === 'off' || helperGateMode === false) {
408
- const label = gateName === 'backgroundScripts' ? 'Background Scripts' : gateName === 'deleteRecords' ? 'Delete Records' : gateName;
409
- throw Object.assign(new Error(`This instance (${instanceOrigin}) does not permit ${label.toLowerCase()} in SN Utils helper.`), { code: 'E_DISABLED' });
641
+ const label = GATE_LABELS[gateName] || gateName;
642
+ throw Object.assign(new Error(`This instance (${instanceOrigin}) does not permit ${label} in the SN Utils helper, so ${req.command} cannot run. ` +
643
+ `The user can turn it on in the helper tab's Agent Access tab for this instance. ` +
644
+ `This is the user's decision to make: ask them to enable it rather than routing around it through the browser UI.`), { code: 'E_DISABLED', details: { gate: gateName, instanceOrigin, source: 'instance' } });
410
645
  }
411
646
  }
412
647
  }
@@ -699,6 +934,61 @@ class StandaloneDispatcher {
699
934
  },
700
935
  };
701
936
  }
937
+ // Create Record (plain data row). Same insert as create_artifact minus the
938
+ // name requirement and the local _map.json tracking, which only makes
939
+ // sense for artifacts that have a file in the workspace.
940
+ if (req.command === 'create_record') {
941
+ const rawTable = req.params?.table;
942
+ if (!rawTable || typeof rawTable !== 'string' || !/^[a-zA-Z0-9_]+$/.test(rawTable.trim())) {
943
+ throw Object.assign(new Error('Missing or invalid param "table" (must be alphanumeric/underscore)'), { code: 'E_INVALID_PARAMS' });
944
+ }
945
+ const table = rawTable.trim();
946
+ const fields = req.params?.fields;
947
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) {
948
+ throw Object.assign(new Error('Missing required param "fields": provide at least one field value for the new record'), { code: 'E_INVALID_PARAMS' });
949
+ }
950
+ const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 70_000 });
951
+ this.ws.sendToBrowser({
952
+ action: 'agentRestApi',
953
+ agentRequestId: correlationId,
954
+ endpoint: `/api/now/table/${table}`,
955
+ method: 'POST',
956
+ body: fields,
957
+ queryParams: { sysparm_display_value: 'false', sysparm_exclude_reference_link: 'true' },
958
+ instance: inst.settings,
959
+ appName: 'SN Utils CLI',
960
+ });
961
+ const res = await pendingPromise;
962
+ if (res.success === false) {
963
+ const message = res.error || `Failed to create a record on ${table}`;
964
+ throw Object.assign(new Error(message), {
965
+ code: res.code || codeForRestStatus(res.status, message),
966
+ details: { status: res.status, detail: res.detail ?? null },
967
+ });
968
+ }
969
+ // POST /api/now/table returns the inserted row, so the write is already
970
+ // verified: no follow-up get_record needed.
971
+ const record = res.data?.result ?? null;
972
+ const readField = (name) => {
973
+ const value = record?.[name];
974
+ if (value && typeof value === 'object')
975
+ return String(value.value ?? value.display_value ?? '');
976
+ return value === undefined || value === null ? '' : String(value);
977
+ };
978
+ return {
979
+ id: req.id,
980
+ command: req.command,
981
+ status: 'success',
982
+ timestamp: Date.now(),
983
+ result: {
984
+ created: true,
985
+ table,
986
+ sys_id: readField('sys_id'),
987
+ name: readField('number') || readField('name') || readField('sys_name') || readField('short_description'),
988
+ record,
989
+ },
990
+ };
991
+ }
702
992
  // Schema Metadata
703
993
  if (req.command === 'get_table_metadata') {
704
994
  const table = req.params?.table;
@@ -833,29 +1123,16 @@ class StandaloneDispatcher {
833
1123
  warnings.push(`Could not resolve map path for ${scope}/${table}: ${e?.message || e}`);
834
1124
  continue;
835
1125
  }
836
- let nameToSysId = {};
837
- if (fs.existsSync(mapPath)) {
838
- try {
839
- nameToSysId = JSON.parse(fs.readFileSync(mapPath, 'utf8')) || {};
840
- }
841
- catch { }
842
- }
843
- let cleanName = name.replace(/[^a-z0-9._\-+]+/gi, '').replace(/\./g, '-') || sysId;
844
- const existingKey = Object.keys(nameToSysId).find((k) => nameToSysId[k] === sysId);
845
- if (existingKey) {
846
- cleanName = existingKey;
847
- }
848
- else if (nameToSysId[cleanName] && nameToSysId[cleanName] !== sysId) {
849
- cleanName = `${cleanName}-${sysId.slice(0, 2)}${sysId.slice(-2)}`.toUpperCase();
850
- }
851
- nameToSysId[cleanName] = sysId;
1126
+ const { cleanName, renamedTo, map: nameToSysId } = resolveMappedFileName(mapPath, name, sysId);
852
1127
  try {
853
- fs.mkdirSync(path.dirname(mapPath), { recursive: true });
854
- fs.writeFileSync(mapPath, JSON.stringify(nameToSysId, null, 4), 'utf8');
1128
+ writeMapFile(mapPath, nameToSysId);
855
1129
  }
856
1130
  catch (e) {
857
1131
  warnings.push(`Failed to write _map.json at ${mapPath}: ${e?.message || e}`);
858
1132
  }
1133
+ if (renamedTo) {
1134
+ warnings.push(`Record ${sysId} is named '${renamedTo}' on the instance but keeps local file name '${cleanName}' (rename tracked in _map.json).`);
1135
+ }
859
1136
  if (table === 'sp_widget') {
860
1137
  try {
861
1138
  const testUrlsPath = safeJoinUnderRoot(this.cwd, inst.name, scope, table, cleanName, '_test_urls.txt');
@@ -940,6 +1217,47 @@ class StandaloneDispatcher {
940
1217
  },
941
1218
  };
942
1219
  }
1220
+ // Generic REST passthrough. The escape hatch the typed commands are built
1221
+ // on: the browser helper's agentRestApi action has always accepted every
1222
+ // method plus a body, so this needs no extension-side change. Gating is
1223
+ // handled above by getCommandPolicy (GET open, POST/PUT/PATCH behind
1224
+ // restRequest, DELETE behind deleteRecords).
1225
+ if (req.command === 'rest_request') {
1226
+ const endpoint = req.params?.endpoint;
1227
+ if (!endpoint || typeof endpoint !== 'string' || !endpoint.startsWith('/')) {
1228
+ throw Object.assign(new Error("Missing/invalid 'endpoint' (must be an instance-relative path beginning with '/', e.g. /api/now/table/incident)"), { code: 'E_INVALID_PARAMS' });
1229
+ }
1230
+ const method = String(req.params?.method || 'GET').toUpperCase();
1231
+ if (!REST_METHODS.includes(method)) {
1232
+ throw Object.assign(new Error(`Invalid method. Must be one of: ${REST_METHODS.join(', ')}`), { code: 'E_INVALID_PARAMS' });
1233
+ }
1234
+ const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 70_000 });
1235
+ this.ws.sendToBrowser({
1236
+ action: 'agentRestApi',
1237
+ agentRequestId: correlationId,
1238
+ endpoint,
1239
+ method,
1240
+ body: req.params?.body,
1241
+ queryParams: req.params?.queryParams && typeof req.params.queryParams === 'object' ? req.params.queryParams : undefined,
1242
+ instance: inst.settings,
1243
+ appName: 'SN Utils CLI',
1244
+ });
1245
+ const res = await pendingPromise;
1246
+ if (res.success === false) {
1247
+ const message = res.error || 'REST request failed';
1248
+ throw Object.assign(new Error(message), {
1249
+ code: res.code || codeForRestStatus(res.status, message),
1250
+ details: { status: res.status, detail: res.detail ?? null },
1251
+ });
1252
+ }
1253
+ return {
1254
+ id: req.id,
1255
+ command: req.command,
1256
+ status: 'success',
1257
+ timestamp: Date.now(),
1258
+ result: { status: res.status, data: res.data },
1259
+ };
1260
+ }
943
1261
  // Browser Form & UI Actions
944
1262
  if (['get_form_state', 'set_field', 'run_ui_action', 'navigate', 'take_screenshot'].includes(req.command)) {
945
1263
  const actionMap = {