abapgit-agent 1.17.9 → 1.18.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.
@@ -1,26 +1,97 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * ADT HTTP client for SAP ABAP Development Tools REST API
5
- * Handles XML/AtomPub content, CSRF token, cookie session caching.
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
- return new Promise((resolve, reject) => {
79
- const url = new URL('/sap/bc/adt/discovery', `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
80
- const options = {
81
- hostname: url.hostname,
82
- port: url.port,
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.clearSession();
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
- return new Promise((resolve, reject) => {
144
- const url = new URL(urlPath, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
145
-
146
- const headers = {
147
- 'Content-Type': options.contentType || 'application/atom+xml',
148
- 'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
149
- 'sap-client': this.config.client,
150
- 'sap-language': this.config.language || 'EN',
151
- ...options.headers
152
- };
153
-
154
- headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
155
-
156
- if (['POST', 'PUT', 'DELETE'].includes(method) && this.csrfToken) {
157
- headers['X-CSRF-Token'] = this.csrfToken;
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
- // Update cookies from any response that sets them.
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
- let respBody = '';
241
- res.on('data', chunk => respBody += chunk);
242
- res.on('end', () => {
243
- resolve({ body: respBody, headers: res.headers, statusCode: res.statusCode });
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
- req.on('error', reject);
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: resolves when the request bytes have been flushed
263
- * to the TCP send buffer does NOT wait for a response.
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
- return new Promise((resolve, reject) => {
280
- const url = new URL(urlPath, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
281
-
282
- const headers = {
283
- 'Content-Type': options.contentType || 'application/atom+xml',
284
- 'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
285
- 'sap-client': this.config.client,
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
- agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
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
- // Resolve as soon as the request is fully written and flushed.
318
- req.on('error', resolve); // ignore errors — fire-and-forget
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", "type": "stepOver|stepInto|stepReturn|stepContinue" }
29
- * { "cmd": "vars", "name": null }
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
- * @param {object} config - ABAP connection config
55
- * @param {string} sessionId - debugSessionId returned by attach
56
- * @param {string} socketPath - Unix socket path to listen on
57
- * @param {object|null} snapshot - { csrfToken, cookies } captured from attach process
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(config, sessionId, socketPath, snapshot) {
60
- const adt = new AdtHttp(config);
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
- // On SIGTERM (e.g. pkill from ensure_breakpoint cleanup), attempt to release
100
- // the frozen ABAP work process before exiting. Without this, killing the
101
- // daemon leaves the work process paused at the breakpoint until SAP's own
102
- // session-timeout fires (up to several minutes).
103
- process.once('SIGTERM', async () => {
104
- try {
105
- await session.terminate();
106
- } catch (e) { /* ignore best effort */ }
107
- cleanupAndExit(0);
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
- startDaemon(config, sessionId, socketPath, snapshot).catch((err) => {
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
  });