@yeaft/webchat-agent 1.0.313 → 1.0.315
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +87 -88
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/cli-session-runner.js +5 -0
- package/yeaft/cli.js +74 -17
- package/yeaft/engine.js +78 -20
- package/yeaft/linux-process-namespace.js +93 -0
- package/yeaft/systemd-scope.js +89 -3
- package/yeaft/systemd-service-runner.js +43 -0
- package/yeaft/tools/bash.js +62 -104
- package/yeaft/tools/process-runner.js +164 -30
- package/yeaft/tools/registry.js +9 -8
- package/yeaft/web-bridge.js +24 -22
- package/yeaft/work-center/runner.js +11 -0
|
Binary file
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
412
|
+
const handleLine = async (line, lineSequence) => {
|
|
413
|
+
if (lineSequence > exitLineSequence) return;
|
|
398
414
|
const input = line.trim();
|
|
399
415
|
if (!input) {
|
|
400
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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 (
|
|
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
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
+
});
|
package/yeaft/systemd-scope.js
CHANGED
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
* without polluting the agent service lifecycle.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
+
});
|