@mmmbuto/nexuscrew 0.8.27 → 0.8.28
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/CHANGELOG.md +23 -0
- package/README.md +23 -8
- package/frontend/dist/assets/{index-xxfypte7.css → index-BAzlX2nP.css} +1 -1
- package/frontend/dist/assets/index-DiKMLCvW.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +19 -2
- package/lib/cli/doctor.js +98 -11
- package/lib/cli/service.js +7 -4
- package/lib/diagnostics/store.js +1 -1
- package/lib/fleet/builtin.js +201 -16
- package/lib/fleet/causes.js +68 -0
- package/lib/fleet/definitions.js +86 -4
- package/lib/fleet/launch.js +19 -1
- package/lib/fleet/managed.js +62 -9
- package/lib/fleet/routes.js +16 -2
- package/lib/fleet/runtime.js +69 -33
- package/package.json +1 -1
- package/frontend/dist/assets/index-CWKyv6KS.js +0 -93
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Cause-preserving diagnostics for Fleet up() failures (T4).
|
|
4
|
+
//
|
|
5
|
+
// The up() path of the built-in fleet crosses five boundaries:
|
|
6
|
+
// 1. preflight gates (command trust, cwd, managed-engine config)
|
|
7
|
+
// 2. the secure launch broker (private runtime dir, payload, lifecycle)
|
|
8
|
+
// 3. tmux new-session (session creation / duplicate)
|
|
9
|
+
// 4. pane/client early-exit (readiness / liveness gate)
|
|
10
|
+
// 5. the cell client spawn (ENOENT/EACCES/... surfaced via cell-exec)
|
|
11
|
+
//
|
|
12
|
+
// When one of them failed, the cause used to be buried in a free-text error
|
|
13
|
+
// message. These bounded enums travel on structured HTTP errors instead, so
|
|
14
|
+
// FLEET_ACTION_FAILED can report a closed {status, code, phase} triple that
|
|
15
|
+
// NEVER embeds cwd/path, argv, env, prompt, token or credential data — only
|
|
16
|
+
// stable enum strings, which are safe to persist and to show.
|
|
17
|
+
//
|
|
18
|
+
// Add a value here ONLY when a new reachable boundary needs a stable cause.
|
|
19
|
+
// Anything not in the enum degrades to UNKNOWN (bounded) at the coercion gate,
|
|
20
|
+
// so an untagged/legacy/unexpected error can never leak an unbounded string.
|
|
21
|
+
|
|
22
|
+
const UNKNOWN = 'UNKNOWN';
|
|
23
|
+
|
|
24
|
+
// Lifecycle phase of up() where a boundary failed. Closed enum.
|
|
25
|
+
// preflight -> pre-launch validation gates
|
|
26
|
+
// launch-broker -> secure launch broker
|
|
27
|
+
// new-session -> tmux new-session creation
|
|
28
|
+
// readiness -> pane/client early-exit liveness gate
|
|
29
|
+
// spawn-client -> cell client spawn error surfaced via cell-exec
|
|
30
|
+
const PHASES = ['preflight', 'launch-broker', 'new-session', 'readiness', 'spawn-client'];
|
|
31
|
+
const PHASE_SET = new Set(PHASES);
|
|
32
|
+
|
|
33
|
+
// Stable failure code per boundary. Closed enum; free text never enters here.
|
|
34
|
+
const CODES = [
|
|
35
|
+
// preflight (pre-launch gates)
|
|
36
|
+
'COMMAND_UNTRUSTED',
|
|
37
|
+
'CWD_INVALID',
|
|
38
|
+
'ENGINE_UNCONFIGURED',
|
|
39
|
+
'SHELL_NOT_AVAILABLE',
|
|
40
|
+
// launch-broker
|
|
41
|
+
'LAUNCH_BROKER_UNSAFE',
|
|
42
|
+
'LAUNCH_BROKER_PAYLOAD',
|
|
43
|
+
'LAUNCH_BROKER_CLOSED',
|
|
44
|
+
'LAUNCH_BROKER_FAILED',
|
|
45
|
+
// new-session
|
|
46
|
+
'NEW_SESSION_FAILED',
|
|
47
|
+
'SESSION_DUPLICATE',
|
|
48
|
+
// readiness (client started but exited immediately)
|
|
49
|
+
'CLIENT_EARLY_EXIT',
|
|
50
|
+
// spawn-client (cell client spawn error: ENOENT/EACCES/...)
|
|
51
|
+
'SPAWN_CLIENT_FAILED',
|
|
52
|
+
// bounded fallback for any untagged / legacy / unexpected error
|
|
53
|
+
UNKNOWN,
|
|
54
|
+
];
|
|
55
|
+
const CODE_SET = new Set(CODES);
|
|
56
|
+
|
|
57
|
+
function coerce(value, allowed, fallback) {
|
|
58
|
+
return typeof value === 'string' && allowed.has(value) ? value : fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Coerce a raw code/phase to the bounded enum, degrading to UNKNOWN.
|
|
62
|
+
// Pure + without dependencies: testable directly.
|
|
63
|
+
function codeOf(value) { return coerce(value, CODE_SET, UNKNOWN); }
|
|
64
|
+
function phaseOf(value) { return coerce(value, PHASE_SET, UNKNOWN); }
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
UNKNOWN, PHASES, CODES, codeOf, phaseOf,
|
|
68
|
+
};
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -29,6 +29,7 @@ const MAX_MODEL_FLAG_LEN = 32;
|
|
|
29
29
|
const MAX_MODEL_VAL_LEN = 128;
|
|
30
30
|
const MAX_PROMPTFLAG_LEN = 32;
|
|
31
31
|
const MAX_PROMPT_LEN = 8192; // 8 KB
|
|
32
|
+
const MAX_CELL_COMMAND_LEN = 4096;
|
|
32
33
|
const MAX_TMUXSESSION_LEN = 64;
|
|
33
34
|
|
|
34
35
|
const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
|
|
@@ -97,12 +98,14 @@ function parseDefinitions(raw) {
|
|
|
97
98
|
if (d.cells.length > MAX_CELLS) return null;
|
|
98
99
|
|
|
99
100
|
const engineIds = new Set();
|
|
101
|
+
const engineMap = new Map();
|
|
100
102
|
const engines = [];
|
|
101
103
|
for (const e of d.engines) {
|
|
102
104
|
const eng = parseEngine(e);
|
|
103
105
|
if (!eng) return null;
|
|
104
106
|
if (engineIds.has(eng.id)) return null; // id engine univoco
|
|
105
107
|
engineIds.add(eng.id);
|
|
108
|
+
engineMap.set(eng.id, eng);
|
|
106
109
|
engines.push(eng);
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -110,7 +113,7 @@ function parseDefinitions(raw) {
|
|
|
110
113
|
const cellIds = new Set();
|
|
111
114
|
const cells = [];
|
|
112
115
|
for (const c of d.cells) {
|
|
113
|
-
const cell = parseCell(c, engineIds);
|
|
116
|
+
const cell = parseCell(c, engineIds, engineMap);
|
|
114
117
|
if (!cell) return null;
|
|
115
118
|
if (cellIds.has(cell.id)) return null; // id cell univoco
|
|
116
119
|
cellIds.add(cell.id);
|
|
@@ -215,7 +218,7 @@ function parseEngine(e) {
|
|
|
215
218
|
return out;
|
|
216
219
|
}
|
|
217
220
|
|
|
218
|
-
function parseCell(c, engineIds) {
|
|
221
|
+
function parseCell(c, engineIds, engineMap = new Map()) {
|
|
219
222
|
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
|
220
223
|
|
|
221
224
|
// id
|
|
@@ -224,6 +227,18 @@ function parseCell(c, engineIds) {
|
|
|
224
227
|
// cwd (obbligatorio; la risoluzione/confinamento avviene via resolveCwd a runtime)
|
|
225
228
|
if (typeof c.cwd !== 'string' || !c.cwd || c.cwd.length > MAX_CWD_LEN) return null;
|
|
226
229
|
|
|
230
|
+
// cwdRel (opzionale canonico, design §4.3): forma portatile home-relative.
|
|
231
|
+
// Qui si valida solo il FORMATO (stringa canonica): la coerenza cwd<->cwdRel
|
|
232
|
+
// e' un invariante di SCRITTURA (define/edit/restore), non di lettura — così
|
|
233
|
+
// le definizioni legacy (solo cwd) e quelle nuove (cwd+cwdRel) caricano senza
|
|
234
|
+
// riscrittura on-read e senza rendere il file illeggibile per disallineamenti.
|
|
235
|
+
let cwdRel;
|
|
236
|
+
if (c.cwdRel !== undefined) {
|
|
237
|
+
if (typeof c.cwdRel !== 'string') return null;
|
|
238
|
+
cwdRel = normalizeCwdRel(c.cwdRel);
|
|
239
|
+
if (cwdRel === null) return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
227
242
|
// engine = riferimento a engines[].id esistente (dangling -> null)
|
|
228
243
|
if (typeof c.engine !== 'string' || !engineIds.has(c.engine)) return null;
|
|
229
244
|
|
|
@@ -268,10 +283,27 @@ function parseCell(c, engineIds) {
|
|
|
268
283
|
for (const [engineId, value] of entries) {
|
|
269
284
|
if (!engineIds.has(engineId)) return null;
|
|
270
285
|
if (value !== 'standard' && value !== 'unsafe') return null;
|
|
286
|
+
if (engineMap.get(engineId)?.managed?.client === 'shell' && value !== 'standard') return null;
|
|
271
287
|
permissionPolicies[engineId] = value;
|
|
272
288
|
}
|
|
273
289
|
}
|
|
274
290
|
|
|
291
|
+
// commands: comando Shell PER-CELL PER-ENGINE. La stringa resta opaca e
|
|
292
|
+
// viene interpretata solo dalla shell target con `-lc`; qui si applicano
|
|
293
|
+
// limiti e forma chiusa. Sono ammesse soltanto chiavi di engine Shell.
|
|
294
|
+
let commands;
|
|
295
|
+
if (c.commands !== undefined) {
|
|
296
|
+
if (!c.commands || typeof c.commands !== 'object' || Array.isArray(c.commands)) return null;
|
|
297
|
+
const entries = Object.entries(c.commands);
|
|
298
|
+
if (entries.length > MAX_ENGINES) return null;
|
|
299
|
+
commands = {};
|
|
300
|
+
for (const [engineId, value] of entries) {
|
|
301
|
+
if (!engineIds.has(engineId) || engineMap.get(engineId)?.managed?.client !== 'shell') return null;
|
|
302
|
+
if (typeof value !== 'string' || value.length > MAX_CELL_COMMAND_LEN || /[\x00-\x1f\x7f]/.test(value)) return null;
|
|
303
|
+
commands[engineId] = value;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
275
307
|
// prompt (opzionale, cap)
|
|
276
308
|
let prompt;
|
|
277
309
|
if (c.prompt !== undefined) {
|
|
@@ -296,9 +328,11 @@ function parseCell(c, engineIds) {
|
|
|
296
328
|
}
|
|
297
329
|
|
|
298
330
|
const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
|
|
331
|
+
if (cwdRel !== undefined) out.cwdRel = cwdRel;
|
|
299
332
|
if (model !== undefined) out.model = model;
|
|
300
333
|
if (Object.keys(models).length) out.models = models;
|
|
301
334
|
if (permissionPolicies) out.permissionPolicies = permissionPolicies;
|
|
335
|
+
if (commands && Object.keys(commands).length) out.commands = commands;
|
|
302
336
|
if (prompt !== undefined) out.prompt = prompt;
|
|
303
337
|
return out;
|
|
304
338
|
}
|
|
@@ -345,6 +379,52 @@ function resolveCwd(cwd, home) {
|
|
|
345
379
|
} catch (_) { return null; }
|
|
346
380
|
}
|
|
347
381
|
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// cwdRel — cwd home-relative PORTATILE (design §4.3 / backup v3).
|
|
384
|
+
// Rappresentazione canonica di una cwd come percorso relativo alla home del
|
|
385
|
+
// device target: '' == la home stessa; 'personal' == <home>/personal.
|
|
386
|
+
// Helper PURI (nessun fs): la normalizzazione e' string-only e fail-closed.
|
|
387
|
+
// La risoluzione/confinamento finale resta demandata a resolveCwd (realpath su
|
|
388
|
+
// entrambi i lati), INVARIATO: cwdRel aggiunge un vincolo in scrittura, non lo
|
|
389
|
+
// indebolisce in lettura. Nessun '..'/assoluto/control/backslash/drive letter.
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Restituisce la forma canonica ('' = home) oppure null (input non portabile).
|
|
392
|
+
// Normalizza (collassa '.' e segmenti vuoti, scosta lo slash finale) RIFIUTANDO
|
|
393
|
+
// traversal, path assoluti, drive letter (Win), NUL/C0/DEL e backslash.
|
|
394
|
+
function normalizeCwdRel(rel, maxLen = MAX_CWD_LEN) {
|
|
395
|
+
if (typeof rel !== 'string') return null;
|
|
396
|
+
if (rel.length > maxLen) return null;
|
|
397
|
+
for (let i = 0; i < rel.length; i += 1) {
|
|
398
|
+
const c = rel.charCodeAt(i);
|
|
399
|
+
if (c <= 0x1f || c === 0x7f || c === 0x5c) return null; // C0, DEL, backslash
|
|
400
|
+
}
|
|
401
|
+
if (rel === '') return ''; // la home stessa
|
|
402
|
+
if (rel.charAt(0) === '/') return null; // path assoluto (leading sep)
|
|
403
|
+
if (/^[A-Za-z]:/.test(rel)) return null; // drive letter (Win-like)
|
|
404
|
+
const out = [];
|
|
405
|
+
for (const seg of rel.split('/')) {
|
|
406
|
+
if (seg === '' || seg === '.') continue; // collassa vuoti/dot
|
|
407
|
+
if (seg === '..') return null; // traversal
|
|
408
|
+
out.push(seg);
|
|
409
|
+
}
|
|
410
|
+
return out.join('/');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Deriva il cwdRel canonico da una cwd ASSOLUTA rispetto a una home (entrambe
|
|
414
|
+
// gia' realpath: il caller passa realpath). Restituisce '' (== home), un rel
|
|
415
|
+
// normalizzato, oppure null se la cwd non e' esprimibile sotto la home.
|
|
416
|
+
// Pura: nessun fs. Usa path.relative sulle stringhe (sicuro perche' entrambi
|
|
417
|
+
// realpath e cwd confinato sotto home).
|
|
418
|
+
function deriveCwdRel(absCwd, home) {
|
|
419
|
+
if (typeof absCwd !== 'string' || !absCwd || typeof home !== 'string' || !home) return null;
|
|
420
|
+
if (absCwd.includes('\0') || home.includes('\0')) return null;
|
|
421
|
+
const rel = path.relative(home, absCwd);
|
|
422
|
+
if (rel === '') return ''; // cwd == home
|
|
423
|
+
if (path.isAbsolute(rel)) return null; // drive diverso (Win)
|
|
424
|
+
if (rel === '..' || rel.startsWith('..' + path.sep)) return null; // fuori home
|
|
425
|
+
return normalizeCwdRel(rel);
|
|
426
|
+
}
|
|
427
|
+
|
|
348
428
|
// ---------------------------------------------------------------------------
|
|
349
429
|
// loadDefinitions(p) -> parsed | null
|
|
350
430
|
// Legge il file rifiutando i symlink; parse strict. Mai throw.
|
|
@@ -413,18 +493,20 @@ const CAPS = {
|
|
|
413
493
|
SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
|
|
414
494
|
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
|
|
415
495
|
MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
|
|
416
|
-
MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_TMUXSESSION_LEN,
|
|
496
|
+
MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN, MAX_TMUXSESSION_LEN,
|
|
417
497
|
};
|
|
418
498
|
|
|
419
499
|
module.exports = {
|
|
420
500
|
parseDefinitions,
|
|
421
501
|
validateCommandTrust,
|
|
422
502
|
resolveCwd,
|
|
503
|
+
normalizeCwdRel,
|
|
504
|
+
deriveCwdRel,
|
|
423
505
|
loadDefinitions,
|
|
424
506
|
atomicWrite,
|
|
425
507
|
validTmuxName,
|
|
426
508
|
CAPS,
|
|
427
509
|
// Costanti esposte anche piatte (comode per la UI/schema e i test)
|
|
428
510
|
SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
|
|
429
|
-
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN,
|
|
511
|
+
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN,
|
|
430
512
|
};
|
package/lib/fleet/launch.js
CHANGED
|
@@ -22,6 +22,7 @@ const os = require('node:os');
|
|
|
22
22
|
const path = require('node:path');
|
|
23
23
|
const { execFile } = require('node:child_process');
|
|
24
24
|
const { minimalRuntimeEnv } = require('../runtime/env.js');
|
|
25
|
+
const { codeOf, phaseOf } = require('./causes.js');
|
|
25
26
|
|
|
26
27
|
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
27
28
|
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
@@ -34,7 +35,24 @@ function minimalEnv() {
|
|
|
34
35
|
return minimalRuntimeEnv(process.env, { home: os.homedir() });
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
// httpError(status, msg, data?, cause?) — structured HTTP error. `data` carries
|
|
39
|
+
// arbitrary API detail for the response body; `cause` (T4) is the OPTIONAL
|
|
40
|
+
// bounded failure triple {phase, code} of the up() boundary that failed. The
|
|
41
|
+
// cause is coerced through the closed enum in causes.js (anything not
|
|
42
|
+
// allowlisted degrades to UNKNOWN) and attached as e.fleetCode / e.fleetPhase,
|
|
43
|
+
// so the fleet router can surface {status, code, phase} WITHOUT ever embedding
|
|
44
|
+
// cwd/path, argv, env, prompt, token or credentials. The two channels are kept
|
|
45
|
+
// distinct: `data` is free API detail, `cause` is the bounded failure triple.
|
|
46
|
+
function httpError(status, msg, data = null, cause = null) {
|
|
47
|
+
const e = new Error(msg);
|
|
48
|
+
e.status = status;
|
|
49
|
+
if (data) e.data = data;
|
|
50
|
+
if (cause) {
|
|
51
|
+
e.fleetCode = codeOf(cause.code);
|
|
52
|
+
e.fleetPhase = phaseOf(cause.phase);
|
|
53
|
+
}
|
|
54
|
+
return e;
|
|
55
|
+
}
|
|
38
56
|
|
|
39
57
|
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
40
58
|
// NON devono mai ecoare i segreti delle definizioni.
|
package/lib/fleet/managed.js
CHANGED
|
@@ -61,7 +61,7 @@ const ALIBABA_PI_MODELS = Object.freeze([
|
|
|
61
61
|
|
|
62
62
|
const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
|
|
63
63
|
const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
|
|
64
|
-
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi' });
|
|
64
|
+
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', shell: 'Shell' });
|
|
65
65
|
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
66
66
|
|
|
67
67
|
function validBaseUrl(value) {
|
|
@@ -109,6 +109,9 @@ const CATALOG = Object.freeze([
|
|
|
109
109
|
// Pi uses its real provider IDs directly. OAuth providers do not need env keys.
|
|
110
110
|
{ id: 'pi.native', client: 'pi', provider: 'native', label: 'Pi configured default', auth: 'login', protocol: 'pi_native', default: true, core: true },
|
|
111
111
|
{ id: 'pi.alibaba-token-plan', client: 'pi', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai-completions', model: 'qwen3.8-max-preview', models: ALIBABA_TOKEN_PLAN_MODELS, strictModels: true, piProvider: 'alibaba-token-plan', piExtension: { baseUrl: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', models: ALIBABA_PI_MODELS }, delegatePiAuth: false, core: true, notice: 'alibaba-token-plan' },
|
|
112
|
+
// Device-local shell. Kept after the established default clients so adding
|
|
113
|
+
// it does not change the preselected engine for a newly-created cell.
|
|
114
|
+
{ id: 'shell.local', client: 'shell', provider: 'local', label: 'Shell', auth: 'none', protocol: 'shell', default: true, core: true },
|
|
112
115
|
{ id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic', core: true },
|
|
113
116
|
{ id: 'pi.openai', client: 'pi', provider: 'openai', label: 'OpenAI API', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai', core: true },
|
|
114
117
|
{ id: 'pi.openai-codex', client: 'pi', provider: 'openai-codex', label: 'OpenAI Codex OAuth', auth: 'login', protocol: 'pi_native', piProvider: 'openai-codex', core: true },
|
|
@@ -148,11 +151,12 @@ function normalizeManagedSpec(value) {
|
|
|
148
151
|
if (!profile) return null;
|
|
149
152
|
const model = value.model === undefined ? (profile.model || '') : value.model;
|
|
150
153
|
if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
|
|
154
|
+
if (value.client === 'shell' && model) return null;
|
|
151
155
|
if (profile.requiresModel && !model) return null;
|
|
152
156
|
if (profile.strictModels && !(profile.models || []).includes(model)) return null;
|
|
153
157
|
const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
|
|
154
158
|
if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
|
|
155
|
-
if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
|
|
159
|
+
if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
|
|
156
160
|
const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
|
|
157
161
|
if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
|
|
158
162
|
if (profile.credentialEnv) {
|
|
@@ -188,6 +192,16 @@ function defaultDefinitions() {
|
|
|
188
192
|
};
|
|
189
193
|
}
|
|
190
194
|
|
|
195
|
+
function defaultShellEngine() {
|
|
196
|
+
const profile = CATALOG.find((entry) => entry.id === 'shell.local');
|
|
197
|
+
return {
|
|
198
|
+
id: profile.id,
|
|
199
|
+
label: CLIENT_LABELS.shell,
|
|
200
|
+
rc: false,
|
|
201
|
+
managed: { client: 'shell', provider: 'local', model: '', permissionPolicy: 'standard' },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
191
205
|
function parseAssignments(raw) {
|
|
192
206
|
const out = {};
|
|
193
207
|
for (const line of raw.split(/\r?\n/)) {
|
|
@@ -269,6 +283,35 @@ function findBinary(client, home) {
|
|
|
269
283
|
return null;
|
|
270
284
|
}
|
|
271
285
|
|
|
286
|
+
// Resolve a device-local interactive shell without persisting a path in
|
|
287
|
+
// fleet.json. Candidates are ordered and fail closed. Symlinks are resolved
|
|
288
|
+
// first, then the existing command trust policy is applied to the real file.
|
|
289
|
+
function resolveInteractiveShell(cfg = {}) {
|
|
290
|
+
const env = cfg.env || process.env;
|
|
291
|
+
const platform = cfg.platform || process.platform;
|
|
292
|
+
const termux = termuxRuntimePaths(env, { platform, home: cfg.home });
|
|
293
|
+
const candidates = [];
|
|
294
|
+
if (typeof env.SHELL === 'string' && path.isAbsolute(env.SHELL)) candidates.push(env.SHELL);
|
|
295
|
+
if (termux?.prefix) {
|
|
296
|
+
candidates.push(path.join(termux.prefix, 'bin', 'bash'));
|
|
297
|
+
candidates.push(path.join(termux.prefix, 'bin', 'sh'));
|
|
298
|
+
}
|
|
299
|
+
candidates.push('/bin/bash', '/bin/sh');
|
|
300
|
+
const validate = cfg.validateCommandTrust
|
|
301
|
+
|| ((command) => require('./definitions.js').validateCommandTrust(command));
|
|
302
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
303
|
+
try {
|
|
304
|
+
const real = fs.realpathSync(candidate);
|
|
305
|
+
if (validate(real).ok) return real;
|
|
306
|
+
} catch (_) { /* next candidate */ }
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function shellLoginArgs(command) {
|
|
312
|
+
return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || ''))) ? ['-l'] : [];
|
|
313
|
+
}
|
|
314
|
+
|
|
272
315
|
// Termux reports process.platform === 'android' and deliberately has no
|
|
273
316
|
// /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
|
|
274
317
|
// `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
|
|
@@ -422,7 +465,9 @@ function describeManaged(spec, cfg = {}) {
|
|
|
422
465
|
if (!normalized) return { configured: false, reason: 'invalid managed profile' };
|
|
423
466
|
const home = cfg.home || require('node:os').homedir();
|
|
424
467
|
const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
|
|
425
|
-
const binary =
|
|
468
|
+
const binary = normalized.client === 'shell'
|
|
469
|
+
? resolveInteractiveShell({ ...cfg, home })
|
|
470
|
+
: findBinary(normalized.client, home);
|
|
426
471
|
const cred = credential(profile, normalized, cfg, home);
|
|
427
472
|
// Pi can resolve credentials from its own documented /login auth store. Do
|
|
428
473
|
// not inspect or copy that store; delegate native-provider auth to Pi.
|
|
@@ -585,13 +630,20 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
585
630
|
let effectivePolicy = (override === 'standard' || override === 'unsafe') ? override : spec.permissionPolicy;
|
|
586
631
|
// Pi resta sempre 'standard': normalizeManagedSpec rifiuta gia' unsafe nello spec
|
|
587
632
|
// dell'engine, ma l'override PER-CELL bypasserebbe quel check -> clamp esplicito.
|
|
588
|
-
if (spec.client === 'pi') effectivePolicy = 'standard';
|
|
633
|
+
if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
|
|
589
634
|
info.permissionPolicy = effectivePolicy;
|
|
590
635
|
if (effectivePolicy === 'unsafe') {
|
|
591
636
|
if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
|
|
592
637
|
if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
593
638
|
}
|
|
594
|
-
|
|
639
|
+
let shellOneShot = false;
|
|
640
|
+
if (spec.client === 'shell') {
|
|
641
|
+
const raw = cell?.commands && typeof cell.commands[engineId] === 'string'
|
|
642
|
+
? cell.commands[engineId] : '';
|
|
643
|
+
shellOneShot = raw.trim().length > 0;
|
|
644
|
+
if (shellOneShot) args.push('-lc', raw);
|
|
645
|
+
else args.push(...shellLoginArgs(info.binary));
|
|
646
|
+
} else if (spec.client === 'claude') {
|
|
595
647
|
if (spec.provider === 'native') {
|
|
596
648
|
if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
|
|
597
649
|
} else if (profile.providerEnv) {
|
|
@@ -708,7 +760,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
708
760
|
if (model) args.push('--model', model);
|
|
709
761
|
if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
|
|
710
762
|
}
|
|
711
|
-
if (cell?.prompt) args.push(cell.prompt);
|
|
763
|
+
if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
|
|
712
764
|
let command = info.binary;
|
|
713
765
|
if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
|
|
714
766
|
command = cfg.nodeExecPath || process.execPath;
|
|
@@ -716,6 +768,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
716
768
|
}
|
|
717
769
|
return { ok: true, info, engine: {
|
|
718
770
|
...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
|
|
771
|
+
...(spec.client === 'shell' ? { shellOneShot } : {}),
|
|
719
772
|
} };
|
|
720
773
|
}
|
|
721
774
|
|
|
@@ -724,7 +777,7 @@ function publicCatalog() {
|
|
|
724
777
|
id: p.id, client: p.client, clientLabel: CLIENT_LABELS[p.client], provider: p.provider,
|
|
725
778
|
credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
|
|
726
779
|
auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
|
|
727
|
-
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe:
|
|
780
|
+
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: !['pi', 'shell'].includes(p.client), requiresModel: !!p.requiresModel || !!p.custom,
|
|
728
781
|
permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
|
|
729
782
|
rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
|
|
730
783
|
credentialEnv: p.auth === 'dynamic' ? !!p.credentialEnv : (ENV_KEY_RE.test(p.auth || '') ? p.auth : false),
|
|
@@ -736,8 +789,8 @@ module.exports = {
|
|
|
736
789
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
|
|
737
790
|
ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
|
|
738
791
|
CLIENT_LABELS, normalizeManagedSpec,
|
|
739
|
-
defaultDefinitions, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
792
|
+
defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
740
793
|
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
741
794
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
|
|
742
|
-
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, ENV_KEY_RE,
|
|
795
|
+
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell, shellLoginArgs, ENV_KEY_RE,
|
|
743
796
|
};
|
package/lib/fleet/routes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const express = require('express');
|
|
3
|
+
const { codeOf, phaseOf } = require('./causes.js');
|
|
3
4
|
|
|
4
5
|
// Fallback difensivo per un adapter builtin incompleto nei test. Il provider di
|
|
5
6
|
// prodotto dichiara sempre la propria capability list.
|
|
@@ -60,10 +61,23 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
60
61
|
action: opts.action, cell: safeCell(req.body), errno: match[1], client: match[2], status: e.status || 500,
|
|
61
62
|
});
|
|
62
63
|
else emit('warn', 'FLEET_ACTION_FAILED', 'Fleet action failed', {
|
|
63
|
-
action: opts.action, cell: safeCell(req.body), state: 'failed',
|
|
64
|
+
action: opts.action, cell: safeCell(req.body), state: 'failed',
|
|
65
|
+
status: e.status || 500,
|
|
66
|
+
// Cause-preserving (T4): bounded enum only (lib/fleet/causes.js). The
|
|
67
|
+
// cause triple is {status, code, phase}; no free message, cwd/path,
|
|
68
|
+
// argv, env, prompt, token or credential ever enters here. Untagged /
|
|
69
|
+
// legacy errors degrade to the bounded UNKNOWN fallback.
|
|
70
|
+
code: codeOf(e.fleetCode), phase: phaseOf(e.fleetPhase),
|
|
64
71
|
});
|
|
65
72
|
}
|
|
66
|
-
res.status(e.status || 500).json({
|
|
73
|
+
res.status(e.status || 500).json({
|
|
74
|
+
error: String(e.message || e),
|
|
75
|
+
...(e.data || {}),
|
|
76
|
+
// Structured, bounded cause on the HTTP error too: stable enum only,
|
|
77
|
+
// never cwd/path, argv, env, prompt, token or credentials. Untagged
|
|
78
|
+
// errors (no fleetCode) keep the historical response shape.
|
|
79
|
+
...(e.fleetCode ? { code: codeOf(e.fleetCode), phase: phaseOf(e.fleetPhase) } : {}),
|
|
80
|
+
});
|
|
67
81
|
}
|
|
68
82
|
};
|
|
69
83
|
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -85,7 +85,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
85
85
|
const remembered = c.permissionPolicies
|
|
86
86
|
&& (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
|
|
87
87
|
? c.permissionPolicies[c.engine] : null;
|
|
88
|
-
const effectivePolicy = engineDef?.managed?.client
|
|
88
|
+
const effectivePolicy = ['pi', 'shell'].includes(engineDef?.managed?.client)
|
|
89
89
|
? 'standard'
|
|
90
90
|
: (remembered || engineDefault || '');
|
|
91
91
|
return {
|
|
@@ -149,16 +149,19 @@ function createBuiltinRuntime(ctx) {
|
|
|
149
149
|
let launchEngine = engine;
|
|
150
150
|
if (engine.managed) {
|
|
151
151
|
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
|
|
152
|
-
if (!resolved.ok)
|
|
152
|
+
if (!resolved.ok) {
|
|
153
|
+
const code = engine.managed.client === 'shell' ? 'SHELL_NOT_AVAILABLE' : 'ENGINE_UNCONFIGURED';
|
|
154
|
+
throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
|
|
155
|
+
}
|
|
153
156
|
launchEngine = resolved.engine;
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
// (2) trust del command PRIMA di lanciare
|
|
157
160
|
const trust = validateCommandTrust(launchEngine.command);
|
|
158
|
-
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}
|
|
161
|
+
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`, null, { phase: 'preflight', code: 'COMMAND_UNTRUSTED' });
|
|
159
162
|
// (3) cwd reale sotto la home
|
|
160
163
|
const realCwd = resolveCwd(cell.cwd, home);
|
|
161
|
-
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}
|
|
164
|
+
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`, null, { phase: 'preflight', code: 'CWD_INVALID' });
|
|
162
165
|
|
|
163
166
|
// (4)+(5) argv diretto (no shell). Every cell goes through the private
|
|
164
167
|
// broker-backed supervisor: credentials never enter tmux state/argv and a
|
|
@@ -168,32 +171,46 @@ function createBuiltinRuntime(ctx) {
|
|
|
168
171
|
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
169
172
|
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
170
173
|
const child = composeClientInvocation(launchEngine, cell);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
enabled: true,
|
|
181
|
-
initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
|
|
182
|
-
restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
|
|
183
|
-
maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
|
|
184
|
-
resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
|
|
185
|
-
rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
|
|
186
|
-
maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
|
|
187
|
-
},
|
|
188
|
-
...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
|
|
189
|
-
restartPrompt: {
|
|
190
|
-
tmuxBin,
|
|
191
|
-
tmuxSession: cell.tmuxSession,
|
|
192
|
-
prompt: cell.prompt,
|
|
193
|
-
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
174
|
+
let ticket;
|
|
175
|
+
try {
|
|
176
|
+
ticket = await launchBroker.issue({
|
|
177
|
+
command: child.command,
|
|
178
|
+
args: child.args,
|
|
179
|
+
env: {
|
|
180
|
+
...minimalEnv(),
|
|
181
|
+
...launchEngine.env,
|
|
182
|
+
NEXUSCREW_MCP_SESSION: cell.tmuxSession,
|
|
194
183
|
},
|
|
195
|
-
|
|
196
|
-
|
|
184
|
+
supervise: {
|
|
185
|
+
enabled: !launchEngine.shellOneShot,
|
|
186
|
+
initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
|
|
187
|
+
restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
|
|
188
|
+
maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
|
|
189
|
+
resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
|
|
190
|
+
rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
|
|
191
|
+
maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
|
|
192
|
+
},
|
|
193
|
+
...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
|
|
194
|
+
restartPrompt: {
|
|
195
|
+
tmuxBin,
|
|
196
|
+
tmuxSession: cell.tmuxSession,
|
|
197
|
+
prompt: cell.prompt,
|
|
198
|
+
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
199
|
+
},
|
|
200
|
+
} : {}),
|
|
201
|
+
});
|
|
202
|
+
} catch (brokerErr) {
|
|
203
|
+
const bmsg = String((brokerErr && brokerErr.message) || brokerErr);
|
|
204
|
+
const bcode = /too large/i.test(bmsg) ? 'LAUNCH_BROKER_PAYLOAD'
|
|
205
|
+
: /closed/i.test(bmsg) ? 'LAUNCH_BROKER_CLOSED'
|
|
206
|
+
: /unsafe launch broker/i.test(bmsg) ? 'LAUNCH_BROKER_UNSAFE'
|
|
207
|
+
: 'LAUNCH_BROKER_FAILED';
|
|
208
|
+
const bpublic = bcode === 'LAUNCH_BROKER_PAYLOAD' ? 'launch broker payload rifiutato'
|
|
209
|
+
: bcode === 'LAUNCH_BROKER_CLOSED' ? 'launch broker non disponibile'
|
|
210
|
+
: bcode === 'LAUNCH_BROKER_UNSAFE' ? 'launch broker non sicuro'
|
|
211
|
+
: 'launch broker failed';
|
|
212
|
+
throw httpError(500, bpublic, null, { phase: 'launch-broker', code: bcode });
|
|
213
|
+
}
|
|
197
214
|
const tmuxLaunchEngine = {
|
|
198
215
|
command: process.execPath,
|
|
199
216
|
args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
|
|
@@ -204,14 +221,27 @@ function createBuiltinRuntime(ctx) {
|
|
|
204
221
|
// Mantieni il pane morto solo durante la finestra di readiness: permette di
|
|
205
222
|
// catturare un errore reale del client senza lasciare una sessione fantasma.
|
|
206
223
|
// Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
|
|
207
|
-
|
|
224
|
+
if (!launchEngine.shellOneShot) {
|
|
225
|
+
argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
|
|
226
|
+
}
|
|
208
227
|
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
209
228
|
if (launch.err) {
|
|
210
229
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
211
|
-
const
|
|
230
|
+
const dup = /duplicate session/i.test(launch.stderr);
|
|
231
|
+
const why = dup
|
|
212
232
|
? 'sessione già in esecuzione'
|
|
213
233
|
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
|
|
214
|
-
throw httpError(
|
|
234
|
+
throw httpError(dup ? 409 : 500, why, null,
|
|
235
|
+
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Un comando Shell valorizzato e' intenzionalmente one-shot: l'uscita del
|
|
239
|
+
// processo, anche immediata o non-zero, e' il risultato del comando e non
|
|
240
|
+
// un crash del client Fleet. Non si mantiene il pane e non si abilita il
|
|
241
|
+
// supervisore; la cella torna inattiva quando la shell termina.
|
|
242
|
+
if (launchEngine.shellOneShot) {
|
|
243
|
+
cache = { ...cache, at: 0 };
|
|
244
|
+
return { ok: true, cell: cellId, session: cell.tmuxSession, prompt: null, oneShot: true };
|
|
215
245
|
}
|
|
216
246
|
|
|
217
247
|
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
@@ -235,7 +265,13 @@ function createBuiltinRuntime(ctx) {
|
|
|
235
265
|
cache = { ...cache, at: 0 };
|
|
236
266
|
const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
|
|
237
267
|
const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
|
|
238
|
-
|
|
268
|
+
// Cause-preserving (T4): distinguish a cell-client spawn failure (the
|
|
269
|
+
// captured pane carries the stable 'cell spawn failed:' marker produced by
|
|
270
|
+
// cell-exec.js) from a generic early exit. Both stay on the readiness
|
|
271
|
+
// surface; the spawn branch keeps CELL_SPAWN_FAILED sanitized downstream.
|
|
272
|
+
const isSpawn = /cell spawn failed:/.test(diagnostic);
|
|
273
|
+
throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`, null,
|
|
274
|
+
isSpawn ? { phase: 'spawn-client', code: 'SPAWN_CLIENT_FAILED' } : { phase: 'readiness', code: 'CLIENT_EARLY_EXIT' });
|
|
239
275
|
}
|
|
240
276
|
if (readiness.target) {
|
|
241
277
|
await tmuxExec(tmuxBin,
|