nowaikit 4.12.1 → 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 +346 -40
- 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 +1 -1
|
@@ -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,CAoBzE;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/servicenow/client.ts"],"names":[],"mappings":"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"}
|
|
@@ -43,6 +43,30 @@ export function forbiddenDiagnostic(authMethod) {
|
|
|
43
43
|
return lines.join('\n');
|
|
44
44
|
}
|
|
45
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
|
+
}
|
|
46
70
|
/** Validate and sanitize ServiceNow table names (alphanumeric + underscores only) */
|
|
47
71
|
function validateTableName(table) {
|
|
48
72
|
if (!table || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(table)) {
|
|
@@ -91,6 +115,10 @@ export class ServiceNowClient {
|
|
|
91
115
|
impersonateUserSysId;
|
|
92
116
|
/** For per-user mode: pre-loaded token overrides service-account auth */
|
|
93
117
|
perUserBearerToken;
|
|
118
|
+
/** Local per-user OAuth only: refresh token + expiry + persist callback (unset for the gateway). */
|
|
119
|
+
perUserRefreshToken;
|
|
120
|
+
perUserTokenExpiry;
|
|
121
|
+
onTokenRefreshed;
|
|
94
122
|
accessToken;
|
|
95
123
|
tokenExpiry;
|
|
96
124
|
constructor(config) {
|
|
@@ -104,6 +132,49 @@ export class ServiceNowClient {
|
|
|
104
132
|
this.requestTimeoutMs = config.requestTimeoutMs || 30000;
|
|
105
133
|
this.impersonateUserSysId = config.impersonateUserSysId;
|
|
106
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 */ }
|
|
107
178
|
}
|
|
108
179
|
/**
|
|
109
180
|
* The ServiceNow instance host this client targets (e.g. "acme.service-now.com").
|
|
@@ -117,6 +188,56 @@ export class ServiceNowClient {
|
|
|
117
188
|
return this.baseUrl.toLowerCase();
|
|
118
189
|
}
|
|
119
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
|
+
}
|
|
120
241
|
/**
|
|
121
242
|
* Return a copy of this client configured to run as a specific user.
|
|
122
243
|
* Used for per-request user context switching without mutating the shared client.
|
|
@@ -154,6 +275,11 @@ export class ServiceNowClient {
|
|
|
154
275
|
if (options.bearerToken) {
|
|
155
276
|
copy.authMode = 'per-user';
|
|
156
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;
|
|
157
283
|
}
|
|
158
284
|
return copy;
|
|
159
285
|
}
|
|
@@ -164,6 +290,11 @@ export class ServiceNowClient {
|
|
|
164
290
|
// Per-user mode carries the user's own bearer token (delegated auth) —
|
|
165
291
|
// no service-account token acquisition needed.
|
|
166
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
|
+
}
|
|
167
298
|
return;
|
|
168
299
|
}
|
|
169
300
|
if (this.authMethod === 'basic') {
|
|
@@ -308,6 +439,17 @@ export class ServiceNowClient {
|
|
|
308
439
|
catch { /* fall through to the throw below */ }
|
|
309
440
|
continue;
|
|
310
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
|
+
}
|
|
311
453
|
// Attach the actionable checklist so a bare 401 isn't a dead end.
|
|
312
454
|
if (this.authMethod === 'basic') {
|
|
313
455
|
errorMessage = `${errorMessage}\n\n${basicAuthDiagnostic()}`;
|
|
@@ -375,6 +517,11 @@ export class ServiceNowClient {
|
|
|
375
517
|
if (params.fields) {
|
|
376
518
|
queryParams.set('sysparm_fields', params.fields);
|
|
377
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
|
+
}
|
|
378
525
|
if (params.limit !== undefined && params.limit > 0) {
|
|
379
526
|
queryParams.set('sysparm_limit', Math.min(params.limit, 1000).toString());
|
|
380
527
|
}
|
|
@@ -465,7 +612,7 @@ export class ServiceNowClient {
|
|
|
465
612
|
/**
|
|
466
613
|
* Get a single record by sys_id
|
|
467
614
|
*/
|
|
468
|
-
async getRecord(table, sysId, fields) {
|
|
615
|
+
async getRecord(table, sysId, fields, displayValue) {
|
|
469
616
|
validateTableName(table);
|
|
470
617
|
validateSysId(sysId);
|
|
471
618
|
await this.authenticate();
|
|
@@ -473,6 +620,9 @@ export class ServiceNowClient {
|
|
|
473
620
|
if (fields) {
|
|
474
621
|
queryParams.set('sysparm_fields', fields);
|
|
475
622
|
}
|
|
623
|
+
if (displayValue) {
|
|
624
|
+
queryParams.set('sysparm_display_value', displayValue === 'all' ? 'all' : 'true');
|
|
625
|
+
}
|
|
476
626
|
const url = `${this.baseUrl}/api/now/table/${table}/${sysId}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
|
477
627
|
logger.info(`Getting record from ${table}: ${sysId}`);
|
|
478
628
|
try {
|
|
@@ -983,12 +1133,73 @@ export class ServiceNowClient {
|
|
|
983
1133
|
* Accepts base64-encoded content and uploads it as a multipart form.
|
|
984
1134
|
*/
|
|
985
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) {
|
|
986
1199
|
await this.authenticate();
|
|
987
1200
|
const url = `${this.baseUrl}/api/now/attachment/file?table_name=${encodeURIComponent(table)}&table_sys_id=${encodeURIComponent(recordSysId)}&file_name=${encodeURIComponent(fileName)}`;
|
|
988
|
-
logger.info(`Uploading attachment "${fileName}" to ${table}:${recordSysId}`);
|
|
1201
|
+
logger.info(`Uploading attachment "${fileName}" (${binary.length} bytes) to ${table}:${recordSysId}`);
|
|
989
1202
|
try {
|
|
990
|
-
// Decode base64 to binary
|
|
991
|
-
const binary = Buffer.from(contentBase64, 'base64');
|
|
992
1203
|
const response = await fetch(url, {
|
|
993
1204
|
method: 'POST',
|
|
994
1205
|
headers: {
|
|
@@ -1023,6 +1234,76 @@ export class ServiceNowClient {
|
|
|
1023
1234
|
throw new ServiceNowError(`Failed to upload attachment: ${error instanceof Error ? error.message : 'Unknown error'}`, 'ATTACHMENT_UPLOAD_FAILED');
|
|
1024
1235
|
}
|
|
1025
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
|
+
}
|
|
1026
1307
|
/**
|
|
1027
1308
|
* Execute multiple REST API operations in a single HTTP call (Batch API).
|
|
1028
1309
|
* Uses /api/now/v1/batch endpoint. Up to 50 operations per batch.
|
|
@@ -1085,50 +1366,75 @@ export class ServiceNowClient {
|
|
|
1085
1366
|
*/
|
|
1086
1367
|
async executeScript(script, scope) {
|
|
1087
1368
|
await this.authenticate();
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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, {
|
|
1092
1381
|
method: 'POST',
|
|
1093
|
-
body: JSON.stringify({
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1382
|
+
body: JSON.stringify({ script, scope: scope || 'global' }),
|
|
1383
|
+
});
|
|
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,
|
|
1109
1405
|
});
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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;
|
|
1113
1426
|
try {
|
|
1114
|
-
|
|
1427
|
+
parsed = JSON.parse(rec.value);
|
|
1115
1428
|
}
|
|
1116
1429
|
catch {
|
|
1117
|
-
|
|
1430
|
+
parsed = { ok: true, result: rec.value };
|
|
1118
1431
|
}
|
|
1119
|
-
return {
|
|
1120
|
-
status: results[0].status_code,
|
|
1121
|
-
output: body,
|
|
1122
|
-
scope: scope || 'global',
|
|
1123
|
-
};
|
|
1432
|
+
return { status: 200, ...parsed, via: 'scheduled-job' };
|
|
1124
1433
|
}
|
|
1125
|
-
return { status: 200, output: 'Script executed (no output captured)', scope: scope || 'global' };
|
|
1126
|
-
}
|
|
1127
|
-
catch (error) {
|
|
1128
|
-
if (error instanceof ServiceNowError)
|
|
1129
|
-
throw error;
|
|
1130
|
-
throw new ServiceNowError(`Script execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'SCRIPT_FAILED');
|
|
1131
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');
|
|
1132
1438
|
}
|
|
1133
1439
|
/**
|
|
1134
1440
|
* Natural language update (simplified implementation)
|