nowaikit 4.12.0 → 4.15.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 +12 -5
- package/dist/cli/auth.d.ts +13 -0
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +237 -50
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/detect-clients.d.ts.map +1 -1
- package/dist/cli/detect-clients.js +78 -2
- package/dist/cli/detect-clients.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +218 -9
- package/dist/cli/setup.js.map +1 -1
- package/dist/servicenow/client.d.ts +49 -1
- package/dist/servicenow/client.d.ts.map +1 -1
- package/dist/servicenow/client.js +367 -46
- package/dist/servicenow/client.js.map +1 -1
- package/dist/servicenow/instances.d.ts.map +1 -1
- package/dist/servicenow/instances.js +56 -4
- package/dist/servicenow/instances.js.map +1 -1
- package/dist/servicenow/types.d.ts +17 -0
- package/dist/servicenow/types.d.ts.map +1 -1
- package/dist/tools/app-studio.d.ts.map +1 -1
- package/dist/tools/app-studio.js +80 -9
- package/dist/tools/app-studio.js.map +1 -1
- package/dist/tools/change.d.ts.map +1 -1
- package/dist/tools/change.js +3 -1
- package/dist/tools/change.js.map +1 -1
- package/dist/tools/core.d.ts.map +1 -1
- package/dist/tools/core.js +28 -2
- package/dist/tools/core.js.map +1 -1
- package/dist/tools/incident.d.ts.map +1 -1
- package/dist/tools/incident.js +5 -3
- package/dist/tools/incident.js.map +1 -1
- package/dist/tools/index.d.ts +159 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/knowledge.d.ts.map +1 -1
- package/dist/tools/knowledge.js +12 -1
- package/dist/tools/knowledge.js.map +1 -1
- package/dist/tools/notification.d.ts +169 -0
- package/dist/tools/notification.d.ts.map +1 -1
- package/dist/tools/notification.js +88 -9
- package/dist/tools/notification.js.map +1 -1
- package/dist/tools/problem.d.ts.map +1 -1
- package/dist/tools/problem.js +3 -1
- package/dist/tools/problem.js.map +1 -1
- package/dist/tools/updateset.d.ts.map +1 -1
- package/dist/tools/updateset.js +62 -38
- package/dist/tools/updateset.js.map +1 -1
- package/dist/tools-manifest.json +82 -11
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/servicenow/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EAGpB,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAKpB;;;;;GAKG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CAc5C;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/servicenow/client.ts"],"names":[],"mappings":"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"}
|
|
@@ -28,16 +28,45 @@ export function basicAuthDiagnostic() {
|
|
|
28
28
|
*/
|
|
29
29
|
export function forbiddenDiagnostic(authMethod) {
|
|
30
30
|
const lines = [
|
|
31
|
-
'Authenticated, but not authorized
|
|
32
|
-
'
|
|
31
|
+
'Authenticated, but not authorized (HTTP 403). If ServiceNow returned a specific reason above',
|
|
32
|
+
'(e.g. a "Data Policy Exception" or an ACL detail), that IS the actual cause — read it first.',
|
|
33
|
+
'Otherwise, common causes are:',
|
|
34
|
+
' 1. A data policy or field-level ACL is rejecting a field in the request (e.g. a conditionally',
|
|
35
|
+
' mandatory or read-only field). This can fail some requests and succeed on others depending on',
|
|
36
|
+
' the exact fields sent, even for the same user.',
|
|
37
|
+
' 2. The account is missing a role this needs (e.g. itil for ITSM, admin for config tables).',
|
|
33
38
|
];
|
|
34
39
|
if (authMethod === 'oauth') {
|
|
35
|
-
lines.push('
|
|
40
|
+
lines.push(' 3. Your OAuth app/token is missing API scope. In the OAuth application registry, grant the', ' required scope (e.g. "useraccount"), and make sure the token\'s user actually holds the roles.');
|
|
36
41
|
}
|
|
37
|
-
lines.push('
|
|
42
|
+
lines.push(' 4. Writes need WRITE_ENABLED=true AND the user\'s write roles; some tools also need scripting/CMDB/ATF flags.');
|
|
38
43
|
return lines.join('\n');
|
|
39
44
|
}
|
|
40
45
|
// ─── Input validation helpers ────────────────────────────────────────────────
|
|
46
|
+
/** Best-effort MIME type from a file extension, for attachments fetched without a content type. */
|
|
47
|
+
function guessContentType(fileName) {
|
|
48
|
+
const ext = (fileName.split('.').pop() || '').toLowerCase();
|
|
49
|
+
const map = {
|
|
50
|
+
pdf: 'application/pdf',
|
|
51
|
+
doc: 'application/msword',
|
|
52
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
53
|
+
xls: 'application/vnd.ms-excel',
|
|
54
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
55
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
56
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
57
|
+
csv: 'text/csv',
|
|
58
|
+
txt: 'text/plain',
|
|
59
|
+
json: 'application/json',
|
|
60
|
+
xml: 'application/xml',
|
|
61
|
+
html: 'text/html',
|
|
62
|
+
png: 'image/png',
|
|
63
|
+
jpg: 'image/jpeg',
|
|
64
|
+
jpeg: 'image/jpeg',
|
|
65
|
+
gif: 'image/gif',
|
|
66
|
+
zip: 'application/zip',
|
|
67
|
+
};
|
|
68
|
+
return map[ext] || 'application/octet-stream';
|
|
69
|
+
}
|
|
41
70
|
/** Validate and sanitize ServiceNow table names (alphanumeric + underscores only) */
|
|
42
71
|
function validateTableName(table) {
|
|
43
72
|
if (!table || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(table)) {
|
|
@@ -86,6 +115,10 @@ export class ServiceNowClient {
|
|
|
86
115
|
impersonateUserSysId;
|
|
87
116
|
/** For per-user mode: pre-loaded token overrides service-account auth */
|
|
88
117
|
perUserBearerToken;
|
|
118
|
+
/** Local per-user OAuth only: refresh token + expiry + persist callback (unset for the gateway). */
|
|
119
|
+
perUserRefreshToken;
|
|
120
|
+
perUserTokenExpiry;
|
|
121
|
+
onTokenRefreshed;
|
|
89
122
|
accessToken;
|
|
90
123
|
tokenExpiry;
|
|
91
124
|
constructor(config) {
|
|
@@ -99,6 +132,49 @@ export class ServiceNowClient {
|
|
|
99
132
|
this.requestTimeoutMs = config.requestTimeoutMs || 30000;
|
|
100
133
|
this.impersonateUserSysId = config.impersonateUserSysId;
|
|
101
134
|
this.perUserBearerToken = config.perUserBearerToken;
|
|
135
|
+
this.perUserRefreshToken = config.perUserRefreshToken;
|
|
136
|
+
this.perUserTokenExpiry = config.perUserTokenExpiry;
|
|
137
|
+
this.onTokenRefreshed = config.onTokenRefreshed;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* True only for LOCAL per-user OAuth that owns a refresh token + client id (i.e. can renew its
|
|
141
|
+
* own token). The multi-tenant gateway's per-user mode has no refresh token, so this is false
|
|
142
|
+
* there and the injected token is used as-is — the gateway path is unchanged.
|
|
143
|
+
*/
|
|
144
|
+
hasPerUserRefresh() {
|
|
145
|
+
return this.authMode === 'per-user' && !!this.perUserRefreshToken && !!this.oauthConfig?.clientId;
|
|
146
|
+
}
|
|
147
|
+
/** Renew the per-user access token using the stored refresh token (refresh_token grant). */
|
|
148
|
+
async refreshPerUserToken() {
|
|
149
|
+
if (!this.perUserRefreshToken || !this.oauthConfig?.clientId) {
|
|
150
|
+
throw new ServiceNowError('Cannot refresh: no refresh token or OAuth client configured', 'AUTHENTICATION_FAILED');
|
|
151
|
+
}
|
|
152
|
+
const body = new URLSearchParams({
|
|
153
|
+
grant_type: 'refresh_token',
|
|
154
|
+
client_id: this.oauthConfig.clientId,
|
|
155
|
+
refresh_token: this.perUserRefreshToken,
|
|
156
|
+
});
|
|
157
|
+
// Confidential clients send the secret; public (PKCE) clients omit it.
|
|
158
|
+
if (this.oauthConfig.clientSecret)
|
|
159
|
+
body.set('client_secret', this.oauthConfig.clientSecret);
|
|
160
|
+
const resp = await fetch(`${this.baseUrl}/oauth_token.do`, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
163
|
+
body: body.toString(),
|
|
164
|
+
});
|
|
165
|
+
if (!resp.ok) {
|
|
166
|
+
throw new ServiceNowError(`OAuth token refresh failed: ${resp.status} ${resp.statusText}`, 'AUTHENTICATION_FAILED');
|
|
167
|
+
}
|
|
168
|
+
const data = await resp.json();
|
|
169
|
+
this.perUserBearerToken = data.access_token;
|
|
170
|
+
if (data.refresh_token)
|
|
171
|
+
this.perUserRefreshToken = data.refresh_token; // honour rotation
|
|
172
|
+
const expiresAt = Date.now() + (data.expires_in ? data.expires_in * 1000 * 0.9 : 25 * 60 * 1000);
|
|
173
|
+
this.perUserTokenExpiry = expiresAt;
|
|
174
|
+
try {
|
|
175
|
+
this.onTokenRefreshed?.({ accessToken: this.perUserBearerToken, refreshToken: this.perUserRefreshToken, expiresAt });
|
|
176
|
+
}
|
|
177
|
+
catch { /* persistence is best-effort; never fail the request over it */ }
|
|
102
178
|
}
|
|
103
179
|
/**
|
|
104
180
|
* The ServiceNow instance host this client targets (e.g. "acme.service-now.com").
|
|
@@ -112,6 +188,56 @@ export class ServiceNowClient {
|
|
|
112
188
|
return this.baseUrl.toLowerCase();
|
|
113
189
|
}
|
|
114
190
|
}
|
|
191
|
+
/** Public accessor for the resolved instance base URL (no trailing slash), for building UI links. */
|
|
192
|
+
get instanceUrl() {
|
|
193
|
+
return this.baseUrl;
|
|
194
|
+
}
|
|
195
|
+
/** Human-facing record URL (opens the form in ServiceNow), not the /api/now REST link. */
|
|
196
|
+
recordUrl(table, sysId) {
|
|
197
|
+
return `${this.baseUrl}/${encodeURIComponent(table)}.do?sys_id=${encodeURIComponent(sysId)}`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Return the fields that ServiceNow Data Policies make mandatory on a table, so an agent can
|
|
201
|
+
* collect them BEFORE a create/update and avoid the "Data Policy Exception: mandatory fields"
|
|
202
|
+
* error. Reads sys_data_policy_rule (the mandatory rules) and sys_data_policy2 (the condition
|
|
203
|
+
* under which each applies). All server-side, read-only.
|
|
204
|
+
*/
|
|
205
|
+
async getMandatoryFields(table) {
|
|
206
|
+
const t = validateTableName(table);
|
|
207
|
+
const rules = await this.queryRecords({
|
|
208
|
+
table: 'sys_data_policy_rule',
|
|
209
|
+
query: `table=${t}^mandatory=true`,
|
|
210
|
+
fields: 'field,sys_data_policy',
|
|
211
|
+
limit: 100,
|
|
212
|
+
});
|
|
213
|
+
const rows = rules.records ?? rules ?? [];
|
|
214
|
+
// Resolve the parent policy conditions once.
|
|
215
|
+
const policyIds = Array.from(new Set(rows.map((r) => (typeof r.sys_data_policy === 'object' ? r.sys_data_policy?.value : r.sys_data_policy)).filter(Boolean)));
|
|
216
|
+
const condByPolicy = new Map();
|
|
217
|
+
if (policyIds.length) {
|
|
218
|
+
const pol = await this.queryRecords({
|
|
219
|
+
table: 'sys_data_policy2',
|
|
220
|
+
query: `sys_idIN${policyIds.join(',')}`,
|
|
221
|
+
fields: 'sys_id,short_description,conditions,active',
|
|
222
|
+
limit: 100,
|
|
223
|
+
});
|
|
224
|
+
for (const p of (pol.records ?? pol ?? [])) {
|
|
225
|
+
condByPolicy.set(p.sys_id, { desc: p.short_description, conditions: p.conditions, active: p.active });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const fields = rows.map((r) => {
|
|
229
|
+
const pid = typeof r.sys_data_policy === 'object' ? r.sys_data_policy?.value : r.sys_data_policy;
|
|
230
|
+
const meta = condByPolicy.get(pid) || {};
|
|
231
|
+
return { field: r.field, applies_when: meta.conditions || 'always', policy: meta.desc, active: meta.active };
|
|
232
|
+
}).filter((f) => f.active !== 'false');
|
|
233
|
+
return {
|
|
234
|
+
table: t,
|
|
235
|
+
mandatory_fields: fields,
|
|
236
|
+
summary: fields.length
|
|
237
|
+
? `On ${t}, these fields are made mandatory by data policy: ${fields.map((f) => f.field).join(', ')}. Collect them before creating or updating.`
|
|
238
|
+
: `No data-policy mandatory fields found on ${t} (dictionary-level mandatory fields may still apply).`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
115
241
|
/**
|
|
116
242
|
* Return a copy of this client configured to run as a specific user.
|
|
117
243
|
* Used for per-request user context switching without mutating the shared client.
|
|
@@ -149,6 +275,11 @@ export class ServiceNowClient {
|
|
|
149
275
|
if (options.bearerToken) {
|
|
150
276
|
copy.authMode = 'per-user';
|
|
151
277
|
copy.perUserBearerToken = options.bearerToken;
|
|
278
|
+
// A per-request injected token (the gateway path) is not ours to refresh — never carry over
|
|
279
|
+
// any refresh state from the base client. Keeps hasPerUserRefresh() false for the gateway.
|
|
280
|
+
copy.perUserRefreshToken = undefined;
|
|
281
|
+
copy.perUserTokenExpiry = undefined;
|
|
282
|
+
copy.onTokenRefreshed = undefined;
|
|
152
283
|
}
|
|
153
284
|
return copy;
|
|
154
285
|
}
|
|
@@ -159,6 +290,11 @@ export class ServiceNowClient {
|
|
|
159
290
|
// Per-user mode carries the user's own bearer token (delegated auth) —
|
|
160
291
|
// no service-account token acquisition needed.
|
|
161
292
|
if (this.authMode === 'per-user' && this.perUserBearerToken) {
|
|
293
|
+
// Local stored-session OAuth: proactively refresh a token that is expired or within 60s of it.
|
|
294
|
+
// The gateway path has no refresh token (hasPerUserRefresh() === false), so it is untouched.
|
|
295
|
+
if (this.hasPerUserRefresh() && this.perUserTokenExpiry && Date.now() >= this.perUserTokenExpiry - 60_000) {
|
|
296
|
+
await this.refreshPerUserToken();
|
|
297
|
+
}
|
|
162
298
|
return;
|
|
163
299
|
}
|
|
164
300
|
if (this.authMethod === 'basic') {
|
|
@@ -277,6 +413,11 @@ export class ServiceNowClient {
|
|
|
277
413
|
const errorJson = JSON.parse(errorText);
|
|
278
414
|
if (errorJson.error?.message) {
|
|
279
415
|
errorMessage = errorJson.error.message;
|
|
416
|
+
// ServiceNow puts the ACTUAL reason (data policy, ACL, mandatory/read-only field) in
|
|
417
|
+
// `detail`; `message` is often just the generic "Operation Failed". Surface both.
|
|
418
|
+
if (errorJson.error.detail) {
|
|
419
|
+
errorMessage += `: ${String(errorJson.error.detail).replace(/\s+/g, ' ').trim()}`;
|
|
420
|
+
}
|
|
280
421
|
}
|
|
281
422
|
}
|
|
282
423
|
catch {
|
|
@@ -298,6 +439,17 @@ export class ServiceNowClient {
|
|
|
298
439
|
catch { /* fall through to the throw below */ }
|
|
299
440
|
continue;
|
|
300
441
|
}
|
|
442
|
+
// Local per-user OAuth that owns a refresh token: renew once and retry. The multi-tenant
|
|
443
|
+
// gateway's injected-token per-user mode has NO refresh token, so hasPerUserRefresh() is
|
|
444
|
+
// false there and this is skipped — the live Copilot path is unchanged.
|
|
445
|
+
if (this.hasPerUserRefresh() && !reauthed) {
|
|
446
|
+
reauthed = true;
|
|
447
|
+
try {
|
|
448
|
+
await this.refreshPerUserToken();
|
|
449
|
+
}
|
|
450
|
+
catch { /* fall through to the throw below */ }
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
301
453
|
// Attach the actionable checklist so a bare 401 isn't a dead end.
|
|
302
454
|
if (this.authMethod === 'basic') {
|
|
303
455
|
errorMessage = `${errorMessage}\n\n${basicAuthDiagnostic()}`;
|
|
@@ -322,7 +474,9 @@ export class ServiceNowClient {
|
|
|
322
474
|
lastError = error instanceof Error ? error : new Error('Unknown error');
|
|
323
475
|
// Don't retry on auth errors or invalid requests
|
|
324
476
|
if (error instanceof ServiceNowError) {
|
|
325
|
-
|
|
477
|
+
// 403 is not transient (a role/ACL/data-policy denial won't clear in 4s), so fail fast
|
|
478
|
+
// instead of burning ~7s on retries.
|
|
479
|
+
if (['AUTHENTICATION_FAILED', 'INVALID_REQUEST', 'NOT_FOUND', 'INSUFFICIENT_PRIVILEGES'].includes(error.code)) {
|
|
326
480
|
throw error;
|
|
327
481
|
}
|
|
328
482
|
}
|
|
@@ -363,6 +517,11 @@ export class ServiceNowClient {
|
|
|
363
517
|
if (params.fields) {
|
|
364
518
|
queryParams.set('sysparm_fields', params.fields);
|
|
365
519
|
}
|
|
520
|
+
// Return reference fields as readable names, not raw sys_ids. 'all' keeps the sys_id too
|
|
521
|
+
// (as {display_value, value}) so the agent can still act on the record.
|
|
522
|
+
if (params.displayValue) {
|
|
523
|
+
queryParams.set('sysparm_display_value', params.displayValue === 'all' ? 'all' : 'true');
|
|
524
|
+
}
|
|
366
525
|
if (params.limit !== undefined && params.limit > 0) {
|
|
367
526
|
queryParams.set('sysparm_limit', Math.min(params.limit, 1000).toString());
|
|
368
527
|
}
|
|
@@ -453,7 +612,7 @@ export class ServiceNowClient {
|
|
|
453
612
|
/**
|
|
454
613
|
* Get a single record by sys_id
|
|
455
614
|
*/
|
|
456
|
-
async getRecord(table, sysId, fields) {
|
|
615
|
+
async getRecord(table, sysId, fields, displayValue) {
|
|
457
616
|
validateTableName(table);
|
|
458
617
|
validateSysId(sysId);
|
|
459
618
|
await this.authenticate();
|
|
@@ -461,6 +620,9 @@ export class ServiceNowClient {
|
|
|
461
620
|
if (fields) {
|
|
462
621
|
queryParams.set('sysparm_fields', fields);
|
|
463
622
|
}
|
|
623
|
+
if (displayValue) {
|
|
624
|
+
queryParams.set('sysparm_display_value', displayValue === 'all' ? 'all' : 'true');
|
|
625
|
+
}
|
|
464
626
|
const url = `${this.baseUrl}/api/now/table/${table}/${sysId}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
|
465
627
|
logger.info(`Getting record from ${table}: ${sysId}`);
|
|
466
628
|
try {
|
|
@@ -971,12 +1133,73 @@ export class ServiceNowClient {
|
|
|
971
1133
|
* Accepts base64-encoded content and uploads it as a multipart form.
|
|
972
1134
|
*/
|
|
973
1135
|
async uploadAttachment(table, recordSysId, fileName, contentType, contentBase64) {
|
|
1136
|
+
// Decode base64 to binary, then hand off to the shared binary uploader.
|
|
1137
|
+
const binary = Buffer.from(contentBase64, 'base64');
|
|
1138
|
+
return this.uploadAttachmentBuffer(table, recordSysId, fileName, contentType, binary);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Attach a file to a record by fetching its bytes server-side from a URL, so the file content
|
|
1142
|
+
* never has to travel through the LLM/tool call as a base64 argument. This is the reliable path
|
|
1143
|
+
* for anything larger than a few hundred KB. `sourceHeaders` lets the caller pass auth (e.g. a
|
|
1144
|
+
* bearer/basic credential) for any protected URL. Content type is taken from the caller, then
|
|
1145
|
+
* the response's Content-Type, then guessed from the file extension.
|
|
1146
|
+
*/
|
|
1147
|
+
async uploadAttachmentFromUrl(table, recordSysId, fileName, sourceUrl, contentType, sourceHeaders) {
|
|
1148
|
+
const MAX_FETCH_BYTES = 50 * 1024 * 1024; // 50MB safety cap on server-side fetch
|
|
1149
|
+
let src;
|
|
1150
|
+
try {
|
|
1151
|
+
src = new URL(sourceUrl);
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
throw new ServiceNowError(`source_url is not a valid URL: ${sourceUrl}`, 'INVALID_REQUEST');
|
|
1155
|
+
}
|
|
1156
|
+
if (src.protocol !== 'https:' && src.protocol !== 'http:') {
|
|
1157
|
+
throw new ServiceNowError(`source_url must be http(s), got ${src.protocol}`, 'INVALID_REQUEST');
|
|
1158
|
+
}
|
|
1159
|
+
logger.info(`Fetching attachment "${fileName}" from source URL for ${table}:${recordSysId}`);
|
|
1160
|
+
let fetched;
|
|
1161
|
+
try {
|
|
1162
|
+
fetched = await fetch(src, { headers: sourceHeaders ?? {} });
|
|
1163
|
+
}
|
|
1164
|
+
catch (error) {
|
|
1165
|
+
throw new ServiceNowError(`Failed to fetch source_url: ${error instanceof Error ? error.message : 'Unknown error'}`, 'ATTACHMENT_SOURCE_FETCH_FAILED');
|
|
1166
|
+
}
|
|
1167
|
+
if (!fetched.ok) {
|
|
1168
|
+
throw new ServiceNowError(`source_url returned HTTP ${fetched.status} ${fetched.statusText}`, 'ATTACHMENT_SOURCE_FETCH_FAILED');
|
|
1169
|
+
}
|
|
1170
|
+
const arrayBuf = await fetched.arrayBuffer();
|
|
1171
|
+
const binary = Buffer.from(arrayBuf);
|
|
1172
|
+
if (binary.length > MAX_FETCH_BYTES) {
|
|
1173
|
+
throw new ServiceNowError(`Fetched file is ${(binary.length / 1024 / 1024).toFixed(1)}MB, over the ${MAX_FETCH_BYTES / 1024 / 1024}MB limit`, 'ATTACHMENT_TOO_LARGE');
|
|
1174
|
+
}
|
|
1175
|
+
const responseType = fetched.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() || '';
|
|
1176
|
+
const resolvedType = contentType || responseType || guessContentType(fileName);
|
|
1177
|
+
// Phantom-attachment guard: a protected/expired link (e.g. SharePoint) often returns a tiny
|
|
1178
|
+
// HTML/JSON error page with HTTP 200. Uploading that as "document.pdf" creates an empty, broken
|
|
1179
|
+
// attachment that looks successful. Reject the obvious cases instead of attaching junk.
|
|
1180
|
+
const expectedType = (contentType || guessContentType(fileName)).toLowerCase();
|
|
1181
|
+
const expectsBinary = !expectedType.startsWith('text/') && !/json|xml|csv|html/.test(expectedType);
|
|
1182
|
+
const looksLikeErrorPage = /text\/html|application\/json|application\/xml/.test(responseType);
|
|
1183
|
+
const head = binary.subarray(0, 5).toString('latin1');
|
|
1184
|
+
const magicOk = !expectedType.includes('pdf') ? true : head.startsWith('%PDF'); // PDFs must start with %PDF
|
|
1185
|
+
if (binary.length < 100 && expectsBinary) {
|
|
1186
|
+
throw new ServiceNowError(`source_url returned only ${binary.length} bytes for "${fileName}" — this is almost certainly an error/redirect page, not the file. ` +
|
|
1187
|
+
'The link is likely protected or expired (for SharePoint, use the pre-authorised @microsoft.graph.downloadUrl). Nothing was attached.', 'ATTACHMENT_SOURCE_NOT_A_FILE');
|
|
1188
|
+
}
|
|
1189
|
+
if (expectsBinary && looksLikeErrorPage) {
|
|
1190
|
+
throw new ServiceNowError(`source_url returned ${responseType} but "${fileName}" was expected to be a binary file — this looks like an error page, not the document. Nothing was attached.`, 'ATTACHMENT_SOURCE_NOT_A_FILE');
|
|
1191
|
+
}
|
|
1192
|
+
if (!magicOk) {
|
|
1193
|
+
throw new ServiceNowError(`source_url content is not a valid PDF (missing %PDF header) for "${fileName}". The link likely returned an error page. Nothing was attached.`, 'ATTACHMENT_SOURCE_NOT_A_FILE');
|
|
1194
|
+
}
|
|
1195
|
+
return this.uploadAttachmentBuffer(table, recordSysId, fileName, resolvedType, binary);
|
|
1196
|
+
}
|
|
1197
|
+
/** Shared core: POST raw bytes to ServiceNow's native binary attachment endpoint. */
|
|
1198
|
+
async uploadAttachmentBuffer(table, recordSysId, fileName, contentType, binary) {
|
|
974
1199
|
await this.authenticate();
|
|
975
1200
|
const url = `${this.baseUrl}/api/now/attachment/file?table_name=${encodeURIComponent(table)}&table_sys_id=${encodeURIComponent(recordSysId)}&file_name=${encodeURIComponent(fileName)}`;
|
|
976
|
-
logger.info(`Uploading attachment "${fileName}" to ${table}:${recordSysId}`);
|
|
1201
|
+
logger.info(`Uploading attachment "${fileName}" (${binary.length} bytes) to ${table}:${recordSysId}`);
|
|
977
1202
|
try {
|
|
978
|
-
// Decode base64 to binary
|
|
979
|
-
const binary = Buffer.from(contentBase64, 'base64');
|
|
980
1203
|
const response = await fetch(url, {
|
|
981
1204
|
method: 'POST',
|
|
982
1205
|
headers: {
|
|
@@ -991,8 +1214,11 @@ export class ServiceNowClient {
|
|
|
991
1214
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
992
1215
|
try {
|
|
993
1216
|
const errorJson = JSON.parse(errorText);
|
|
994
|
-
if (errorJson.error?.message)
|
|
1217
|
+
if (errorJson.error?.message) {
|
|
995
1218
|
errorMessage = errorJson.error.message;
|
|
1219
|
+
if (errorJson.error.detail)
|
|
1220
|
+
errorMessage += `: ${String(errorJson.error.detail).replace(/\s+/g, ' ').trim()}`;
|
|
1221
|
+
}
|
|
996
1222
|
}
|
|
997
1223
|
catch {
|
|
998
1224
|
// ignore parse error
|
|
@@ -1008,6 +1234,76 @@ export class ServiceNowClient {
|
|
|
1008
1234
|
throw new ServiceNowError(`Failed to upload attachment: ${error instanceof Error ? error.message : 'Unknown error'}`, 'ATTACHMENT_UPLOAD_FAILED');
|
|
1009
1235
|
}
|
|
1010
1236
|
}
|
|
1237
|
+
/** Fetch an existing attachment's metadata + raw bytes, server-side (no LLM involvement). */
|
|
1238
|
+
async fetchAttachment(attachmentSysId) {
|
|
1239
|
+
await this.authenticate();
|
|
1240
|
+
const id = validateSysId(attachmentSysId);
|
|
1241
|
+
// Metadata (file_name, content_type, size) via the attachment record.
|
|
1242
|
+
const metaRes = await fetch(`${this.baseUrl}/api/now/attachment/${encodeURIComponent(id)}`, {
|
|
1243
|
+
headers: { Authorization: this.getAuthHeader(), Accept: 'application/json' },
|
|
1244
|
+
});
|
|
1245
|
+
if (!metaRes.ok) {
|
|
1246
|
+
throw new ServiceNowError(metaRes.status === 404 ? `Attachment not found: ${id}` : `HTTP ${metaRes.status}: ${metaRes.statusText}`, metaRes.status === 404 ? 'NOT_FOUND' : 'ATTACHMENT_READ_FAILED');
|
|
1247
|
+
}
|
|
1248
|
+
const meta = (await metaRes.json()).result ?? {};
|
|
1249
|
+
// Raw bytes via the binary file endpoint.
|
|
1250
|
+
const fileRes = await fetch(`${this.baseUrl}/api/now/attachment/${encodeURIComponent(id)}/file`, {
|
|
1251
|
+
headers: { Authorization: this.getAuthHeader() },
|
|
1252
|
+
});
|
|
1253
|
+
if (!fileRes.ok) {
|
|
1254
|
+
throw new ServiceNowError(`HTTP ${fileRes.status}: ${fileRes.statusText}`, 'ATTACHMENT_READ_FAILED');
|
|
1255
|
+
}
|
|
1256
|
+
const bytes = Buffer.from(await fileRes.arrayBuffer());
|
|
1257
|
+
return { meta, bytes };
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Copy an existing ServiceNow attachment onto another record, entirely server-side. The bytes
|
|
1261
|
+
* are fetched from the source attachment and re-posted to the target, so nothing passes through
|
|
1262
|
+
* the LLM/tool call. Great for "attach the same document from the RITM onto the KB".
|
|
1263
|
+
*/
|
|
1264
|
+
async copyAttachment(attachmentSysId, targetTable, targetRecordSysId, newFileName) {
|
|
1265
|
+
const { meta, bytes } = await this.fetchAttachment(attachmentSysId);
|
|
1266
|
+
const fileName = newFileName || meta.file_name || `attachment-${attachmentSysId}`;
|
|
1267
|
+
const contentType = meta.content_type || guessContentType(fileName);
|
|
1268
|
+
logger.info(`Copying attachment ${attachmentSysId} (${bytes.length} bytes) to ${targetTable}:${targetRecordSysId}`);
|
|
1269
|
+
return this.uploadAttachmentBuffer(targetTable, targetRecordSysId, fileName, contentType, bytes);
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Read an existing ServiceNow attachment and return its text content when the file is text-based
|
|
1273
|
+
* (txt/csv/json/xml/html/md). For binary formats (PDF/DOCX/images) it returns metadata plus a note
|
|
1274
|
+
* rather than dumping bytes, since those need a dedicated extractor. All server-side.
|
|
1275
|
+
*/
|
|
1276
|
+
async readAttachment(attachmentSysId, maxChars = 200_000) {
|
|
1277
|
+
const { meta, bytes } = await this.fetchAttachment(attachmentSysId);
|
|
1278
|
+
const ct = String(meta.content_type || guessContentType(meta.file_name || '')).toLowerCase();
|
|
1279
|
+
const name = String(meta.file_name || '').toLowerCase();
|
|
1280
|
+
const isTextual = ct.startsWith('text/') ||
|
|
1281
|
+
/json|xml|csv|html|yaml|markdown|javascript|x-www-form-urlencoded/.test(ct) ||
|
|
1282
|
+
/\.(txt|csv|json|xml|html?|md|log|yaml|yml|tsv)$/.test(name);
|
|
1283
|
+
const base = {
|
|
1284
|
+
attachment_sys_id: attachmentSysId,
|
|
1285
|
+
file_name: meta.file_name,
|
|
1286
|
+
content_type: meta.content_type,
|
|
1287
|
+
size_bytes: bytes.length,
|
|
1288
|
+
};
|
|
1289
|
+
if (isTextual) {
|
|
1290
|
+
const text = bytes.toString('utf8');
|
|
1291
|
+
const truncated = text.length > maxChars;
|
|
1292
|
+
return {
|
|
1293
|
+
...base,
|
|
1294
|
+
text: truncated ? text.slice(0, maxChars) : text,
|
|
1295
|
+
truncated,
|
|
1296
|
+
summary: `Read ${bytes.length} bytes of text from "${meta.file_name}"${truncated ? ` (truncated to ${maxChars} chars)` : ''}`,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
...base,
|
|
1301
|
+
text: null,
|
|
1302
|
+
extractable: false,
|
|
1303
|
+
note: `"${meta.file_name}" is a binary format (${meta.content_type}) that needs a dedicated text extractor. ` +
|
|
1304
|
+
'Use copy_attachment to move it between records, or process the file where it was uploaded.',
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1011
1307
|
/**
|
|
1012
1308
|
* Execute multiple REST API operations in a single HTTP call (Batch API).
|
|
1013
1309
|
* Uses /api/now/v1/batch endpoint. Up to 50 operations per batch.
|
|
@@ -1070,50 +1366,75 @@ export class ServiceNowClient {
|
|
|
1070
1366
|
*/
|
|
1071
1367
|
async executeScript(script, scope) {
|
|
1072
1368
|
await this.authenticate();
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1369
|
+
// ServiceNow has NO supported REST endpoint for running arbitrary background scripts. The only
|
|
1370
|
+
// reliable server-side path is an optional helper the customer installs and reviews: a scoped,
|
|
1371
|
+
// secured Scripted REST API. Point us at it with SCRIPT_EXEC_ENDPOINT (e.g. "/api/x_nowaikit/exec")
|
|
1372
|
+
// and this executes through it; otherwise we fail with a clear, actionable message rather than
|
|
1373
|
+
// silently hitting a non-existent endpoint (the previous implementation targeted a bogus
|
|
1374
|
+
// sys_script_execution table wrapped in a malformed Batch request and never worked).
|
|
1375
|
+
// 1) Optional customer-installed helper (a scoped, secured Scripted REST API) if configured.
|
|
1376
|
+
const endpoint = process.env.SCRIPT_EXEC_ENDPOINT;
|
|
1377
|
+
if (endpoint) {
|
|
1378
|
+
logger.info('Executing server-side script via SCRIPT_EXEC_ENDPOINT');
|
|
1379
|
+
const url = `${this.baseUrl}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`;
|
|
1380
|
+
const response = await this.request(url, {
|
|
1077
1381
|
method: 'POST',
|
|
1078
|
-
body: JSON.stringify({
|
|
1079
|
-
batch_request_id: `script_${Date.now()}`,
|
|
1080
|
-
rest_requests: [{
|
|
1081
|
-
id: 'script_exec',
|
|
1082
|
-
method: 'POST',
|
|
1083
|
-
url: '/api/now/table/sys_script_execution',
|
|
1084
|
-
headers: [
|
|
1085
|
-
{ name: 'Content-Type', value: 'application/json' },
|
|
1086
|
-
{ name: 'Accept', value: 'application/json' },
|
|
1087
|
-
],
|
|
1088
|
-
body: JSON.stringify({
|
|
1089
|
-
script,
|
|
1090
|
-
scope: scope || 'global',
|
|
1091
|
-
}),
|
|
1092
|
-
}],
|
|
1093
|
-
}),
|
|
1382
|
+
body: JSON.stringify({ script, scope: scope || 'global' }),
|
|
1094
1383
|
});
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1384
|
+
return { status: 200, output: (response && response.result !== undefined) ? response.result : response, scope: scope || 'global' };
|
|
1385
|
+
}
|
|
1386
|
+
// 2) Default (no install): run via a one-time, self-terminating scheduled job. ServiceNow has no
|
|
1387
|
+
// REST endpoint to run background scripts, but the SCHEDULER is a separate path — and the job record
|
|
1388
|
+
// is created with the Table API, which works. The wrapper captures the result to a temp property,
|
|
1389
|
+
// then deactivates and deletes the job.
|
|
1390
|
+
return await this.executeViaScheduledJob(script);
|
|
1391
|
+
}
|
|
1392
|
+
async executeViaScheduledJob(script) {
|
|
1393
|
+
const runId = `nwk_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
1394
|
+
const jobName = `NowAIKit exec ${runId}`;
|
|
1395
|
+
const prop = `nowaikit.exec.${runId}`;
|
|
1396
|
+
const wrapper = `var __r, __e = null;\n` +
|
|
1397
|
+
`try { __r = (function(){\n${script}\n})(); } catch (e) { __e = '' + e; }\n` +
|
|
1398
|
+
`try { gs.setProperty(${JSON.stringify(prop)}, JSON.stringify({ ok: __e === null, result: (__r === undefined ? null : __r), error: __e })); } catch (ig) {}\n` +
|
|
1399
|
+
`try { var __j = new GlideRecord('sysauto_script'); if (__j.get('name', ${JSON.stringify(jobName)})) { __j.active = false; __j.deleteRecord(); } } catch (ig) {}\n`;
|
|
1400
|
+
const nowGmt = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
1401
|
+
let jobSysId;
|
|
1402
|
+
try {
|
|
1403
|
+
const job = await this.createRecord('sysauto_script', {
|
|
1404
|
+
name: jobName, script: wrapper, active: 'true', run_type: 'once', run_start: nowGmt, next_action: nowGmt,
|
|
1405
|
+
});
|
|
1406
|
+
jobSysId = job?.sys_id;
|
|
1407
|
+
}
|
|
1408
|
+
catch (e) {
|
|
1409
|
+
throw new ServiceNowError(`Server-side script execution needs to create a one-time scheduled job (sysauto_script), but that ` +
|
|
1410
|
+
`write was rejected: ${e instanceof Error ? e.message : String(e)}. ` +
|
|
1411
|
+
`Alternatives: use the Table API tools for data changes, run it in ServiceNow (Scripts - Background), ` +
|
|
1412
|
+
`or install the optional script-exec helper and set SCRIPT_EXEC_ENDPOINT.`, 'SCRIPT_EXEC_UNAVAILABLE');
|
|
1413
|
+
}
|
|
1414
|
+
// Poll for the result property (scheduler latency is typically a few to ~60s).
|
|
1415
|
+
const deadline = Date.now() + 90_000;
|
|
1416
|
+
while (Date.now() < deadline) {
|
|
1417
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1418
|
+
const res = await this.queryRecords({ table: 'sys_properties', query: `name=${prop}`, fields: 'sys_id,value', limit: 1 });
|
|
1419
|
+
if (res.count > 0) {
|
|
1420
|
+
const rec = res.records[0];
|
|
1421
|
+
try {
|
|
1422
|
+
await this.deleteRecord('sys_properties', rec.sys_id);
|
|
1423
|
+
}
|
|
1424
|
+
catch { /* leave it */ }
|
|
1425
|
+
let parsed;
|
|
1098
1426
|
try {
|
|
1099
|
-
|
|
1427
|
+
parsed = JSON.parse(rec.value);
|
|
1100
1428
|
}
|
|
1101
1429
|
catch {
|
|
1102
|
-
|
|
1430
|
+
parsed = { ok: true, result: rec.value };
|
|
1103
1431
|
}
|
|
1104
|
-
return {
|
|
1105
|
-
status: results[0].status_code,
|
|
1106
|
-
output: body,
|
|
1107
|
-
scope: scope || 'global',
|
|
1108
|
-
};
|
|
1432
|
+
return { status: 200, ...parsed, via: 'scheduled-job' };
|
|
1109
1433
|
}
|
|
1110
|
-
return { status: 200, output: 'Script executed (no output captured)', scope: scope || 'global' };
|
|
1111
|
-
}
|
|
1112
|
-
catch (error) {
|
|
1113
|
-
if (error instanceof ServiceNowError)
|
|
1114
|
-
throw error;
|
|
1115
|
-
throw new ServiceNowError(`Script execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'SCRIPT_FAILED');
|
|
1116
1434
|
}
|
|
1435
|
+
throw new ServiceNowError(`Script was scheduled (sysauto_script "${jobName}"${jobSysId ? ' ' + jobSysId : ''}) but did not report a ` +
|
|
1436
|
+
`result within 90s — the scheduler may be backed up or the script was blocked by instance hardening. ` +
|
|
1437
|
+
`Check the job and its output on the instance.`, 'SCRIPT_EXEC_TIMEOUT');
|
|
1117
1438
|
}
|
|
1118
1439
|
/**
|
|
1119
1440
|
* Natural language update (simplified implementation)
|