abapgit-agent 1.17.8 → 1.18.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 +1 -0
- package/abap/CLAUDE.md +150 -25
- package/abap/CLAUDE.slim.md +5 -4
- package/abap/guidelines/abaplint.md +2 -0
- package/abap/guidelines/cds-testing.md +12 -0
- package/abap/guidelines/cds.md +7 -0
- package/abap/guidelines/debug-dump.md +4 -0
- package/abap/guidelines/debug-session.md +27 -2
- package/abap/guidelines/run-probe-classes.md +43 -0
- package/abap/guidelines/string-template.md +66 -1
- package/bin/abapgit-agent +3 -2
- package/package.json +10 -6
- package/src/commands/debug.js +156 -119
- package/src/commands/guide.js +17 -0
- package/src/commands/inspect.js +7 -4
- package/src/commands/pull.js +32 -14
- package/src/commands/unit.js +2 -1
- package/src/commands/view.js +1 -1
- package/src/config.js +13 -1
- package/src/utils/abap-http.js +136 -252
- package/src/utils/adt-http.js +134 -216
- package/src/utils/debug-daemon.js +57 -48
- package/src/utils/debug-session.js +126 -25
package/src/utils/adt-http.js
CHANGED
|
@@ -1,26 +1,97 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* ADT HTTP client for SAP ABAP Development Tools REST API
|
|
5
|
-
*
|
|
4
|
+
* ADT HTTP client for SAP ABAP Development Tools REST API.
|
|
5
|
+
*
|
|
6
|
+
* Uses axios with a single instance per AdtHttp — all requests share the same
|
|
7
|
+
* connection pool, cookie jar, and CSRF token. This mirrors abap-adt-api's
|
|
8
|
+
* approach and is required for ADT debug sessions where SAP pins the debug
|
|
9
|
+
* work process to the originating HTTP session.
|
|
6
10
|
*/
|
|
11
|
+
const axios = require('axios');
|
|
7
12
|
const https = require('https');
|
|
8
|
-
const http = require('http');
|
|
9
13
|
const fs = require('fs');
|
|
10
14
|
const path = require('path');
|
|
11
15
|
const { extractBodyDetail } = require('./format-error');
|
|
12
16
|
const os = require('os');
|
|
13
17
|
const crypto = require('crypto');
|
|
14
18
|
|
|
15
|
-
/**
|
|
16
|
-
* ADT HTTP client with CSRF token, cookie, and session caching.
|
|
17
|
-
* Mirrors AbapHttp but targets /sap/bc/adt/* with XML content-type.
|
|
18
|
-
*/
|
|
19
19
|
class AdtHttp {
|
|
20
20
|
constructor(config) {
|
|
21
21
|
this.config = config;
|
|
22
22
|
this.csrfToken = null;
|
|
23
23
|
this.cookies = null;
|
|
24
|
+
this.stateful = ''; // Set to 'stateful' to pin all requests to one WP
|
|
25
|
+
|
|
26
|
+
const isHttp = config.protocol === 'http';
|
|
27
|
+
const baseURL = `${config.protocol || 'https'}://${config.host}:${config.sapport}`;
|
|
28
|
+
this._axios = axios.create({
|
|
29
|
+
baseURL,
|
|
30
|
+
...(isHttp
|
|
31
|
+
? { httpAgent: new (require('http').Agent)({ keepAlive: true }) }
|
|
32
|
+
: { httpsAgent: new https.Agent({ rejectUnauthorized: false, keepAlive: true }) }),
|
|
33
|
+
maxRedirects: 0,
|
|
34
|
+
validateStatus: () => true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Request interceptor: inject auth, CSRF, cookies, session type on every request
|
|
38
|
+
this._axios.interceptors.request.use((reqConfig) => {
|
|
39
|
+
reqConfig.headers['Authorization'] =
|
|
40
|
+
`Basic ${Buffer.from(`${config.user}:${config.password}`).toString('base64')}`;
|
|
41
|
+
reqConfig.headers['sap-client'] = config.client;
|
|
42
|
+
reqConfig.headers['sap-language'] = config.language || 'EN';
|
|
43
|
+
// Session type header on EVERY request (like abap-adt-api line 324).
|
|
44
|
+
// When stateful, forces SAP to create/maintain SAP_SESSIONID.
|
|
45
|
+
if (this.stateful) reqConfig.headers['X-sap-adt-sessiontype'] = this.stateful;
|
|
46
|
+
if (this.cookies) reqConfig.headers['Cookie'] = this.cookies;
|
|
47
|
+
// Send CSRF on ALL requests (like abap-adt-api). SAP validates it for session routing.
|
|
48
|
+
if (this.csrfToken) reqConfig.headers['X-CSRF-Token'] = this.csrfToken;
|
|
49
|
+
if (process.env.DEBUG_ADT === '1') {
|
|
50
|
+
const cookieNames = (this.cookies || '').split(';').map(c => c.trim().split('=')[0]).filter(Boolean).join(',');
|
|
51
|
+
const hasStateful = reqConfig.headers['X-sap-adt-sessiontype'] || '-';
|
|
52
|
+
const hasCsrf = this.csrfToken ? 'yes' : 'no';
|
|
53
|
+
process.stderr.write(`[adt] → ${(reqConfig.method || 'GET').toUpperCase()} ${reqConfig.url} stateful=${hasStateful} csrf=${hasCsrf} cookies=[${cookieNames}]\n`);
|
|
54
|
+
}
|
|
55
|
+
return reqConfig;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Response interceptor: merge Set-Cookie headers into the cookie jar.
|
|
59
|
+
// Skip on error responses (>= 400) — they may set stale cookies that
|
|
60
|
+
// overwrite valid session state (e.g. sap-usercontext on 400).
|
|
61
|
+
this._axios.interceptors.response.use((resp) => {
|
|
62
|
+
if (process.env.DEBUG_ADT === '1') {
|
|
63
|
+
const newCookies = (resp.headers['set-cookie'] || []).map(c => c.split('=')[0]).join(',');
|
|
64
|
+
const sock = resp.request && resp.request.socket;
|
|
65
|
+
const port = sock ? sock.localPort : '?';
|
|
66
|
+
process.stderr.write(`[adt] ← ${resp.status} port=${port} set-cookie=[${newCookies}]\n`);
|
|
67
|
+
}
|
|
68
|
+
if (resp.status >= 400) return resp;
|
|
69
|
+
const setCookie = resp.headers['set-cookie'];
|
|
70
|
+
if (setCookie) {
|
|
71
|
+
const incoming = Array.isArray(setCookie)
|
|
72
|
+
? setCookie.map(c => c.split(';')[0])
|
|
73
|
+
: [setCookie.split(';')[0]];
|
|
74
|
+
const jar = new Map();
|
|
75
|
+
if (this.cookies) {
|
|
76
|
+
this.cookies.split(';').forEach(pair => {
|
|
77
|
+
const trimmed = pair.trim();
|
|
78
|
+
if (trimmed) {
|
|
79
|
+
const eq = trimmed.indexOf('=');
|
|
80
|
+
jar.set(eq === -1 ? trimmed : trimmed.slice(0, eq).trim(), trimmed);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
incoming.forEach(pair => {
|
|
85
|
+
const trimmed = pair.trim();
|
|
86
|
+
if (trimmed) {
|
|
87
|
+
const eq = trimmed.indexOf('=');
|
|
88
|
+
jar.set(eq === -1 ? trimmed : trimmed.slice(0, eq).trim(), trimmed);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
this.cookies = [...jar.values()].join('; ');
|
|
92
|
+
}
|
|
93
|
+
return resp;
|
|
94
|
+
});
|
|
24
95
|
|
|
25
96
|
const configHash = crypto.createHash('md5')
|
|
26
97
|
.update(`${config.host}:${config.user}:${config.client}`)
|
|
@@ -75,44 +146,15 @@ class AdtHttp {
|
|
|
75
146
|
* Fetch CSRF token via GET /sap/bc/adt/discovery with X-CSRF-Token: fetch
|
|
76
147
|
*/
|
|
77
148
|
async fetchCsrfToken() {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
path: url.pathname,
|
|
84
|
-
method: 'GET',
|
|
85
|
-
headers: {
|
|
86
|
-
'Authorization': `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`,
|
|
87
|
-
'sap-client': this.config.client,
|
|
88
|
-
'sap-language': this.config.language || 'EN',
|
|
89
|
-
'X-CSRF-Token': 'fetch',
|
|
90
|
-
'Accept': 'application/atomsvc+xml'
|
|
91
|
-
},
|
|
92
|
-
agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const req = (this.config.protocol === 'http' ? http : https).request(options, (res) => {
|
|
96
|
-
const csrfToken = res.headers['x-csrf-token'];
|
|
97
|
-
const setCookie = res.headers['set-cookie'];
|
|
98
|
-
if (setCookie) {
|
|
99
|
-
this.cookies = Array.isArray(setCookie)
|
|
100
|
-
? setCookie.map(c => c.split(';')[0]).join('; ')
|
|
101
|
-
: setCookie.split(';')[0];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let body = '';
|
|
105
|
-
res.on('data', chunk => body += chunk);
|
|
106
|
-
res.on('end', () => {
|
|
107
|
-
this.csrfToken = csrfToken;
|
|
108
|
-
this.saveSession();
|
|
109
|
-
resolve(csrfToken);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
req.on('error', reject);
|
|
114
|
-
req.end();
|
|
149
|
+
const resp = await this._axios.get('/sap/bc/adt/discovery', {
|
|
150
|
+
headers: {
|
|
151
|
+
'X-CSRF-Token': 'fetch',
|
|
152
|
+
'Accept': 'application/atomsvc+xml'
|
|
153
|
+
}
|
|
115
154
|
});
|
|
155
|
+
this.csrfToken = resp.headers['x-csrf-token'] || this.csrfToken;
|
|
156
|
+
this.saveSession();
|
|
157
|
+
return this.csrfToken;
|
|
116
158
|
}
|
|
117
159
|
|
|
118
160
|
/**
|
|
@@ -124,7 +166,7 @@ class AdtHttp {
|
|
|
124
166
|
return await this._makeRequest(method, urlPath, body, options);
|
|
125
167
|
} catch (error) {
|
|
126
168
|
if (this._isAuthError(error) && !options.isRetry) {
|
|
127
|
-
this.
|
|
169
|
+
this.csrfToken = null;
|
|
128
170
|
await this.fetchCsrfToken();
|
|
129
171
|
return await this._makeRequest(method, urlPath, body, { ...options, isRetry: true });
|
|
130
172
|
}
|
|
@@ -140,114 +182,42 @@ class AdtHttp {
|
|
|
140
182
|
}
|
|
141
183
|
|
|
142
184
|
async _makeRequest(method, urlPath, body = null, options = {}) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (this.cookies) {
|
|
161
|
-
headers['Cookie'] = this.cookies;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const bodyStr = body || '';
|
|
165
|
-
headers['Content-Length'] = bodyStr ? Buffer.byteLength(bodyStr) : 0;
|
|
166
|
-
|
|
167
|
-
const reqOptions = {
|
|
168
|
-
hostname: url.hostname,
|
|
169
|
-
port: url.port,
|
|
170
|
-
path: url.pathname + url.search,
|
|
171
|
-
method,
|
|
172
|
-
headers,
|
|
173
|
-
agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (res) => {
|
|
177
|
-
if (res.statusCode === 401) {
|
|
178
|
-
reject({ statusCode: 401, message: 'Authentication failed: 401' });
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
if (res.statusCode === 403) {
|
|
182
|
-
const errMsg = 'Missing debug authorization. Grant S_ADT_RES (ACTVT=16) to user.';
|
|
183
|
-
reject({ statusCode: 403, message: errMsg });
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (res.statusCode === 404) {
|
|
187
|
-
let respBody = '';
|
|
188
|
-
res.on('data', chunk => respBody += chunk);
|
|
189
|
-
res.on('end', () => {
|
|
190
|
-
reject({ statusCode: 404, message: `HTTP 404: ${reqOptions.path}`, body: respBody });
|
|
191
|
-
});
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
if (res.statusCode >= 400) {
|
|
195
|
-
let respBody = '';
|
|
196
|
-
res.on('data', chunk => respBody += chunk);
|
|
197
|
-
res.on('end', () => {
|
|
198
|
-
const detail = extractBodyDetail(respBody);
|
|
199
|
-
const message = detail
|
|
200
|
-
? `(HTTP ${res.statusCode}) ${detail}`
|
|
201
|
-
: `(HTTP ${res.statusCode}) ${res.statusMessage || 'Internal Server Error'}`;
|
|
202
|
-
reject({ statusCode: res.statusCode, message, body: respBody });
|
|
203
|
-
});
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
185
|
+
const headers = {
|
|
186
|
+
'Content-Type': options.contentType || 'application/atom+xml',
|
|
187
|
+
'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
|
|
188
|
+
...options.headers
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const resp = await this._axios.request({
|
|
192
|
+
method,
|
|
193
|
+
url: urlPath,
|
|
194
|
+
data: body || undefined,
|
|
195
|
+
headers,
|
|
196
|
+
responseType: 'text',
|
|
197
|
+
// Long timeout for listener long-polls (up to 5 min)
|
|
198
|
+
timeout: options.timeout || 300000,
|
|
199
|
+
});
|
|
206
200
|
|
|
207
|
-
|
|
208
|
-
// Merge by name so that updated values (e.g. SAP_SESSIONID) replace
|
|
209
|
-
// their old counterparts rather than accumulating duplicates.
|
|
210
|
-
// Duplicate SAP_SESSIONID cookies would cause the ICM to route requests
|
|
211
|
-
// to a stale work process ("Service cannot be reached").
|
|
212
|
-
if (res.headers['set-cookie']) {
|
|
213
|
-
const incoming = Array.isArray(res.headers['set-cookie'])
|
|
214
|
-
? res.headers['set-cookie'].map(c => c.split(';')[0])
|
|
215
|
-
: [res.headers['set-cookie'].split(';')[0]];
|
|
216
|
-
// Parse existing cookies into a Map (preserves insertion order)
|
|
217
|
-
const jar = new Map();
|
|
218
|
-
if (this.cookies) {
|
|
219
|
-
this.cookies.split(';').forEach(pair => {
|
|
220
|
-
const trimmed = pair.trim();
|
|
221
|
-
if (trimmed) {
|
|
222
|
-
const eq = trimmed.indexOf('=');
|
|
223
|
-
const k = eq === -1 ? trimmed : trimmed.slice(0, eq);
|
|
224
|
-
jar.set(k.trim(), trimmed);
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
// Overwrite with incoming cookies
|
|
229
|
-
incoming.forEach(pair => {
|
|
230
|
-
const trimmed = pair.trim();
|
|
231
|
-
if (trimmed) {
|
|
232
|
-
const eq = trimmed.indexOf('=');
|
|
233
|
-
const k = eq === -1 ? trimmed : trimmed.slice(0, eq);
|
|
234
|
-
jar.set(k.trim(), trimmed);
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
this.cookies = [...jar.values()].join('; ');
|
|
238
|
-
}
|
|
201
|
+
const statusCode = resp.status;
|
|
239
202
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
203
|
+
if (statusCode === 401) {
|
|
204
|
+
throw { statusCode: 401, message: 'Authentication failed: 401' };
|
|
205
|
+
}
|
|
206
|
+
if (statusCode === 403) {
|
|
207
|
+
throw { statusCode: 403, message: 'Missing debug authorization. Grant S_ADT_RES (ACTVT=16) to user.' };
|
|
208
|
+
}
|
|
209
|
+
if (statusCode === 404) {
|
|
210
|
+
throw { statusCode: 404, message: `HTTP 404: ${urlPath}`, body: resp.data || '' };
|
|
211
|
+
}
|
|
212
|
+
if (statusCode >= 400) {
|
|
213
|
+
const detail = extractBodyDetail(resp.data || '');
|
|
214
|
+
const message = detail
|
|
215
|
+
? `(HTTP ${statusCode}) ${detail}`
|
|
216
|
+
: `(HTTP ${statusCode}) ${resp.statusText || 'Internal Server Error'}`;
|
|
217
|
+
throw { statusCode, message, body: resp.data || '' };
|
|
218
|
+
}
|
|
246
219
|
|
|
247
|
-
|
|
248
|
-
if (bodyStr) req.write(bodyStr);
|
|
249
|
-
req.end();
|
|
250
|
-
});
|
|
220
|
+
return { body: resp.data || '', headers: resp.headers, statusCode };
|
|
251
221
|
}
|
|
252
222
|
|
|
253
223
|
async get(urlPath, options = {}) {
|
|
@@ -259,66 +229,24 @@ class AdtHttp {
|
|
|
259
229
|
}
|
|
260
230
|
|
|
261
231
|
/**
|
|
262
|
-
* Fire-and-forget POST:
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* Used by detach() (stepContinue) where:
|
|
266
|
-
* - ADT long-polls until the next breakpoint fires → response may never come
|
|
267
|
-
* - We only need ADT to *receive* the request, not respond to it
|
|
268
|
-
* - Using the existing stateful session (cookies/CSRF) is mandatory
|
|
269
|
-
*
|
|
270
|
-
* The socket is deliberately left open so the OS TCP stack can finish
|
|
271
|
-
* delivering the data to ADT after we return from this method.
|
|
272
|
-
*
|
|
273
|
-
* @param {string} urlPath - URL path
|
|
274
|
-
* @param {string} body - Request body (may be empty string)
|
|
275
|
-
* @param {object} options - Same options as post() (contentType, headers, etc.)
|
|
276
|
-
* @returns {Promise<void>} Resolves when req.end() callback fires
|
|
232
|
+
* Fire-and-forget POST: sends the request but does not wait for a full response.
|
|
233
|
+
* Used by detach() (stepContinue) where ADT may long-poll indefinitely.
|
|
277
234
|
*/
|
|
278
235
|
async postFire(urlPath, body = null, options = {}) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
'sap-language': this.config.language || 'EN',
|
|
287
|
-
...options.headers
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
|
|
291
|
-
|
|
292
|
-
if (this.csrfToken) {
|
|
293
|
-
headers['X-CSRF-Token'] = this.csrfToken;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (this.cookies) {
|
|
297
|
-
headers['Cookie'] = this.cookies;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const bodyStr = body || '';
|
|
301
|
-
headers['Content-Length'] = bodyStr ? Buffer.byteLength(bodyStr) : 0;
|
|
302
|
-
|
|
303
|
-
const reqOptions = {
|
|
304
|
-
hostname: url.hostname,
|
|
305
|
-
port: url.port,
|
|
306
|
-
path: url.pathname + url.search,
|
|
307
|
-
method: 'POST',
|
|
236
|
+
const headers = {
|
|
237
|
+
'Content-Type': options.contentType || 'application/atom+xml',
|
|
238
|
+
'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
|
|
239
|
+
...options.headers
|
|
240
|
+
};
|
|
241
|
+
try {
|
|
242
|
+
await this._axios.post(urlPath, body || undefined, {
|
|
308
243
|
headers,
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (_res) => {
|
|
313
|
-
// Drain response body to prevent socket hang; we don't use the data.
|
|
314
|
-
_res.resume();
|
|
244
|
+
timeout: 5000, // short timeout — we don't need the response
|
|
245
|
+
responseType: 'text',
|
|
315
246
|
});
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
if (bodyStr) req.write(bodyStr);
|
|
320
|
-
req.end(() => resolve());
|
|
321
|
-
});
|
|
247
|
+
} catch (e) {
|
|
248
|
+
// Ignore — fire-and-forget
|
|
249
|
+
}
|
|
322
250
|
}
|
|
323
251
|
|
|
324
252
|
async put(urlPath, body = null, options = {}) {
|
|
@@ -331,12 +259,6 @@ class AdtHttp {
|
|
|
331
259
|
|
|
332
260
|
/**
|
|
333
261
|
* Extract an attribute value from a simple XML element using regex.
|
|
334
|
-
* e.g. extractXmlAttr(xml, 'adtcore:uri', null) for text content
|
|
335
|
-
* extractXmlAttr(xml, 'entry', 'id') for attribute
|
|
336
|
-
* @param {string} xml - XML string
|
|
337
|
-
* @param {string} tag - Tag name (may include namespace prefix)
|
|
338
|
-
* @param {string|null} attr - Attribute name, or null for text content
|
|
339
|
-
* @returns {string|null} Extracted value or null
|
|
340
262
|
*/
|
|
341
263
|
static extractXmlAttr(xml, tag, attr) {
|
|
342
264
|
if (attr) {
|
|
@@ -351,10 +273,6 @@ class AdtHttp {
|
|
|
351
273
|
|
|
352
274
|
/**
|
|
353
275
|
* Extract all occurrences of a tag's content or attribute from XML.
|
|
354
|
-
* @param {string} xml - XML string
|
|
355
|
-
* @param {string} tag - Tag name
|
|
356
|
-
* @param {string|null} attr - Attribute name, or null for text content
|
|
357
|
-
* @returns {string[]} Array of matched values
|
|
358
276
|
*/
|
|
359
277
|
static extractXmlAll(xml, tag, attr) {
|
|
360
278
|
const results = [];
|
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Commands:
|
|
27
27
|
* { "cmd": "ping" }
|
|
28
|
-
* { "cmd": "step",
|
|
29
|
-
* { "cmd": "vars",
|
|
28
|
+
* { "cmd": "step", "type": "stepOver|stepInto|stepReturn|stepContinue" }
|
|
29
|
+
* { "cmd": "vars", "name": null }
|
|
30
|
+
* { "cmd": "expand", "id": "<adt-id>", "meta": { metaType, tableLines } }
|
|
31
|
+
* { "cmd": "expandPath", "pathParts": ["VAR", "[1]", "FIELD"] }
|
|
30
32
|
* { "cmd": "stack" }
|
|
31
33
|
* { "cmd": "terminate" }
|
|
32
34
|
*
|
|
@@ -49,63 +51,47 @@ const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
49
51
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
* Start the daemon server.
|
|
54
|
+
* Start the daemon server using an already-attached DebugSession.
|
|
53
55
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* Used by `debug attach --json`: after attach() succeeds the caller passes
|
|
57
|
+
* the live session directly so ALL requests share the same AdtHttp instance
|
|
58
|
+
* (and the same TCP connection). This is required because SAP pins the debug
|
|
59
|
+
* session to the originating TCP connection — a new connection returns
|
|
60
|
+
* HTTP 400 "Service cannot be reached".
|
|
61
|
+
*
|
|
62
|
+
* @param {object} session - DebugSession already attached
|
|
63
|
+
* @param {string} socketPath - Unix socket path to listen on
|
|
58
64
|
*/
|
|
59
|
-
async function startDaemon(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// Restore the exact SAP_SESSIONID cookie + CSRF token from the attach process.
|
|
63
|
-
// This guarantees the first IPC command reuses the same ABAP work process
|
|
64
|
-
// without another round-trip for a new token.
|
|
65
|
-
if (snapshot && snapshot.csrfToken) adt.csrfToken = snapshot.csrfToken;
|
|
66
|
-
if (snapshot && snapshot.cookies) adt.cookies = snapshot.cookies;
|
|
67
|
-
|
|
68
|
-
const session = new DebugSession(adt, sessionId);
|
|
69
|
-
|
|
70
|
-
// Pin the SAP_SESSIONID so _restorePinnedSession() works inside the daemon.
|
|
71
|
-
// DebugSession.attach() normally sets pinnedSessionId, but the daemon skips
|
|
72
|
-
// attach() and reconstructs the session from the snapshot. Without this,
|
|
73
|
-
// any CSRF refresh that AdtHttp performs internally (401/403 retry → HEAD
|
|
74
|
-
// request → new Set-Cookie) silently overwrites the session cookie and routes
|
|
75
|
-
// subsequent IPC calls to the wrong ABAP work process → HTTP 400.
|
|
76
|
-
if (snapshot && snapshot.cookies) {
|
|
77
|
-
const m = snapshot.cookies.match(/SAP_SESSIONID=([^;]*)/);
|
|
78
|
-
if (m) session.pinnedSessionId = m[1];
|
|
79
|
-
}
|
|
65
|
+
async function startDaemon(session, socketPath) {
|
|
66
|
+
process.stderr.write(`[debug-daemon] started: pid=${process.pid} sessionId=${session.sessionId}\n`);
|
|
80
67
|
|
|
81
68
|
// Remove stale socket file from a previous crash
|
|
82
69
|
try { fs.unlinkSync(socketPath); } catch (e) { /* ignore ENOENT */ }
|
|
83
70
|
|
|
84
71
|
let idleTimer = null;
|
|
85
72
|
|
|
86
|
-
function resetIdle() {
|
|
87
|
-
if (idleTimer) clearTimeout(idleTimer);
|
|
88
|
-
idleTimer = setTimeout(cleanupAndExit, DAEMON_IDLE_TIMEOUT_MS);
|
|
89
|
-
// unref so idle timer alone doesn't keep the process alive — if the
|
|
90
|
-
// server stops listening for another reason the process can exit naturally
|
|
91
|
-
if (idleTimer.unref) idleTimer.unref();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
73
|
function cleanupAndExit(code) {
|
|
95
74
|
try { fs.unlinkSync(socketPath); } catch (e) { /* ignore */ }
|
|
96
75
|
process.exit(code || 0);
|
|
97
76
|
}
|
|
98
77
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
78
|
+
async function terminateAndExit(code) {
|
|
79
|
+
try { await session.terminate(); } catch (e) { /* best effort */ }
|
|
80
|
+
cleanupAndExit(code);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resetIdle() {
|
|
84
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
85
|
+
// On idle timeout, release the ABAP work process before exiting so it
|
|
86
|
+
// doesn't stay frozen until SAP's own session-timeout fires.
|
|
87
|
+
idleTimer = setTimeout(() => terminateAndExit(0), DAEMON_IDLE_TIMEOUT_MS);
|
|
88
|
+
// unref so idle timer alone doesn't keep the process alive
|
|
89
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// On SIGTERM (e.g. pkill from ensure_breakpoint cleanup), release the
|
|
93
|
+
// frozen ABAP work process before exiting.
|
|
94
|
+
process.once('SIGTERM', () => terminateAndExit(0));
|
|
109
95
|
|
|
110
96
|
const server = net.createServer((socket) => {
|
|
111
97
|
resetIdle();
|
|
@@ -150,6 +136,7 @@ async function _handleLine(socket, line, session, cleanupAndExit, resetIdle) {
|
|
|
150
136
|
}
|
|
151
137
|
|
|
152
138
|
resetIdle();
|
|
139
|
+
process.stderr.write(`[debug-daemon] cmd=${req.cmd}\n`);
|
|
153
140
|
|
|
154
141
|
try {
|
|
155
142
|
switch (req.cmd) {
|
|
@@ -172,6 +159,11 @@ async function _handleLine(socket, line, session, cleanupAndExit, resetIdle) {
|
|
|
172
159
|
_send(socket, { ok: true, variables: children });
|
|
173
160
|
break;
|
|
174
161
|
}
|
|
162
|
+
case 'expandPath': {
|
|
163
|
+
const result = await session.expandPath(req.pathParts);
|
|
164
|
+
_send(socket, { ok: true, variable: result.variable, children: result.children });
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
175
167
|
case 'stack': {
|
|
176
168
|
const frames = await session.getStack();
|
|
177
169
|
_send(socket, { ok: true, frames });
|
|
@@ -206,7 +198,7 @@ function _send(socket, obj) {
|
|
|
206
198
|
}
|
|
207
199
|
}
|
|
208
200
|
|
|
209
|
-
// ─── Entry point when run as standalone daemon process
|
|
201
|
+
// ─── Entry point when run as standalone daemon process (legacy / testing) ────
|
|
210
202
|
|
|
211
203
|
if (require.main === module || process.env.DEBUG_DAEMON_MODE === '1') {
|
|
212
204
|
const config = JSON.parse(process.env.DEBUG_DAEMON_CONFIG || '{}');
|
|
@@ -221,7 +213,24 @@ if (require.main === module || process.env.DEBUG_DAEMON_MODE === '1') {
|
|
|
221
213
|
process.exit(1);
|
|
222
214
|
}
|
|
223
215
|
|
|
224
|
-
|
|
216
|
+
// Build session from env vars (used only in standalone mode)
|
|
217
|
+
const adt = new AdtHttp(config);
|
|
218
|
+
if (snapshot && snapshot.csrfToken) adt.csrfToken = snapshot.csrfToken;
|
|
219
|
+
if (snapshot && snapshot.cookies) adt.cookies = snapshot.cookies;
|
|
220
|
+
const session = new DebugSession(adt, sessionId);
|
|
221
|
+
if (snapshot && Array.isArray(snapshot.pinnedSessionId) && snapshot.pinnedSessionId.length > 0) {
|
|
222
|
+
session.pinnedSessionId = snapshot.pinnedSessionId;
|
|
223
|
+
} else if (snapshot && snapshot.cookies) {
|
|
224
|
+
session.pinnedSessionId = [];
|
|
225
|
+
for (const pair of snapshot.cookies.split(';')) {
|
|
226
|
+
const name = pair.trim().split('=')[0].trim();
|
|
227
|
+
if (/^SAP_SESSIONID/i.test(name) || name === 'sap-contextid') {
|
|
228
|
+
session.pinnedSessionId.push(pair.trim());
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
startDaemon(session, socketPath).catch((err) => {
|
|
225
234
|
process.stderr.write(`[debug-daemon] startup error: ${err.message}\n`);
|
|
226
235
|
process.exit(1);
|
|
227
236
|
});
|