@the-open-engine/zeroshot 6.22.0 → 6.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docker/zeroshot-cluster/Dockerfile +7 -0
- package/lib/agent-cli-provider/adapters/omp.d.ts +3 -1
- package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/omp.js +252 -269
- package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
- package/lib/agent-cli-provider/contract-invoke.d.ts.map +1 -1
- package/lib/agent-cli-provider/contract-invoke.js +68 -14
- package/lib/agent-cli-provider/contract-invoke.js.map +1 -1
- package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
- package/lib/agent-cli-provider/contract-options.js +5 -3
- package/lib/agent-cli-provider/contract-options.js.map +1 -1
- package/lib/agent-cli-provider/index.d.ts +6 -0
- package/lib/agent-cli-provider/index.d.ts.map +1 -1
- package/lib/agent-cli-provider/index.js +12 -1
- package/lib/agent-cli-provider/index.js.map +1 -1
- package/lib/agent-cli-provider/omp-release.d.ts +6 -1
- package/lib/agent-cli-provider/omp-release.d.ts.map +1 -1
- package/lib/agent-cli-provider/omp-release.js +41 -2
- package/lib/agent-cli-provider/omp-release.js.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-bounds.d.ts +7 -0
- package/lib/agent-cli-provider/omp-rpc-bounds.d.ts.map +1 -0
- package/lib/agent-cli-provider/omp-rpc-bounds.js +27 -0
- package/lib/agent-cli-provider/omp-rpc-bounds.js.map +1 -0
- package/lib/agent-cli-provider/omp-rpc-driver.d.ts +40 -0
- package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -0
- package/lib/agent-cli-provider/omp-rpc-driver.js +494 -0
- package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -0
- package/lib/agent-cli-provider/omp-rpc-events.d.ts +28 -0
- package/lib/agent-cli-provider/omp-rpc-events.d.ts.map +1 -0
- package/lib/agent-cli-provider/omp-rpc-events.js +264 -0
- package/lib/agent-cli-provider/omp-rpc-events.js.map +1 -0
- package/lib/agent-cli-provider/omp-rpc-protocol.d.ts +1 -1
- package/lib/agent-cli-provider/omp-rpc-protocol.d.ts.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-protocol.js +8 -3
- package/lib/agent-cli-provider/omp-rpc-protocol.js.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-session.d.ts +17 -0
- package/lib/agent-cli-provider/omp-rpc-session.d.ts.map +1 -0
- package/lib/agent-cli-provider/omp-rpc-session.js +6 -0
- package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -0
- package/lib/agent-cli-provider/provider-registry.d.ts +33 -17
- package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
- package/lib/agent-cli-provider/provider-registry.js +63 -11
- package/lib/agent-cli-provider/provider-registry.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +13 -11
- package/lib/agent-cli-provider/types.d.ts.map +1 -1
- package/lib/agent-cli-provider/types.js.map +1 -1
- package/lib/docker-config.js +122 -5
- package/package.json +2 -1
- package/src/agent/agent-lifecycle.js +12 -1
- package/src/agent/provider-session.js +1 -0
- package/src/agent-cli-provider/adapters/omp.ts +276 -329
- package/src/agent-cli-provider/contract-invoke.ts +86 -15
- package/src/agent-cli-provider/contract-options.ts +5 -3
- package/src/agent-cli-provider/index.ts +13 -0
- package/src/agent-cli-provider/omp-release.ts +50 -1
- package/src/agent-cli-provider/omp-rpc-bounds.ts +28 -0
- package/src/agent-cli-provider/omp-rpc-driver.ts +611 -0
- package/src/agent-cli-provider/omp-rpc-events.ts +318 -0
- package/src/agent-cli-provider/omp-rpc-protocol.ts +8 -3
- package/src/agent-cli-provider/omp-rpc-session.ts +20 -0
- package/src/agent-cli-provider/provider-registry.ts +118 -23
- package/src/agent-cli-provider/types.ts +14 -11
- package/src/command-cleanup-ownership.js +307 -0
- package/src/isolation-manager.js +535 -89
- package/src/omp-config-overlay.js +96 -0
- package/src/orchestrator.js +31 -6
- package/src/preflight.js +50 -5
- package/src/watcher-prompt-channel.js +209 -0
- package/task-lib/command-spec-cleanup.js +1 -262
- package/task-lib/provider-helper-runtime.js +6 -0
- package/task-lib/rpc-watcher.js +186 -0
- package/task-lib/runner.js +40 -6
- package/task-lib/watcher-output-runtime.js +32 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { randomUUID } = require('crypto');
|
|
5
|
+
|
|
6
|
+
const OVERLAY_PREFIX = 'zeroshot-omp-config-';
|
|
7
|
+
const OMP_CONFIG_OVERLAY_DIR_PATTERN = /^zeroshot-omp-config-[A-Za-z0-9_-]+$/u;
|
|
8
|
+
const OMP_CONFIG_OVERLAY_FILE_PATTERN =
|
|
9
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.yml$/u;
|
|
10
|
+
|
|
11
|
+
// Verified against tagged v17.2.1 source: packages/coding-agent/src/config/settings-schema.ts
|
|
12
|
+
// (key types/defaults) and packages/coding-agent/src/main.ts (HOST_DEFAULTED_SETTING_PATHS /
|
|
13
|
+
// RPC_BACKGROUND_DEFAULTED_SETTING_PATHS). RPC mode (docs/rpc.md) only re-applies its own
|
|
14
|
+
// neutral defaults for task.*/memory.backend/memories.enabled/advisor.*/tier.advisor/
|
|
15
|
+
// async.*/bash.autoBackground.* when a path is completely unconfigured at every settings
|
|
16
|
+
// layer; `todo.*` is explicitly caller-controlled and never host-defaulted in RPC mode
|
|
17
|
+
// (main.ts: "embedders need project-level opt-outs for reminder/prelude prompt injection").
|
|
18
|
+
// A host's ~/.omp/agent/config.yml or project .omp/config.yml can therefore change one of
|
|
19
|
+
// these workflow-altering namespaces out from under an unattended RPC worker. This overlay
|
|
20
|
+
// pins every one of them to its schema built-in default plus marketplace.autoUpdate off, so
|
|
21
|
+
// Zeroshot's worker behavior never depends on the host machine's OMP config. It deliberately
|
|
22
|
+
// never sets context/skills/rules/extensions/mcp keys, which keep flowing from native
|
|
23
|
+
// project/user config underneath this overlay.
|
|
24
|
+
const OVERLAY_BODY = `# Zeroshot-owned OMP safety overlay — do not add model/profile settings.
|
|
25
|
+
marketplace:
|
|
26
|
+
autoUpdate: "off"
|
|
27
|
+
todo:
|
|
28
|
+
enabled: true
|
|
29
|
+
reminders: true
|
|
30
|
+
remindersMax: 3
|
|
31
|
+
eager: "default"
|
|
32
|
+
task:
|
|
33
|
+
isolation:
|
|
34
|
+
mode: "none"
|
|
35
|
+
apply: true
|
|
36
|
+
merge: "patch"
|
|
37
|
+
commits: "generic"
|
|
38
|
+
eager: "default"
|
|
39
|
+
batch: true
|
|
40
|
+
maxConcurrency: 32
|
|
41
|
+
maxRecursionDepth: 2
|
|
42
|
+
disabledAgents: []
|
|
43
|
+
agentModelOverrides: {}
|
|
44
|
+
agentPrewalk: {}
|
|
45
|
+
memory:
|
|
46
|
+
backend: "off"
|
|
47
|
+
memories:
|
|
48
|
+
enabled: false
|
|
49
|
+
advisor:
|
|
50
|
+
enabled: false
|
|
51
|
+
subagents: false
|
|
52
|
+
syncBacklog: "off"
|
|
53
|
+
immuneTurns: 3
|
|
54
|
+
tier:
|
|
55
|
+
advisor: "none"
|
|
56
|
+
async:
|
|
57
|
+
enabled: true
|
|
58
|
+
maxJobs: 100
|
|
59
|
+
bash:
|
|
60
|
+
autoBackground:
|
|
61
|
+
enabled: false
|
|
62
|
+
thresholdMs: 60000
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
function createOmpConfigOverlay() {
|
|
66
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), OVERLAY_PREFIX), { mode: 0o700 });
|
|
67
|
+
try {
|
|
68
|
+
const file = path.join(dir, `${randomUUID()}.yml`);
|
|
69
|
+
fs.writeFileSync(file, OVERLAY_BODY, { flag: 'wx', mode: 0o600 });
|
|
70
|
+
if (process.platform !== 'win32') {
|
|
71
|
+
fs.chmodSync(dir, 0o700);
|
|
72
|
+
fs.chmodSync(file, 0o600);
|
|
73
|
+
}
|
|
74
|
+
return { dir, file };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isCanonicalOmpConfigOverlayDirectory(overlayDir) {
|
|
82
|
+
if (typeof overlayDir !== 'string' || !overlayDir || path.resolve(overlayDir) !== overlayDir) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
path.dirname(overlayDir) === path.resolve(os.tmpdir()) &&
|
|
87
|
+
OMP_CONFIG_OVERLAY_DIR_PATTERN.test(path.basename(overlayDir))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
OMP_CONFIG_OVERLAY_DIR_PATTERN,
|
|
93
|
+
OMP_CONFIG_OVERLAY_FILE_PATTERN,
|
|
94
|
+
createOmpConfigOverlay,
|
|
95
|
+
isCanonicalOmpConfigOverlayDirectory,
|
|
96
|
+
};
|
package/src/orchestrator.js
CHANGED
|
@@ -46,7 +46,11 @@ const { generateName } = require('./name-generator');
|
|
|
46
46
|
const configValidator = require('./config-validator');
|
|
47
47
|
const TemplateResolver = require('./template-resolver');
|
|
48
48
|
const { loadSettings } = require('../lib/settings');
|
|
49
|
-
const {
|
|
49
|
+
const {
|
|
50
|
+
normalizeProviderName,
|
|
51
|
+
getDefaultProviderId,
|
|
52
|
+
providerSupportsCapability,
|
|
53
|
+
} = require('../lib/provider-names');
|
|
50
54
|
const { resolveRunPlan } = require('../lib/run-plan');
|
|
51
55
|
const { isProcessRunning } = require('../lib/process-liveness');
|
|
52
56
|
const { getProvider } = require('./providers');
|
|
@@ -1819,20 +1823,31 @@ class Orchestrator {
|
|
|
1819
1823
|
let isolationImage = null;
|
|
1820
1824
|
|
|
1821
1825
|
if (options.isolation) {
|
|
1826
|
+
// Resolve the provider first so the capability gate and the cluster's image variant (which
|
|
1827
|
+
// installs the provider CLI as a Docker-cached layer) both see the same provider. Providers
|
|
1828
|
+
// baked into the base image (e.g. Claude) resolve back to the base image unchanged.
|
|
1829
|
+
const providerName = this._resolveClusterProvider(config);
|
|
1830
|
+
if (!providerSupportsCapability(providerName, 'dockerIsolation')) {
|
|
1831
|
+
throw new Error(
|
|
1832
|
+
`Provider ${providerName} does not support Docker isolation; run without --docker (worktree isolation is supported).`
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1822
1835
|
if (!IsolationManager.isDockerAvailable()) {
|
|
1823
1836
|
throw new Error('Docker is not available. Install Docker to use --docker mode.');
|
|
1824
1837
|
}
|
|
1825
1838
|
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
|
|
1829
|
-
|
|
1839
|
+
// Pre-effect platform probe: fail before any workspace/container side effect when the
|
|
1840
|
+
// Docker engine cannot run the provider's registry-owned platform (e.g. OMP's linux/amd64).
|
|
1841
|
+
const platform = IsolationManager.providerDockerPlatform(providerName);
|
|
1842
|
+
IsolationManager.assertPlatformSupported(platform);
|
|
1843
|
+
|
|
1830
1844
|
const baseImage = options.isolationImage || 'zeroshot-cluster-base';
|
|
1831
1845
|
const image = IsolationManager.imageForProvider(providerName, baseImage);
|
|
1832
1846
|
await IsolationManager.ensureImage(
|
|
1833
1847
|
image,
|
|
1834
1848
|
true,
|
|
1835
|
-
IsolationManager.providerBuildArgs(providerName)
|
|
1849
|
+
IsolationManager.providerBuildArgs(providerName, options.containerHome || '/home/node'),
|
|
1850
|
+
platform
|
|
1836
1851
|
);
|
|
1837
1852
|
isolationImage = image;
|
|
1838
1853
|
|
|
@@ -1847,9 +1862,16 @@ class Orchestrator {
|
|
|
1847
1862
|
mounts: options.mounts,
|
|
1848
1863
|
containerHome: options.containerHome,
|
|
1849
1864
|
provider: providerName,
|
|
1865
|
+
platform,
|
|
1850
1866
|
});
|
|
1851
1867
|
this._log(`[Orchestrator] Container created: ${containerId} (workDir: ${workDir})`);
|
|
1852
1868
|
} else if (options.worktree) {
|
|
1869
|
+
const providerName = this._resolveClusterProvider(config);
|
|
1870
|
+
if (!providerSupportsCapability(providerName, 'worktreeIsolation')) {
|
|
1871
|
+
throw new Error(
|
|
1872
|
+
`Provider ${providerName} does not support worktree isolation; run without --worktree.`
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1853
1875
|
const workDir = options.cwd || process.cwd();
|
|
1854
1876
|
|
|
1855
1877
|
isolationManager = new IsolationManager({});
|
|
@@ -2833,11 +2855,14 @@ class Orchestrator {
|
|
|
2833
2855
|
}
|
|
2834
2856
|
|
|
2835
2857
|
const providerName = this._resolveClusterProvider(cluster.config);
|
|
2858
|
+
const platform = IsolationManager.providerDockerPlatform(providerName);
|
|
2859
|
+
IsolationManager.assertPlatformSupported(platform);
|
|
2836
2860
|
const newContainerId = await cluster.isolation.manager.createContainer(clusterId, {
|
|
2837
2861
|
workDir,
|
|
2838
2862
|
image: cluster.isolation.image,
|
|
2839
2863
|
reuseExistingWorkspace: true,
|
|
2840
2864
|
provider: providerName,
|
|
2865
|
+
platform,
|
|
2841
2866
|
});
|
|
2842
2867
|
|
|
2843
2868
|
this._log(`[Orchestrator] New container created: ${newContainerId}`);
|
package/src/preflight.js
CHANGED
|
@@ -24,6 +24,7 @@ const {
|
|
|
24
24
|
getDefaultProviderId,
|
|
25
25
|
getProviderMetadata,
|
|
26
26
|
normalizeProviderName,
|
|
27
|
+
providerSupportsCapability,
|
|
27
28
|
resolveProviderCommand,
|
|
28
29
|
} = require('../lib/provider-names');
|
|
29
30
|
const { detectGitContext } = require('../lib/git-remote-utils');
|
|
@@ -441,6 +442,32 @@ function validateGatewayProvider() {
|
|
|
441
442
|
};
|
|
442
443
|
}
|
|
443
444
|
|
|
445
|
+
function validateProviderIsolationCapabilities(providerName, options) {
|
|
446
|
+
const errors = [];
|
|
447
|
+
if (options.requireDocker && !providerSupportsCapability(providerName, 'dockerIsolation')) {
|
|
448
|
+
errors.push(
|
|
449
|
+
formatError(
|
|
450
|
+
'Provider does not support Docker isolation',
|
|
451
|
+
`Provider "${providerName}" does not advertise dockerIsolation`,
|
|
452
|
+
[
|
|
453
|
+
'Run without --docker (worktree isolation may be supported)',
|
|
454
|
+
'Or select a different provider',
|
|
455
|
+
]
|
|
456
|
+
)
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (options.requireGit && !providerSupportsCapability(providerName, 'worktreeIsolation')) {
|
|
460
|
+
errors.push(
|
|
461
|
+
formatError(
|
|
462
|
+
'Provider does not support worktree isolation',
|
|
463
|
+
`Provider "${providerName}" does not advertise worktreeIsolation`,
|
|
464
|
+
['Run without --worktree/--pr/--ship', 'Or select a different provider']
|
|
465
|
+
)
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
return errors;
|
|
469
|
+
}
|
|
470
|
+
|
|
444
471
|
function validateProvider(providerName, options) {
|
|
445
472
|
let metadata;
|
|
446
473
|
try {
|
|
@@ -456,14 +483,19 @@ function validateProvider(providerName, options) {
|
|
|
456
483
|
};
|
|
457
484
|
}
|
|
458
485
|
|
|
486
|
+
const isolationErrors = validateProviderIsolationCapabilities(providerName, options);
|
|
487
|
+
|
|
459
488
|
if (metadata.command.kind === 'configured-claude') {
|
|
460
|
-
|
|
489
|
+
const result = validateClaudeProvider(options);
|
|
490
|
+
return { errors: [...isolationErrors, ...result.errors], warnings: result.warnings };
|
|
461
491
|
}
|
|
462
492
|
if (providerName === 'gateway') {
|
|
463
|
-
|
|
493
|
+
const result = validateGatewayProvider();
|
|
494
|
+
return { errors: [...isolationErrors, ...result.errors], warnings: result.warnings };
|
|
464
495
|
}
|
|
465
496
|
|
|
466
|
-
|
|
497
|
+
const result = validateRegistryCliProvider(providerName);
|
|
498
|
+
return { errors: [...isolationErrors, ...result.errors], warnings: result.warnings };
|
|
467
499
|
}
|
|
468
500
|
|
|
469
501
|
function validateRegistryCliProvider(providerName) {
|
|
@@ -519,7 +551,7 @@ function validateGhRequirement() {
|
|
|
519
551
|
return errors;
|
|
520
552
|
}
|
|
521
553
|
|
|
522
|
-
function validateDockerRequirement() {
|
|
554
|
+
function validateDockerRequirement(providerName) {
|
|
523
555
|
const errors = [];
|
|
524
556
|
const docker = checkDocker();
|
|
525
557
|
if (!docker.available) {
|
|
@@ -535,6 +567,19 @@ function validateDockerRequirement() {
|
|
|
535
567
|
]
|
|
536
568
|
)
|
|
537
569
|
);
|
|
570
|
+
return errors;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
const IsolationManager = require('./isolation-manager');
|
|
575
|
+
IsolationManager.assertPlatformSupported(IsolationManager.providerDockerPlatform(providerName));
|
|
576
|
+
} catch (err) {
|
|
577
|
+
errors.push(
|
|
578
|
+
formatError('Docker cannot run required platform', err.message, [
|
|
579
|
+
'Install Buildx and run: docker run --privileged --rm tonistiigi/binfmt --install amd64',
|
|
580
|
+
'Or run Zeroshot on a native amd64 Docker host',
|
|
581
|
+
])
|
|
582
|
+
);
|
|
538
583
|
}
|
|
539
584
|
|
|
540
585
|
return errors;
|
|
@@ -700,7 +745,7 @@ async function runPreflight(options = {}) {
|
|
|
700
745
|
|
|
701
746
|
// 6. Check Docker (if required)
|
|
702
747
|
if (options.requireDocker) {
|
|
703
|
-
errors.push(...validateDockerRequirement());
|
|
748
|
+
errors.push(...validateDockerRequirement(providerName));
|
|
704
749
|
}
|
|
705
750
|
|
|
706
751
|
// 7. Check git repo (if required for worktree isolation)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private parent -> detached-watcher prompt channel.
|
|
3
|
+
*
|
|
4
|
+
* The OMP prompt is task content (repository text, instructions, whatever a caller pasted in), so
|
|
5
|
+
* it must never travel in the watcher's argv: `ps` and /proc/<pid>/cmdline expose argv to every
|
|
6
|
+
* local user for the entire lifetime of a long-lived watcher. task-lib/runner.js therefore hands
|
|
7
|
+
* the prompt to task-lib/rpc-watcher.js over the anonymous stdin pipe fork() creates for it. The
|
|
8
|
+
* pipe has exactly two ends and no name, so nothing is written to the filesystem, the task log, or
|
|
9
|
+
* the ledger while the prompt is in transit.
|
|
10
|
+
*
|
|
11
|
+
* Framing is a newline-terminated JSON header followed by exactly `promptBytes` UTF-8 bytes. That
|
|
12
|
+
* declared length is what lets the receiver tell a complete payload apart from an absent,
|
|
13
|
+
* truncated, over-long, or over-contract one, so it can fail closed instead of prompting the
|
|
14
|
+
* provider with a partial instruction.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { TextDecoder } = require('util');
|
|
18
|
+
|
|
19
|
+
const PROMPT_CHANNEL_KIND = 'zeroshot-watcher-prompt-v1';
|
|
20
|
+
|
|
21
|
+
// The header is a single short JSON object ({"kind":"...","promptBytes":1048576} is 56 bytes), so a
|
|
22
|
+
// peer that never sends a newline must not be able to make the receiver buffer without bound before
|
|
23
|
+
// the declared payload length is even known.
|
|
24
|
+
const MAX_PROMPT_CHANNEL_HEADER_BYTES = 256;
|
|
25
|
+
|
|
26
|
+
class WatcherPromptChannelError extends Error {
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'WatcherPromptChannelError';
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function encodeWatcherPromptFrame(prompt) {
|
|
35
|
+
const body = Buffer.from(prompt, 'utf8');
|
|
36
|
+
const header = `${JSON.stringify({ kind: PROMPT_CHANNEL_KIND, promptBytes: body.byteLength })}\n`;
|
|
37
|
+
return Buffer.concat([Buffer.from(header, 'utf8'), body]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write the framed prompt into the watcher's stdin pipe and close the write end.
|
|
42
|
+
*
|
|
43
|
+
* The receiver owns fail-closed handling for a short or missing payload, so a watcher that already
|
|
44
|
+
* exited (a cancellation requested before spawn closes the read end) has to surface here as a
|
|
45
|
+
* swallowed EPIPE rather than as an unhandled 'error' that would take down the spawning process.
|
|
46
|
+
*/
|
|
47
|
+
function sendWatcherPrompt(stream, prompt) {
|
|
48
|
+
stream.on('error', () => {});
|
|
49
|
+
stream.end(encodeWatcherPromptFrame(prompt));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parsePromptChannelHeader(headerText, maxBytes) {
|
|
53
|
+
let header;
|
|
54
|
+
try {
|
|
55
|
+
header = JSON.parse(headerText);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new WatcherPromptChannelError(
|
|
58
|
+
'malformed-header',
|
|
59
|
+
'Prompt channel header is not valid JSON.'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (header === null || typeof header !== 'object' || header.kind !== PROMPT_CHANNEL_KIND) {
|
|
63
|
+
throw new WatcherPromptChannelError(
|
|
64
|
+
'malformed-header',
|
|
65
|
+
`Prompt channel header is not a ${PROMPT_CHANNEL_KIND} frame.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const { promptBytes } = header;
|
|
69
|
+
if (!Number.isSafeInteger(promptBytes) || promptBytes < 0) {
|
|
70
|
+
throw new WatcherPromptChannelError(
|
|
71
|
+
'malformed-header',
|
|
72
|
+
"Prompt channel header has an invalid 'promptBytes' value."
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (promptBytes > maxBytes) {
|
|
76
|
+
throw new WatcherPromptChannelError(
|
|
77
|
+
'prompt-too-large',
|
|
78
|
+
`Prompt channel declared ${promptBytes} bytes, above the ${maxBytes}-byte contract.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return promptBytes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read exactly one framed prompt off `stream`, or reject with a WatcherPromptChannelError.
|
|
86
|
+
*
|
|
87
|
+
* Every rejection path is a fail-closed one: an absent channel, a header that never arrives, a
|
|
88
|
+
* declared length above `maxBytes`, a payload that ends short of (or overruns) the declared length,
|
|
89
|
+
* and invalid UTF-8 all reject rather than resolving with partial text.
|
|
90
|
+
*/
|
|
91
|
+
function receiveWatcherPrompt(stream, { maxBytes }) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
if (!stream || typeof stream.on !== 'function' || stream.isTTY) {
|
|
94
|
+
reject(
|
|
95
|
+
new WatcherPromptChannelError(
|
|
96
|
+
'absent',
|
|
97
|
+
'No private prompt pipe is attached to this watcher.'
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let buffer = Buffer.alloc(0);
|
|
104
|
+
let declaredBytes = null;
|
|
105
|
+
let settled = false;
|
|
106
|
+
|
|
107
|
+
function detach() {
|
|
108
|
+
stream.removeListener('data', onData);
|
|
109
|
+
stream.removeListener('end', onEnd);
|
|
110
|
+
stream.removeListener('error', onError);
|
|
111
|
+
// destroy() can emit 'error' after the listeners above are gone, which would otherwise throw
|
|
112
|
+
// as an unhandled 'error' event and mask the real channel outcome.
|
|
113
|
+
stream.on('error', () => {});
|
|
114
|
+
try {
|
|
115
|
+
stream.destroy();
|
|
116
|
+
} catch {
|
|
117
|
+
// Already released; nothing further to close.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fail(code, message) {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
detach();
|
|
125
|
+
reject(new WatcherPromptChannelError(code, message));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function succeed(prompt) {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
detach();
|
|
132
|
+
resolve(prompt);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readHeader() {
|
|
136
|
+
const newlineIndex = buffer.indexOf(0x0a);
|
|
137
|
+
if (newlineIndex === -1) {
|
|
138
|
+
if (buffer.byteLength > MAX_PROMPT_CHANNEL_HEADER_BYTES) {
|
|
139
|
+
fail(
|
|
140
|
+
'malformed-header',
|
|
141
|
+
`Prompt channel header exceeded ${MAX_PROMPT_CHANNEL_HEADER_BYTES} bytes without a newline.`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const headerText = buffer.subarray(0, newlineIndex).toString('utf8');
|
|
147
|
+
buffer = buffer.subarray(newlineIndex + 1);
|
|
148
|
+
try {
|
|
149
|
+
declaredBytes = parsePromptChannelHeader(headerText, maxBytes);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
fail(error.code, error.message);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function consume() {
|
|
158
|
+
if (declaredBytes === null && !readHeader()) return;
|
|
159
|
+
if (buffer.byteLength > declaredBytes) {
|
|
160
|
+
fail(
|
|
161
|
+
'overlong-payload',
|
|
162
|
+
`Prompt channel delivered more than the declared ${declaredBytes} bytes.`
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (buffer.byteLength < declaredBytes) return;
|
|
167
|
+
let prompt;
|
|
168
|
+
try {
|
|
169
|
+
prompt = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
170
|
+
} catch {
|
|
171
|
+
fail('invalid-utf8', 'Prompt channel payload is not valid UTF-8.');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
succeed(prompt);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function onData(chunk) {
|
|
178
|
+
if (settled) return;
|
|
179
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
180
|
+
consume();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function onEnd() {
|
|
184
|
+
fail(
|
|
185
|
+
'truncated',
|
|
186
|
+
declaredBytes === null
|
|
187
|
+
? 'Prompt channel closed before a complete header arrived.'
|
|
188
|
+
: `Prompt channel closed after ${buffer.byteLength} of ${declaredBytes} declared bytes.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function onError(error) {
|
|
193
|
+
fail('channel-error', `Prompt channel failed: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
stream.on('data', onData);
|
|
197
|
+
stream.on('end', onEnd);
|
|
198
|
+
stream.on('error', onError);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
MAX_PROMPT_CHANNEL_HEADER_BYTES,
|
|
204
|
+
PROMPT_CHANNEL_KIND,
|
|
205
|
+
WatcherPromptChannelError,
|
|
206
|
+
encodeWatcherPromptFrame,
|
|
207
|
+
receiveWatcherPrompt,
|
|
208
|
+
sendWatcherPrompt,
|
|
209
|
+
};
|