@yeaft/webchat-agent 1.0.314 → 1.0.316

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.314",
3
+ "version": "1.0.316",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -128,6 +128,11 @@ export function createCliSessionRunner({
128
128
  try {
129
129
  for await (const event of engine.query(queryOptions)) {
130
130
  if (event.type === 'text_delta') resultText += event.text || '';
131
+ else if (event.type === 'error' && !failed) {
132
+ failed = event.error instanceof Error
133
+ ? event.error
134
+ : new Error(String(event.error?.message || event.error || 'Unknown Engine error'));
135
+ }
131
136
  await options.onEvent?.({ vpId, event, sessionId, turnId: queryOptions.vpTurnId });
132
137
  }
133
138
  } catch (error) {
package/yeaft/cli.js CHANGED
@@ -392,12 +392,28 @@ async function runREPL(config, args) {
392
392
  prompt: `yeaft> `,
393
393
  });
394
394
 
395
+ let closeRequested = false;
396
+ let lineQueue = Promise.resolve();
397
+ let shutdownPromise = null;
398
+ let acceptedLineSequence = 0;
399
+ let exitLineSequence = Number.POSITIVE_INFINITY;
400
+ const isExitLine = line => {
401
+ const input = line.trim();
402
+ if (!input.startsWith('/')) return false;
403
+ const [command] = input.slice(1).split(/\s+/);
404
+ return command === 'quit' || command === 'exit' || command === 'q';
405
+ };
406
+ const promptIfOpen = () => {
407
+ if (!closeRequested) rl.prompt();
408
+ };
409
+
395
410
  rl.prompt();
396
411
 
397
- rl.on('line', async (line) => {
412
+ const handleLine = async (line, lineSequence) => {
413
+ if (lineSequence > exitLineSequence) return;
398
414
  const input = line.trim();
399
415
  if (!input) {
400
- rl.prompt();
416
+ promptIfOpen();
401
417
  return;
402
418
  }
403
419
 
@@ -661,13 +677,14 @@ async function runREPL(config, args) {
661
677
  case 'quit':
662
678
  case 'exit':
663
679
  case 'q':
664
- rl.close(); // close handler does shutdown + process.exit()
665
- return; // don't call rl.prompt() below
680
+ closeRequested = true;
681
+ rl.close();
682
+ return;
666
683
 
667
684
  default:
668
685
  console.log(`Unknown command: /${cmd}. Type /help for commands.`);
669
686
  }
670
- rl.prompt();
687
+ promptIfOpen();
671
688
  return;
672
689
  }
673
690
 
@@ -690,7 +707,7 @@ async function runREPL(config, args) {
690
707
  });
691
708
  console.log();
692
709
  if (outcome.results.some(result => result.error)) process.exitCode = 1;
693
- rl.prompt();
710
+ promptIfOpen();
694
711
  return;
695
712
  }
696
713
 
@@ -733,6 +750,7 @@ async function runREPL(config, args) {
733
750
  break;
734
751
  case 'error':
735
752
  process.stderr.write(`\nError: ${event.error.message}\n`);
753
+ process.exitCode = 1;
736
754
  break;
737
755
  case 'turn_start':
738
756
  if (session.config.debug && event.turnNumber > 1) {
@@ -751,16 +769,44 @@ async function runREPL(config, args) {
751
769
  }
752
770
  } catch (err) {
753
771
  console.error(`Error: ${err.message}`);
772
+ process.exitCode = 1;
773
+ }
774
+ promptIfOpen();
775
+ };
776
+
777
+ rl.on('line', line => {
778
+ if (closeRequested) return;
779
+ const lineSequence = ++acceptedLineSequence;
780
+ const exitsRepl = isExitLine(line);
781
+ if (exitsRepl) {
782
+ exitLineSequence = lineSequence;
783
+ closeRequested = true;
754
784
  }
755
- rl.prompt();
785
+ lineQueue = lineQueue.then(() => handleLine(line, lineSequence)).catch(error => {
786
+ console.error(`Error: ${error.message}`);
787
+ process.exitCode = 1;
788
+ });
789
+ if (exitsRepl) rl.close();
756
790
  });
757
791
 
758
- rl.on('close', async () => {
759
- await sessionRunner?.close();
760
- await session.shutdown();
761
- console.log('\nBye!');
762
- process.exit(0);
792
+ rl.on('close', () => {
793
+ closeRequested = true;
794
+ if (!shutdownPromise) {
795
+ shutdownPromise = lineQueue.catch(error => {
796
+ console.error(`Error: ${error.message}`);
797
+ process.exitCode = 1;
798
+ }).then(async () => {
799
+ await sessionRunner?.close();
800
+ await session.shutdown();
801
+ console.log('\nBye!');
802
+ }).catch(error => {
803
+ console.error(`Error: ${error.message}`);
804
+ process.exitCode = 1;
805
+ });
806
+ }
763
807
  });
808
+ await new Promise(resolve => rl.once('close', resolve));
809
+ await shutdownPromise;
764
810
  }
765
811
 
766
812
  // ─── Structured stdio handler ──────────────────────────────────
@@ -843,20 +889,24 @@ async function runStreamJson(config, args) {
843
889
  : runStreamTurn({ engine, ...turnOptions });
844
890
  };
845
891
 
846
- let lastResult = null;
892
+ let hadError = false;
893
+ const recordResult = (result) => {
894
+ hadError ||= result?.is_error === true;
895
+ return result;
896
+ };
847
897
  try {
848
898
  if (args.prompt) {
849
- lastResult = await runPrompt(args.prompt);
899
+ recordResult(await runPrompt(args.prompt));
850
900
  } else if (input) {
851
901
  for (;;) {
852
902
  const item = await input.nextPrompt();
853
903
  if (!item) break;
854
- lastResult = await runPrompt(item.prompt);
904
+ recordResult(await runPrompt(item.prompt));
855
905
  }
856
906
  } else {
857
907
  let prompt = '';
858
908
  for await (const chunk of process.stdin) prompt += chunk;
859
- if (prompt.trim()) lastResult = await runPrompt(prompt.trim());
909
+ if (prompt.trim()) recordResult(await runPrompt(prompt.trim()));
860
910
  }
861
911
  } finally {
862
912
  input?.close();
@@ -866,7 +916,7 @@ async function runStreamJson(config, args) {
866
916
  console.info = originalConsole.info;
867
917
  console.debug = originalConsole.debug;
868
918
  }
869
- if (lastResult?.is_error) process.exitCode = 1;
919
+ if (hadError) process.exitCode = 1;
870
920
  }
871
921
 
872
922
  // ─── One-shot handler ──────────────────────────────────────────
@@ -922,6 +972,7 @@ async function runOnce(config, args) {
922
972
  ...(m.toolCalls && { toolCalls: m.toolCalls }),
923
973
  }));
924
974
 
975
+ let terminalEngineError = null;
925
976
  for await (const event of engine.query({
926
977
  prompt: args.prompt,
927
978
  messages: priorMessages,
@@ -958,6 +1009,11 @@ async function runOnce(config, args) {
958
1009
  break;
959
1010
  case 'error':
960
1011
  process.stderr.write(`\nError: ${event.error.message}\n`);
1012
+ if (!terminalEngineError) {
1013
+ terminalEngineError = event.error instanceof Error
1014
+ ? event.error
1015
+ : new Error(String(event.error?.message || event.error || 'Engine query failed'));
1016
+ }
961
1017
  break;
962
1018
  case 'turn_start':
963
1019
  if (args.verbose && event.turnNumber > 1) {
@@ -968,6 +1024,7 @@ async function runOnce(config, args) {
968
1024
  }
969
1025
  // Final newline after streaming text
970
1026
  console.log();
1027
+ if (terminalEngineError) process.exitCode = 1;
971
1028
  } finally {
972
1029
  await sessionRunner?.close();
973
1030
  await session.shutdown();
package/yeaft/engine.js CHANGED
@@ -1630,18 +1630,33 @@ export class Engine {
1630
1630
  ? `【先前累计摘要】\n${priorSummary}\n\n【新待压缩对话】\n`
1631
1631
  : `[Previous cumulative summary]\n${priorSummary}\n\n[New conversation to absorb]\n`)
1632
1632
  : '';
1633
- const result = await adapter.call({
1634
- model: fastConfig.model,
1635
- system: summariserSystem,
1636
- messages: [{ role: 'user', content: `${summariserPromptPrefix}${priorBlock}${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
1637
- // 10k output budget: the running summary is the engine's
1638
- // long-term memory of cold turns, so it deserves room to
1639
- // actually preserve detail. We rewrite-in-place each round,
1640
- // so size stays bounded by maxTokens regardless of how many
1641
- // compact passes have run.
1642
- maxTokens: 10240,
1633
+ const maintenanceCtrl = new AbortController();
1634
+ let timeout = null;
1635
+ const timedOut = new Promise((_, reject) => {
1636
+ timeout = setTimeout(() => {
1637
+ maintenanceCtrl.abort('compact_summary_timeout');
1638
+ reject(new LLMAbortError());
1639
+ }, AMS_ADJUST_TIMEOUT_MS);
1640
+ if (timeout && typeof timeout.unref === 'function') timeout.unref();
1643
1641
  });
1644
- return (result.text || '').trim();
1642
+ try {
1643
+ const request = adapter.call({
1644
+ model: fastConfig.model,
1645
+ system: summariserSystem,
1646
+ messages: [{ role: 'user', content: `${summariserPromptPrefix}${priorBlock}${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
1647
+ // 10k output budget: the running summary is the engine's
1648
+ // long-term memory of cold turns, so it deserves room to
1649
+ // actually preserve detail. We rewrite-in-place each round,
1650
+ // so size stays bounded by maxTokens regardless of how many
1651
+ // compact passes have run.
1652
+ maxTokens: 10240,
1653
+ signal: maintenanceCtrl.signal,
1654
+ });
1655
+ const result = await Promise.race([request, timedOut]);
1656
+ return (result.text || '').trim();
1657
+ } finally {
1658
+ if (timeout) clearTimeout(timeout);
1659
+ }
1645
1660
  } catch {
1646
1661
  return '';
1647
1662
  }
@@ -1865,13 +1880,55 @@ export class Engine {
1865
1880
  * string-prompt shape (no regression for existing callers).
1866
1881
  * @yields {EngineEvent}
1867
1882
  */
1868
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1883
+ async *query(params = {}) {
1884
+ let terminalEmitted = false;
1885
+ let lastTurnNumber = 0;
1886
+ try {
1887
+ for await (const event of this.#queryLifecycle(params)) {
1888
+ if (Number.isFinite(event?.turnNumber)) lastTurnNumber = event.turnNumber;
1889
+ if (event?.type === 'turn_end' && event.terminal === true) terminalEmitted = true;
1890
+ yield event;
1891
+ }
1892
+ } catch (err) {
1893
+ // The internal state machine handles expected provider failures, tool
1894
+ // results, retries and aborts. This outermost boundary catches everything
1895
+ // else, including pre-flow and cleanup faults, so an accepted query never
1896
+ // disappears without a diagnostic terminal event.
1897
+ if (!terminalEmitted) {
1898
+ const error = err instanceof Error ? err : new Error(String(err));
1899
+ yield { type: 'error', error, retryable: false };
1900
+ yield {
1901
+ type: 'turn_end',
1902
+ turnNumber: lastTurnNumber,
1903
+ stopReason: 'error',
1904
+ terminal: true,
1905
+ detail: { message: error.message, errorName: error.name },
1906
+ threadId: params.threadId || MAIN_THREAD_ID,
1907
+ };
1908
+ } else {
1909
+ // Normal end_turn is already durable and visible. A failed maintenance
1910
+ // hook must not retroactively turn the completed answer into an error.
1911
+ console.warn('[Engine] post-turn maintenance failed:', err?.message || err);
1912
+ }
1913
+ }
1914
+ }
1915
+
1916
+ async *#queryLifecycle({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1869
1917
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1918
+ const error = new Error('prompt is required and must be a non-empty string');
1870
1919
  yield {
1871
1920
  type: 'error',
1872
- error: new Error('prompt is required and must be a non-empty string'),
1921
+ error,
1873
1922
  retryable: false,
1874
1923
  };
1924
+ yield {
1925
+ type: 'turn_end',
1926
+ turnNumber: 0,
1927
+ stopReason: 'error',
1928
+ terminal: true,
1929
+ detail: { message: error.message, errorName: error.name },
1930
+ threadId,
1931
+ };
1875
1932
  return;
1876
1933
  }
1877
1934
  // promptParts (optional): a content-array form of the user message
@@ -2447,7 +2504,7 @@ export class Engine {
2447
2504
  }
2448
2505
  retryLifecycle.pendingContinuation = null;
2449
2506
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2450
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2507
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId, terminal: true };
2451
2508
  break;
2452
2509
  }
2453
2510
 
@@ -2932,7 +2989,7 @@ export class Engine {
2932
2989
  });
2933
2990
  endAttemptTrace('aborted');
2934
2991
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2935
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2992
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId, terminal: true };
2936
2993
  break;
2937
2994
  }
2938
2995
 
@@ -2987,7 +3044,7 @@ export class Engine {
2987
3044
  const slept = await sleepWithAbort(delayMs, signal);
2988
3045
  if (!slept || signal?.aborted) {
2989
3046
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2990
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
3047
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId, terminal: true };
2991
3048
  break;
2992
3049
  }
2993
3050
  yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
@@ -3042,7 +3099,7 @@ export class Engine {
3042
3099
  }
3043
3100
  retryLifecycle.pendingContinuation = null;
3044
3101
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
3045
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
3102
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId, terminal: true };
3046
3103
  break;
3047
3104
  }
3048
3105
  yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
@@ -3154,7 +3211,7 @@ export class Engine {
3154
3211
  errorEvent.maxRetries = retryPolicy.maxRetries;
3155
3212
  }
3156
3213
  yield errorEvent;
3157
- yield { type: 'turn_end', turnNumber, stopReason: 'error', threadId };
3214
+ yield { type: 'turn_end', turnNumber, stopReason: 'error', threadId, terminal: true };
3158
3215
  break;
3159
3216
  }
3160
3217
 
@@ -3399,7 +3456,7 @@ export class Engine {
3399
3456
  };
3400
3457
  if (signal?.aborted) {
3401
3458
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
3402
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
3459
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId, terminal: true };
3403
3460
  break;
3404
3461
  }
3405
3462
  if (!signal?.aborted) {
@@ -3863,6 +3920,7 @@ export class Engine {
3863
3920
  stopReason: 'tool_handoff',
3864
3921
  detail: handoffDetail,
3865
3922
  threadId,
3923
+ terminal: true,
3866
3924
  };
3867
3925
  break;
3868
3926
  }
@@ -4000,7 +4058,7 @@ export class Engine {
4000
4058
  // 'aborted' instead of looping back to a new adapter call.
4001
4059
  if (abortedDuringTools || signal?.aborted) {
4002
4060
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
4003
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
4061
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId, terminal: true };
4004
4062
  break;
4005
4063
  }
4006
4064
 
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
+
6
+ const uid = process.getuid?.();
7
+ const gid = process.getgid?.();
8
+ const separator = process.argv.indexOf('--');
9
+ const command = separator >= 0 ? process.argv[separator + 1] : null;
10
+ const args = separator >= 0 ? process.argv.slice(separator + 2) : [];
11
+ const unsharePath = process.env.YEAFT_UNSHARE_PATH || '/usr/bin/unshare';
12
+
13
+ if (!Number.isSafeInteger(uid) || !Number.isSafeInteger(gid) || !command) {
14
+ console.error('linux-process-namespace requires a POSIX uid/gid and a command after --');
15
+ process.exit(126);
16
+ }
17
+
18
+ // The child creates user + PID namespaces, then blocks before exec. The parent
19
+ // writes same-uid mappings through /proc while it still owns the new user
20
+ // namespace. A small init process remains PID 1 so the user shell keeps normal
21
+ // signal semantics while namespace teardown remains an unescapable kill boundary.
22
+ const initScript = [
23
+ 'printf ready >&3',
24
+ 'IFS= read -r _ <&4',
25
+ '"$@" & child=$!',
26
+ 'term() { kill -TERM "$child" 2>/dev/null; }',
27
+ 'trap term TERM INT HUP',
28
+ 'while kill -0 "$child" 2>/dev/null; do wait "$child"; rc=$?; done',
29
+ 'exit "${rc:-1}"',
30
+ ].join('; ');
31
+ const child = spawn(unsharePath, [
32
+ '--user',
33
+ '--pid',
34
+ '--fork',
35
+ '--kill-child=SIGKILL',
36
+ '--',
37
+ 'sh',
38
+ '-c',
39
+ initScript,
40
+ 'yeaft-namespace-init',
41
+ command,
42
+ ...args,
43
+ ], {
44
+ stdio: ['ignore', 'inherit', 'inherit', 'pipe', 'pipe'],
45
+ env: process.env,
46
+ cwd: process.cwd(),
47
+ });
48
+
49
+ let started = false;
50
+ const fail = (error) => {
51
+ if (started) return;
52
+ started = true;
53
+ console.error(`Unable to create an isolated process namespace: ${error.message}`);
54
+ try { child.kill('SIGKILL'); } catch {}
55
+ process.exitCode = 126;
56
+ };
57
+
58
+ child.stdio[3].once('data', () => {
59
+ if (started) return;
60
+ try {
61
+ const pid = child.pid;
62
+ try { writeFileSync(`/proc/${pid}/setgroups`, 'deny\n'); } catch {}
63
+ writeFileSync(`/proc/${pid}/uid_map`, `${uid} ${uid} 1\n`);
64
+ writeFileSync(`/proc/${pid}/gid_map`, `${gid} ${gid} 1\n`);
65
+ const uidMap = readFileSync(`/proc/${pid}/uid_map`, 'utf8');
66
+ const gidMap = readFileSync(`/proc/${pid}/gid_map`, 'utf8');
67
+ if (!uidMap.includes(`${uid}`) || !gidMap.includes(`${gid}`)) {
68
+ throw new Error('uid/gid namespace mapping was not applied');
69
+ }
70
+ started = true;
71
+ child.stdio[4].end('go\n');
72
+ } catch (error) {
73
+ fail(error);
74
+ }
75
+ });
76
+ child.once('error', fail);
77
+
78
+ const forward = (signal) => {
79
+ try { child.kill(signal); } catch {}
80
+ };
81
+ process.on('SIGTERM', () => {
82
+ forward('SIGTERM');
83
+ setTimeout(() => forward('SIGKILL'), 200);
84
+ });
85
+ process.on('SIGINT', () => forward('SIGINT'));
86
+ process.on('SIGHUP', () => forward('SIGHUP'));
87
+ child.once('close', (code, signal) => {
88
+ if (signal) {
89
+ process.kill(process.pid, signal);
90
+ return;
91
+ }
92
+ process.exitCode = code ?? 1;
93
+ });
@@ -8,11 +8,18 @@
8
8
  * without polluting the agent service lifecycle.
9
9
  */
10
10
 
11
- import { existsSync } from 'fs';
12
- import { delimiter, isAbsolute, join } from 'path';
11
+ import { spawnSync } from 'child_process';
12
+ import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
13
+ import { tmpdir } from 'os';
14
+ import { delimiter, dirname, isAbsolute, join, resolve } from 'path';
15
+ import { fileURLToPath } from 'url';
13
16
 
14
17
  const DEFAULT_SCOPE_PREFIX = 'yeaft-shell';
15
18
  const UNIT_MAX_LENGTH = 180;
19
+ const SYSTEMD_SERVICE_RUNNER = resolve(
20
+ dirname(fileURLToPath(import.meta.url)),
21
+ 'systemd-service-runner.js',
22
+ );
16
23
 
17
24
  function hasPathSeparator(command) {
18
25
  return command.includes('/') || command.includes('\\');
@@ -43,6 +50,25 @@ export function shouldUseSystemdUserScope({ runtimePlatform, env = process.env,
43
50
  return !!resolvedSystemdRun;
44
51
  }
45
52
 
53
+ export function canUseSystemdUserManager({ runtimePlatform, env = process.env, systemdRunPath = null } = {}) {
54
+ if (!runtimePlatform?.isLinux || !env.XDG_RUNTIME_DIR) return false;
55
+ const systemdRun = systemdRunPath || findExecutableOnPath('systemd-run', env);
56
+ const systemctlPath = findExecutableOnPath('systemctl', env);
57
+ if (!systemdRun || !systemctlPath) return false;
58
+ try {
59
+ const result = spawnSync(systemctlPath, ['--user', 'show-environment'], {
60
+ env,
61
+ encoding: 'utf8',
62
+ stdio: 'ignore',
63
+ timeout: 3000,
64
+ windowsHide: true,
65
+ });
66
+ return !result.error && result.status === 0;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
46
72
  export function sanitizeSystemdUnitPart(value) {
47
73
  const raw = String(value || '').trim() || `${Date.now()}-${process.pid}`;
48
74
  return raw
@@ -66,10 +92,11 @@ export function wrapInvocationInSystemdUserScope(invocation, {
66
92
  systemdRunPath = null,
67
93
  } = {}) {
68
94
  if (!shouldUseSystemdUserScope({ runtimePlatform, env, systemdRunPath })) {
69
- return { ...invocation, systemdScope: null };
95
+ return { ...invocation, systemdScope: null, systemdControl: null };
70
96
  }
71
97
 
72
98
  const scopeName = buildSystemdScopeName(scopeId, scopePrefix);
99
+ const systemctlPath = findExecutableOnPath('systemctl', env);
73
100
  return {
74
101
  command: systemdRunPath || findExecutableOnPath('systemd-run', env) || 'systemd-run',
75
102
  args: [
@@ -83,6 +110,65 @@ export function wrapInvocationInSystemdUserScope(invocation, {
83
110
  ],
84
111
  family: invocation.family,
85
112
  systemdScope: scopeName,
113
+ systemdControl: systemctlPath
114
+ ? { unit: scopeName, systemctlPath, env }
115
+ : null,
116
+ wrappedCommand: invocation.command,
117
+ };
118
+ }
119
+
120
+ export function wrapInvocationInSystemdUserService(invocation, {
121
+ runtimePlatform,
122
+ env = process.env,
123
+ cwd = process.cwd(),
124
+ unitId = null,
125
+ unitPrefix = DEFAULT_SCOPE_PREFIX,
126
+ systemdRunPath = null,
127
+ } = {}) {
128
+ if (!canUseSystemdUserManager({ runtimePlatform, env, systemdRunPath })) {
129
+ return { ...invocation, systemdScope: null, systemdControl: null };
130
+ }
131
+ const systemdRun = systemdRunPath || findExecutableOnPath('systemd-run', env);
132
+ const systemctlPath = findExecutableOnPath('systemctl', env);
133
+ if (!systemctlPath) return { ...invocation, systemdScope: null, systemdControl: null };
134
+ const safePrefix = sanitizeSystemdUnitPart(unitPrefix).slice(0, 48);
135
+ const safeId = sanitizeSystemdUnitPart(unitId);
136
+ const unit = `${safePrefix}-${safeId}`.slice(0, UNIT_MAX_LENGTH) + '.service';
137
+ const payloadDir = mkdtempSync(join(tmpdir(), 'yeaft-systemd-invocation-'));
138
+ const payloadPath = join(payloadDir, 'invocation.json');
139
+ const cleanup = () => rmSync(payloadDir, { recursive: true, force: true });
140
+ try {
141
+ chmodSync(payloadDir, 0o700);
142
+ writeFileSync(payloadPath, JSON.stringify({
143
+ command: invocation.command,
144
+ args: invocation.args || [],
145
+ cwd,
146
+ env,
147
+ }), { mode: 0o600 });
148
+ } catch (error) {
149
+ cleanup();
150
+ throw error;
151
+ }
152
+ return {
153
+ command: systemdRun,
154
+ args: [
155
+ '--user',
156
+ '--wait',
157
+ '--pipe',
158
+ '--quiet',
159
+ '--collect',
160
+ '--service-type=exec',
161
+ '--property=KillMode=control-group',
162
+ `--unit=${unit}`,
163
+ '--',
164
+ process.execPath,
165
+ SYSTEMD_SERVICE_RUNNER,
166
+ payloadPath,
167
+ ],
168
+ family: invocation.family,
169
+ systemdScope: unit,
170
+ systemdControl: { unit, systemctlPath, env },
171
+ cleanup,
86
172
  wrappedCommand: invocation.command,
87
173
  };
88
174
  }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { readFileSync, rmSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+
7
+ const payloadPath = process.argv[2];
8
+ let payload;
9
+ try {
10
+ if (!payloadPath) throw new Error('missing invocation payload path');
11
+ payload = JSON.parse(readFileSync(payloadPath, 'utf8'));
12
+ if (!payload || typeof payload.command !== 'string' || !Array.isArray(payload.args)) {
13
+ throw new Error('invalid invocation payload');
14
+ }
15
+ } catch (error) {
16
+ console.error(`Unable to read systemd service command: ${error.message}`);
17
+ process.exit(126);
18
+ } finally {
19
+ if (payloadPath) rmSync(dirname(payloadPath), { recursive: true, force: true });
20
+ }
21
+
22
+ const child = spawn(payload.command, payload.args, {
23
+ cwd: payload.cwd || process.cwd(),
24
+ env: payload.env || process.env,
25
+ stdio: 'inherit',
26
+ });
27
+ const forward = signal => {
28
+ try { child.kill(signal); } catch {}
29
+ };
30
+ process.on('SIGTERM', () => forward('SIGTERM'));
31
+ process.on('SIGINT', () => forward('SIGINT'));
32
+ process.on('SIGHUP', () => forward('SIGHUP'));
33
+ child.once('error', error => {
34
+ console.error(error.message);
35
+ process.exitCode = 126;
36
+ });
37
+ child.once('close', (code, signal) => {
38
+ if (signal) {
39
+ process.kill(process.pid, signal);
40
+ return;
41
+ }
42
+ process.exitCode = code ?? 1;
43
+ });