dsh-ops 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +417 -0
- package/README.en.md +121 -0
- package/README.md +113 -2
- package/README.zh.md +114 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +993 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
//! Cross-platform process-tree spawning for non-interactive bash commands.
|
|
2
|
+
|
|
3
|
+
#[cfg(windows)]
|
|
4
|
+
use process_wrap::std::CommandWrapper;
|
|
5
|
+
use process_wrap::std::{ChildWrapper, CommandWrap};
|
|
6
|
+
use std::io::PipeReader;
|
|
7
|
+
use std::path::Path;
|
|
8
|
+
#[cfg(windows)]
|
|
9
|
+
use std::path::PathBuf;
|
|
10
|
+
#[cfg(windows)]
|
|
11
|
+
use std::process::Command;
|
|
12
|
+
use std::process::{ExitStatus, Stdio};
|
|
13
|
+
use std::time::{Duration, Instant};
|
|
14
|
+
|
|
15
|
+
#[cfg(windows)]
|
|
16
|
+
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
|
17
|
+
#[cfg(windows)]
|
|
18
|
+
use std::os::windows::process::CommandExt;
|
|
19
|
+
|
|
20
|
+
const TERMINATION_TIMEOUT: Duration = Duration::from_secs(5);
|
|
21
|
+
|
|
22
|
+
/// Byte length of command text above which the command runs from a script file.
|
|
23
|
+
///
|
|
24
|
+
/// Windows CreateProcessW caps one command line at 32,767 UTF-16 units, and argument
|
|
25
|
+
/// quoting can double the text in the worst case, so a long command passed straight to
|
|
26
|
+
/// `bash -c` fails to spawn (os error 206). Commands over this threshold are written to
|
|
27
|
+
/// a temp script instead, which has no length ceiling; 12,000 bytes doubles to well
|
|
28
|
+
/// under the cap with room for the bash path and flags. Applied on every platform so
|
|
29
|
+
/// behavior does not fork by OS (2026-08-08).
|
|
30
|
+
const SCRIPT_SPAWN_THRESHOLD_BYTES: usize = 12_000;
|
|
31
|
+
|
|
32
|
+
/// A spawned bash process whose kill operation covers the whole descendant tree.
|
|
33
|
+
#[derive(Debug)]
|
|
34
|
+
pub(crate) struct ManagedProcess {
|
|
35
|
+
child: Box<dyn ChildWrapper>,
|
|
36
|
+
output: Option<PipeReader>,
|
|
37
|
+
/// Keeps an over-threshold command's script file alive until the process is reaped.
|
|
38
|
+
_command_script: Option<tempfile::TempPath>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl ManagedProcess {
|
|
42
|
+
pub(crate) fn id(&self) -> u32 {
|
|
43
|
+
self.child.id()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pub(crate) fn take_output(&mut self) -> PipeReader {
|
|
47
|
+
self.output
|
|
48
|
+
.take()
|
|
49
|
+
.expect("the merged output pipe is taken exactly once")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub(crate) fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
|
|
53
|
+
self.child.try_wait()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pub(crate) fn kill_tree(&mut self) -> std::io::Result<()> {
|
|
57
|
+
match self.child.start_kill() {
|
|
58
|
+
Ok(()) => Ok(()),
|
|
59
|
+
Err(error)
|
|
60
|
+
if matches!(
|
|
61
|
+
error.kind(),
|
|
62
|
+
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::NotFound
|
|
63
|
+
) =>
|
|
64
|
+
{
|
|
65
|
+
Ok(())
|
|
66
|
+
}
|
|
67
|
+
Err(error) => Err(error),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Kills the whole tree and waits with a hard bound so an OS failure cannot hang the MCP call.
|
|
72
|
+
pub(crate) fn terminate_tree(&mut self) -> std::io::Result<ExitStatus> {
|
|
73
|
+
let kill_result = self.kill_tree();
|
|
74
|
+
let deadline = Instant::now() + TERMINATION_TIMEOUT;
|
|
75
|
+
loop {
|
|
76
|
+
match self.try_wait()? {
|
|
77
|
+
Some(status) => return Ok(status),
|
|
78
|
+
None if Instant::now() < deadline => {
|
|
79
|
+
std::thread::sleep(Duration::from_millis(10));
|
|
80
|
+
}
|
|
81
|
+
None => {
|
|
82
|
+
return Err(kill_result.err().unwrap_or_else(|| {
|
|
83
|
+
std::io::Error::new(
|
|
84
|
+
std::io::ErrorKind::TimedOut,
|
|
85
|
+
"the process tree did not exit within 5 seconds after termination",
|
|
86
|
+
)
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
impl Drop for ManagedProcess {
|
|
95
|
+
fn drop(&mut self) {
|
|
96
|
+
let _ = self.kill_tree();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Spawns a login or clean non-login bash with one merged stdout/stderr pipe.
|
|
101
|
+
pub(crate) fn spawn_bash(
|
|
102
|
+
bash: &Path,
|
|
103
|
+
command_text: &str,
|
|
104
|
+
cwd: &Path,
|
|
105
|
+
login_shell: bool,
|
|
106
|
+
environment: &crate::session::SessionEnvironment,
|
|
107
|
+
utf8_locale: &str,
|
|
108
|
+
) -> std::io::Result<ManagedProcess> {
|
|
109
|
+
let (reader, writer) = std::io::pipe()?;
|
|
110
|
+
let mut command = crate::process_policy::noninteractive_command(bash);
|
|
111
|
+
environment.configure_command(&mut command);
|
|
112
|
+
let command_script = if command_text.len() > SCRIPT_SPAWN_THRESHOLD_BYTES {
|
|
113
|
+
let script = write_command_script(command_text)?;
|
|
114
|
+
if login_shell {
|
|
115
|
+
command.arg("-l");
|
|
116
|
+
} else {
|
|
117
|
+
command.args(["--noprofile", "--norc"]);
|
|
118
|
+
}
|
|
119
|
+
command.arg(script_operand(&script));
|
|
120
|
+
Some(script)
|
|
121
|
+
} else {
|
|
122
|
+
if login_shell {
|
|
123
|
+
command.arg("-lc");
|
|
124
|
+
} else {
|
|
125
|
+
command.args(["--noprofile", "--norc", "-c"]);
|
|
126
|
+
}
|
|
127
|
+
command.arg(command_text);
|
|
128
|
+
None
|
|
129
|
+
};
|
|
130
|
+
command
|
|
131
|
+
.current_dir(cwd)
|
|
132
|
+
.stdin(Stdio::null())
|
|
133
|
+
.stdout(writer.try_clone()?)
|
|
134
|
+
.stderr(writer)
|
|
135
|
+
.env("LANG", utf8_locale)
|
|
136
|
+
.env("LC_ALL", utf8_locale)
|
|
137
|
+
.env("TERM", "dumb")
|
|
138
|
+
.env("NO_COLOR", "1")
|
|
139
|
+
.env("CLICOLOR", "0")
|
|
140
|
+
.env("FORCE_COLOR", "0")
|
|
141
|
+
.env("PAGER", "cat")
|
|
142
|
+
.env("GIT_PAGER", "cat")
|
|
143
|
+
.env("GIT_EDITOR", "true")
|
|
144
|
+
.env("EDITOR", "true")
|
|
145
|
+
.env("VISUAL", "true")
|
|
146
|
+
.env("GIT_TERMINAL_PROMPT", "0")
|
|
147
|
+
.env("PYTHONUNBUFFERED", "1")
|
|
148
|
+
.env("PYTHONIOENCODING", "utf-8");
|
|
149
|
+
|
|
150
|
+
#[cfg(windows)]
|
|
151
|
+
if !login_shell {
|
|
152
|
+
prepend_windows_toolset(&mut command, bash, environment);
|
|
153
|
+
}
|
|
154
|
+
#[cfg(windows)]
|
|
155
|
+
if login_shell && environment.var_os(MSYS_PATH_TYPE).is_none() {
|
|
156
|
+
command.env(MSYS_PATH_TYPE, "inherit");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let mut wrapped = CommandWrap::from(command);
|
|
160
|
+
#[cfg(unix)]
|
|
161
|
+
wrapped.wrap(process_wrap::std::ProcessSession);
|
|
162
|
+
#[cfg(windows)]
|
|
163
|
+
wrapped.wrap(KillOnCloseJobObject);
|
|
164
|
+
let child = wrapped.spawn()?;
|
|
165
|
+
drop(wrapped);
|
|
166
|
+
Ok(ManagedProcess {
|
|
167
|
+
child,
|
|
168
|
+
output: Some(reader),
|
|
169
|
+
_command_script: command_script,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// Writes over-threshold command text to a private temp script deleted when dropped.
|
|
174
|
+
fn write_command_script(command_text: &str) -> std::io::Result<tempfile::TempPath> {
|
|
175
|
+
use std::io::Write;
|
|
176
|
+
|
|
177
|
+
let mut script = tempfile::Builder::new()
|
|
178
|
+
.prefix("fastctx-command-")
|
|
179
|
+
.suffix(".sh")
|
|
180
|
+
.tempfile()?;
|
|
181
|
+
script.write_all(command_text.as_bytes())?;
|
|
182
|
+
script.flush()?;
|
|
183
|
+
Ok(script.into_temp_path())
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Renders the script path as the bash operand.
|
|
187
|
+
fn script_operand(script: &tempfile::TempPath) -> std::ffi::OsString {
|
|
188
|
+
#[cfg(windows)]
|
|
189
|
+
{
|
|
190
|
+
// The msys argument layer passes a native absolute path through cleanly only in
|
|
191
|
+
// forward-slash form; backslashes are exposed to POSIX escaping rules.
|
|
192
|
+
std::ffi::OsString::from(script.to_string_lossy().replace('\\', "/"))
|
|
193
|
+
}
|
|
194
|
+
#[cfg(not(windows))]
|
|
195
|
+
{
|
|
196
|
+
script.as_os_str().to_os_string()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// Owns a Windows Job Object whose process tree dies even if the supervising process is killed.
|
|
201
|
+
///
|
|
202
|
+
/// `process-wrap`'s synchronous JobObject deliberately omits
|
|
203
|
+
/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. That is insufficient for persistent
|
|
204
|
+
/// background jobs: an externally terminated supervisor would drop its handle
|
|
205
|
+
/// without running Rust destructors and leave the bash tree orphaned. This
|
|
206
|
+
/// wrapper keeps the child suspended until assignment and makes the kernel
|
|
207
|
+
/// enforce the ownership invariant.
|
|
208
|
+
#[cfg(windows)]
|
|
209
|
+
#[derive(Debug)]
|
|
210
|
+
struct KillOnCloseJobObject;
|
|
211
|
+
|
|
212
|
+
#[cfg(windows)]
|
|
213
|
+
impl CommandWrapper for KillOnCloseJobObject {
|
|
214
|
+
fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
|
|
215
|
+
use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
|
|
216
|
+
|
|
217
|
+
command.creation_flags(crate::process_policy::noninteractive_creation_flags(
|
|
218
|
+
CREATE_SUSPENDED,
|
|
219
|
+
));
|
|
220
|
+
Ok(())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fn wrap_child(
|
|
224
|
+
&mut self,
|
|
225
|
+
mut child: Box<dyn ChildWrapper>,
|
|
226
|
+
_core: &CommandWrap,
|
|
227
|
+
) -> std::io::Result<Box<dyn ChildWrapper>> {
|
|
228
|
+
let process_handle = child.inner_child().as_raw_handle();
|
|
229
|
+
let job = match create_kill_on_close_job(process_handle) {
|
|
230
|
+
Ok(job) => job,
|
|
231
|
+
Err(error) => {
|
|
232
|
+
let _ = child.start_kill();
|
|
233
|
+
let _ = child.wait();
|
|
234
|
+
return Err(error);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
if let Err(error) = resume_process_threads(child.id()) {
|
|
238
|
+
let _ = terminate_job(&job);
|
|
239
|
+
let _ = child.wait();
|
|
240
|
+
return Err(error);
|
|
241
|
+
}
|
|
242
|
+
Ok(Box::new(KillOnCloseJobChild {
|
|
243
|
+
inner: Some(child),
|
|
244
|
+
job,
|
|
245
|
+
}))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[cfg(windows)]
|
|
250
|
+
#[derive(Debug)]
|
|
251
|
+
struct KillOnCloseJobChild {
|
|
252
|
+
inner: Option<Box<dyn ChildWrapper>>,
|
|
253
|
+
job: OwnedHandle,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[cfg(windows)]
|
|
257
|
+
impl ChildWrapper for KillOnCloseJobChild {
|
|
258
|
+
fn inner(&self) -> &dyn ChildWrapper {
|
|
259
|
+
self.inner
|
|
260
|
+
.as_deref()
|
|
261
|
+
.expect("the child wrapper is present until it is consumed")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn inner_mut(&mut self) -> &mut dyn ChildWrapper {
|
|
265
|
+
self.inner
|
|
266
|
+
.as_deref_mut()
|
|
267
|
+
.expect("the child wrapper is present until it is consumed")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
fn into_inner(mut self: Box<Self>) -> Box<dyn ChildWrapper> {
|
|
271
|
+
let _ = terminate_job(&self.job);
|
|
272
|
+
self.inner
|
|
273
|
+
.take()
|
|
274
|
+
.expect("the child wrapper is consumed exactly once")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fn start_kill(&mut self) -> std::io::Result<()> {
|
|
278
|
+
terminate_job(&self.job)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[cfg(windows)]
|
|
283
|
+
fn create_kill_on_close_job(
|
|
284
|
+
process_handle: std::os::windows::io::RawHandle,
|
|
285
|
+
) -> std::io::Result<OwnedHandle> {
|
|
286
|
+
use std::ffi::c_void;
|
|
287
|
+
use windows_sys::Win32::System::JobObjects::{
|
|
288
|
+
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
289
|
+
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
|
290
|
+
SetInformationJobObject,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// SAFETY: null security/name arguments request an unnamed job with default
|
|
294
|
+
// security. The returned owned handle is closed on every exit path.
|
|
295
|
+
let raw_job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
|
296
|
+
if raw_job.is_null() {
|
|
297
|
+
return Err(std::io::Error::last_os_error());
|
|
298
|
+
}
|
|
299
|
+
// SAFETY: CreateJobObjectW returned a fresh handle owned by this function.
|
|
300
|
+
let job = unsafe { OwnedHandle::from_raw_handle(raw_job.cast()) };
|
|
301
|
+
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
|
302
|
+
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
303
|
+
// SAFETY: both pointers remain valid for the duration of each synchronous
|
|
304
|
+
// call, and the buffer size exactly matches the declared information class.
|
|
305
|
+
let configured = unsafe {
|
|
306
|
+
SetInformationJobObject(
|
|
307
|
+
job.as_raw_handle().cast(),
|
|
308
|
+
JobObjectExtendedLimitInformation,
|
|
309
|
+
(&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast::<c_void>(),
|
|
310
|
+
std::mem::size_of_val(&limits) as u32,
|
|
311
|
+
)
|
|
312
|
+
};
|
|
313
|
+
if configured == 0 {
|
|
314
|
+
return Err(std::io::Error::last_os_error());
|
|
315
|
+
}
|
|
316
|
+
// SAFETY: the process is still suspended, so no descendant can escape
|
|
317
|
+
// between assignment and resumption.
|
|
318
|
+
let assigned =
|
|
319
|
+
unsafe { AssignProcessToJobObject(job.as_raw_handle().cast(), process_handle.cast()) };
|
|
320
|
+
if assigned == 0 {
|
|
321
|
+
return Err(std::io::Error::last_os_error());
|
|
322
|
+
}
|
|
323
|
+
Ok(job)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#[cfg(windows)]
|
|
327
|
+
fn resume_process_threads(pid: u32) -> std::io::Result<()> {
|
|
328
|
+
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
|
329
|
+
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
|
330
|
+
CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
|
|
331
|
+
};
|
|
332
|
+
use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
|
|
333
|
+
|
|
334
|
+
// SAFETY: the snapshot handle is closed below on every path after creation.
|
|
335
|
+
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
|
|
336
|
+
if snapshot == INVALID_HANDLE_VALUE {
|
|
337
|
+
return Err(std::io::Error::last_os_error());
|
|
338
|
+
}
|
|
339
|
+
let mut entry = THREADENTRY32 {
|
|
340
|
+
dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
|
|
341
|
+
..Default::default()
|
|
342
|
+
};
|
|
343
|
+
// SAFETY: entry has the documented size and remains live while enumerated.
|
|
344
|
+
let mut has_entry = unsafe { Thread32First(snapshot, &mut entry) };
|
|
345
|
+
let mut result = if has_entry == 0 {
|
|
346
|
+
Err(std::io::Error::last_os_error())
|
|
347
|
+
} else {
|
|
348
|
+
Ok(())
|
|
349
|
+
};
|
|
350
|
+
let mut resumed_any = false;
|
|
351
|
+
while has_entry != 0 {
|
|
352
|
+
if entry.th32OwnerProcessID == pid {
|
|
353
|
+
// SAFETY: the thread id comes from the live system snapshot.
|
|
354
|
+
let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
|
|
355
|
+
if thread.is_null() {
|
|
356
|
+
result = Err(std::io::Error::last_os_error());
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
// SAFETY: the handle was opened with THREAD_SUSPEND_RESUME.
|
|
360
|
+
let resumed = unsafe { ResumeThread(thread) };
|
|
361
|
+
let resume_error = std::io::Error::last_os_error();
|
|
362
|
+
// SAFETY: this function owns the thread handle.
|
|
363
|
+
let closed = unsafe { CloseHandle(thread) };
|
|
364
|
+
if resumed == u32::MAX {
|
|
365
|
+
result = Err(resume_error);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
if closed == 0 {
|
|
369
|
+
result = Err(std::io::Error::last_os_error());
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
resumed_any = true;
|
|
373
|
+
}
|
|
374
|
+
// SAFETY: snapshot and entry remain valid for the next enumeration step.
|
|
375
|
+
has_entry = unsafe { Thread32Next(snapshot, &mut entry) };
|
|
376
|
+
}
|
|
377
|
+
// SAFETY: this function owns the snapshot handle.
|
|
378
|
+
let closed = unsafe { CloseHandle(snapshot) };
|
|
379
|
+
if result.is_ok() && closed == 0 {
|
|
380
|
+
return Err(std::io::Error::last_os_error());
|
|
381
|
+
}
|
|
382
|
+
if result.is_ok() && !resumed_any {
|
|
383
|
+
return Err(std::io::Error::new(
|
|
384
|
+
std::io::ErrorKind::NotFound,
|
|
385
|
+
format!("cannot find a suspended thread for process {pid}"),
|
|
386
|
+
));
|
|
387
|
+
}
|
|
388
|
+
result
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#[cfg(windows)]
|
|
392
|
+
fn terminate_job(job: &OwnedHandle) -> std::io::Result<()> {
|
|
393
|
+
use windows_sys::Win32::System::JobObjects::TerminateJobObject;
|
|
394
|
+
|
|
395
|
+
// SAFETY: job is a live Job Object handle owned by the wrapper.
|
|
396
|
+
if unsafe { TerminateJobObject(job.as_raw_handle().cast(), 1) } == 0 {
|
|
397
|
+
Err(std::io::Error::last_os_error())
|
|
398
|
+
} else {
|
|
399
|
+
Ok(())
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/// Selects how the msys `/etc/profile` composes PATH out of the Windows PATH it inherits.
|
|
404
|
+
///
|
|
405
|
+
/// Git for Windows defaults this to `inherit`, but an msys2 installation defaults to `minimal`,
|
|
406
|
+
/// whose profile *replaces* the inherited PATH with four Windows system directories — so a login
|
|
407
|
+
/// shell there resolves none of the user's own tools. `prepend_windows_toolset` is the non-login
|
|
408
|
+
/// counterpart; a login shell needs this instead, because profile runs after we hand over the
|
|
409
|
+
/// environment. An explicit value is always left alone: choosing `minimal` or `strict` is a
|
|
410
|
+
/// deliberate isolation choice that belongs to whoever made it.
|
|
411
|
+
#[cfg(windows)]
|
|
412
|
+
const MSYS_PATH_TYPE: &str = "MSYS2_PATH_TYPE";
|
|
413
|
+
|
|
414
|
+
/// Gives a non-login Windows bash the Git-for-Windows Unix toolset on PATH.
|
|
415
|
+
///
|
|
416
|
+
/// `--noprofile --norc` never sources `/etc/profile`, which is what normally puts
|
|
417
|
+
/// `/usr/bin` (coreutils, sed, grep, awk) and `/mingw*/bin` (git) on PATH. Without
|
|
418
|
+
/// them a clean shell resolves no external command and every one fails with 127 —
|
|
419
|
+
/// whenever the server was launched from a host whose PATH lacks those directories
|
|
420
|
+
/// (e.g. Codex started from PowerShell rather than Git Bash). We prepend the toolset
|
|
421
|
+
/// directories in native Windows form; the msys runtime rewrites them to POSIX for
|
|
422
|
+
/// the child. A login shell needs none of this — profile sets it up — so it is left
|
|
423
|
+
/// untouched.
|
|
424
|
+
#[cfg(windows)]
|
|
425
|
+
fn prepend_windows_toolset(
|
|
426
|
+
command: &mut Command,
|
|
427
|
+
bash: &Path,
|
|
428
|
+
environment: &crate::session::SessionEnvironment,
|
|
429
|
+
) {
|
|
430
|
+
let Some(usr_bin) = bash.parent() else { return };
|
|
431
|
+
let mut dirs: Vec<PathBuf> = vec![usr_bin.to_path_buf()];
|
|
432
|
+
// Auto-discovered bash is <GitRoot>\usr\bin\bash.exe; git lives in <GitRoot>\mingw*\bin.
|
|
433
|
+
if let Some(git_root) = usr_bin.parent().and_then(Path::parent) {
|
|
434
|
+
for arch in ["mingw64", "mingw32", "clangarm64"] {
|
|
435
|
+
let bin = git_root.join(arch).join("bin");
|
|
436
|
+
if bin.is_dir() {
|
|
437
|
+
dirs.push(bin);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
let Ok(mut path) = std::env::join_paths(&dirs) else {
|
|
442
|
+
return;
|
|
443
|
+
};
|
|
444
|
+
if let Some(existing) = environment.var_os("PATH")
|
|
445
|
+
&& !existing.is_empty()
|
|
446
|
+
{
|
|
447
|
+
path.push(";");
|
|
448
|
+
path.push(&existing);
|
|
449
|
+
}
|
|
450
|
+
command.env("PATH", path);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
pub(crate) fn detect_utf8_locale(
|
|
454
|
+
bash: &Path,
|
|
455
|
+
environment: &crate::session::SessionEnvironment,
|
|
456
|
+
) -> String {
|
|
457
|
+
let mut command = crate::process_policy::noninteractive_command(bash);
|
|
458
|
+
environment.configure_command(&mut command);
|
|
459
|
+
let available = command
|
|
460
|
+
.args(["-lc", "locale -a 2>/dev/null || true"])
|
|
461
|
+
.stdin(Stdio::null())
|
|
462
|
+
.output()
|
|
463
|
+
.ok()
|
|
464
|
+
.map(|output| String::from_utf8_lossy(&output.stdout).to_ascii_lowercase())
|
|
465
|
+
.unwrap_or_default();
|
|
466
|
+
select_utf8_locale(&available).to_string()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
fn select_utf8_locale(available: &str) -> &'static str {
|
|
470
|
+
if available
|
|
471
|
+
.lines()
|
|
472
|
+
.any(|locale| matches!(locale.trim(), "c.utf8" | "c.utf-8"))
|
|
473
|
+
{
|
|
474
|
+
"C.UTF-8"
|
|
475
|
+
} else {
|
|
476
|
+
"en_US.UTF-8"
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/// Converts signal exits to the bash-compatible `128 + signal` convention.
|
|
481
|
+
pub(crate) fn exit_code(status: ExitStatus) -> i32 {
|
|
482
|
+
if let Some(code) = status.code() {
|
|
483
|
+
return code;
|
|
484
|
+
}
|
|
485
|
+
#[cfg(unix)]
|
|
486
|
+
{
|
|
487
|
+
use std::os::unix::process::ExitStatusExt;
|
|
488
|
+
if let Some(signal) = status.signal() {
|
|
489
|
+
return 128 + signal;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
1
|
|
493
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Modified by dsh-ops (https://github.com/T-Auto/dsh-ops): the run_background description no longer names Codex as a restart partner.
|
|
2
|
+
//! Shell-tool routes merged into the single `fastctx` server.
|
|
3
|
+
|
|
4
|
+
use crate::budget::{GLOBAL_TOKEN_BUDGET_ENV, JOB_OUTPUT_TOKEN_BUDGET_ENV, RUN_TOKEN_BUDGET_ENV};
|
|
5
|
+
use crate::server::FastCtxServer;
|
|
6
|
+
use crate::server_support::{BudgetRetry, run_blocking};
|
|
7
|
+
use crate::shell::{
|
|
8
|
+
JobKillRequest, JobListRequest, JobOutputRequest, RunBackgroundRequest, RunRequest,
|
|
9
|
+
};
|
|
10
|
+
use rmcp::RoleServer;
|
|
11
|
+
use rmcp::handler::server::wrapper::Parameters;
|
|
12
|
+
use rmcp::model::CallToolResult;
|
|
13
|
+
use rmcp::service::RequestContext;
|
|
14
|
+
use rmcp::{tool, tool_router};
|
|
15
|
+
use std::sync::Arc;
|
|
16
|
+
|
|
17
|
+
#[tool_router(router = shell_tool_router, vis = "pub(crate)")]
|
|
18
|
+
impl FastCtxServer {
|
|
19
|
+
#[tool(
|
|
20
|
+
name = "run",
|
|
21
|
+
description = "Use for non-interactive local CLI work, including Git, build/test tools,\npackage managers, database CLIs, and project scripts. Commands run with bash\n(Git Bash on Windows; system bash elsewhere) and return merged stdout+stderr\nwith the exit code. Write POSIX bash — never PowerShell. There is no TTY or\nstdin; use flags like -y or --no-edit. A non-zero exit code is a normal\nresult, not an error. Oversized output is truncated; for the full output,\nredirect it to a file (command > file 2>&1) and page that file with\ninspect_local_file.\nDefault timeout 120000 ms, ceiling 240000 — start anything that may outlast\nit with run_background. If output looks garbled (U+FFFD), pass encoding\n(e.g. \"gbk\"). The last line states Complete, Partial, or Killed.",
|
|
22
|
+
annotations(
|
|
23
|
+
title = "Run bash command",
|
|
24
|
+
read_only_hint = false,
|
|
25
|
+
destructive_hint = false,
|
|
26
|
+
open_world_hint = false
|
|
27
|
+
)
|
|
28
|
+
)]
|
|
29
|
+
async fn run(
|
|
30
|
+
&self,
|
|
31
|
+
Parameters(request): Parameters<RunRequest>,
|
|
32
|
+
context: RequestContext<RoleServer>,
|
|
33
|
+
) -> CallToolResult {
|
|
34
|
+
let _activity = self.activity.request();
|
|
35
|
+
let shell = self.shell.clone();
|
|
36
|
+
let status_shell = self.shell.clone();
|
|
37
|
+
run_blocking(
|
|
38
|
+
Arc::clone(&self.session),
|
|
39
|
+
Arc::clone(&self.shell_permits),
|
|
40
|
+
RUN_TOKEN_BUDGET_ENV,
|
|
41
|
+
move || status_shell.background_status(None),
|
|
42
|
+
BudgetRetry::Never,
|
|
43
|
+
move || shell.run_until_cancelled(request.clone(), || context.ct.is_cancelled()),
|
|
44
|
+
)
|
|
45
|
+
.await
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[tool(
|
|
49
|
+
name = "run_background",
|
|
50
|
+
description = "Start a bash command as a background job and return its job_id\nimmediately. Use for builds, tests, servers, or anything that may outlast\nrun's four-minute maximum. Jobs survive server restarts; their\noutput and exit code stay retrievable by job_id. Check on it with\njob_output; stop with job_kill; rediscover past jobs with job_list. There\nis no timeout: a job runs until it exits or is killed. Everything it\nprints is kept in a plain log file whose path is returned here;\ninspect_local_file or grep that path for anything job_output does not show. While your jobs\nrun, every FastCtx result carries a one-line background status naming\neach job and how long it has run, just above the closing Complete or\nPartial line. It is a readout, not a notification: it refreshes only when\nyou call a tool, so keep working — nothing reaches you if you stop.",
|
|
51
|
+
annotations(
|
|
52
|
+
title = "Start background bash job",
|
|
53
|
+
read_only_hint = false,
|
|
54
|
+
destructive_hint = false,
|
|
55
|
+
open_world_hint = false
|
|
56
|
+
)
|
|
57
|
+
)]
|
|
58
|
+
async fn run_background(
|
|
59
|
+
&self,
|
|
60
|
+
Parameters(request): Parameters<RunBackgroundRequest>,
|
|
61
|
+
) -> CallToolResult {
|
|
62
|
+
let _activity = self.activity.request();
|
|
63
|
+
let shell = self.shell.clone();
|
|
64
|
+
let status_shell = self.shell.clone();
|
|
65
|
+
run_blocking(
|
|
66
|
+
Arc::clone(&self.session),
|
|
67
|
+
Arc::clone(&self.shell_permits),
|
|
68
|
+
GLOBAL_TOKEN_BUDGET_ENV,
|
|
69
|
+
move || status_shell.background_status(None),
|
|
70
|
+
BudgetRetry::Never,
|
|
71
|
+
move || shell.run_background(request.clone()),
|
|
72
|
+
)
|
|
73
|
+
.await
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[tool(
|
|
77
|
+
name = "job_output",
|
|
78
|
+
description = "Query a background job: its status (running, exited with its code,\nkilled, or interrupted) plus output you have not been shown yet. Works for jobs\nstarted in earlier sessions. Long output is windowed: the newest lines\nthat fit, the start of the log on the first call, and a note naming the\nexact lines skipped. The job's whole output is a plain log file on disk\nwhose line numbers are the seq numbers used here, so inspect_local_file or\ngrep that path for anything not shown. The call blocks up to wait_ms, so raise it\nonly when you have nothing else to do. If output looks garbled (U+FFFD),\ncall again with encoding set to the source encoding (e.g. \"gbk\").\nComplete appears only once the job ends; servers and watchers never reach\nit. Take what you need and keep working — the background status on your\nnext result carries this job's state.",
|
|
79
|
+
annotations(
|
|
80
|
+
title = "Check background job output",
|
|
81
|
+
read_only_hint = true,
|
|
82
|
+
destructive_hint = false,
|
|
83
|
+
open_world_hint = false
|
|
84
|
+
)
|
|
85
|
+
)]
|
|
86
|
+
async fn job_output(
|
|
87
|
+
&self,
|
|
88
|
+
Parameters(request): Parameters<JobOutputRequest>,
|
|
89
|
+
context: RequestContext<RoleServer>,
|
|
90
|
+
) -> CallToolResult {
|
|
91
|
+
let _activity = self.activity.request();
|
|
92
|
+
let shell = self.shell.clone();
|
|
93
|
+
let status_shell = self.shell.clone();
|
|
94
|
+
let excluded_job = request.job_id.clone();
|
|
95
|
+
run_blocking(
|
|
96
|
+
Arc::clone(&self.session),
|
|
97
|
+
Arc::clone(&self.shell_permits),
|
|
98
|
+
JOB_OUTPUT_TOKEN_BUDGET_ENV,
|
|
99
|
+
move || status_shell.background_status(Some(&excluded_job)),
|
|
100
|
+
BudgetRetry::Never,
|
|
101
|
+
move || shell.job_output_until_cancelled(request.clone(), || context.ct.is_cancelled()),
|
|
102
|
+
)
|
|
103
|
+
.await
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[tool(
|
|
107
|
+
name = "job_kill",
|
|
108
|
+
description = "Kill a background job's whole process tree. Killing a job that has\nalready exited is not an error.",
|
|
109
|
+
annotations(
|
|
110
|
+
title = "Kill background job",
|
|
111
|
+
read_only_hint = false,
|
|
112
|
+
destructive_hint = false,
|
|
113
|
+
open_world_hint = false
|
|
114
|
+
)
|
|
115
|
+
)]
|
|
116
|
+
async fn job_kill(&self, Parameters(request): Parameters<JobKillRequest>) -> CallToolResult {
|
|
117
|
+
let _activity = self.activity.request();
|
|
118
|
+
let shell = self.shell.clone();
|
|
119
|
+
let status_shell = self.shell.clone();
|
|
120
|
+
let excluded_job = request.job_id.clone();
|
|
121
|
+
run_blocking(
|
|
122
|
+
Arc::clone(&self.session),
|
|
123
|
+
Arc::clone(&self.shell_permits),
|
|
124
|
+
GLOBAL_TOKEN_BUDGET_ENV,
|
|
125
|
+
move || status_shell.background_status(Some(&excluded_job)),
|
|
126
|
+
BudgetRetry::Never,
|
|
127
|
+
move || shell.job_kill(request.clone()),
|
|
128
|
+
)
|
|
129
|
+
.await
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[tool(
|
|
133
|
+
name = "job_list",
|
|
134
|
+
description = "List background jobs across all FastCtx sessions for the current user. Use\nstatus=\"all\" only when both lifecycles are needed. Results are newest first\nwithin each lifecycle. Finished records remain available until the job\nstorage limit evicts the oldest.",
|
|
135
|
+
annotations(
|
|
136
|
+
title = "List background jobs",
|
|
137
|
+
read_only_hint = true,
|
|
138
|
+
destructive_hint = false,
|
|
139
|
+
open_world_hint = false
|
|
140
|
+
)
|
|
141
|
+
)]
|
|
142
|
+
async fn job_list(&self, Parameters(request): Parameters<JobListRequest>) -> CallToolResult {
|
|
143
|
+
let _activity = self.activity.request();
|
|
144
|
+
let shell = self.shell.clone();
|
|
145
|
+
let status_shell = self.shell.clone();
|
|
146
|
+
run_blocking(
|
|
147
|
+
Arc::clone(&self.session),
|
|
148
|
+
Arc::clone(&self.shell_permits),
|
|
149
|
+
GLOBAL_TOKEN_BUDGET_ENV,
|
|
150
|
+
move || status_shell.background_status(None),
|
|
151
|
+
BudgetRetry::Never,
|
|
152
|
+
move || shell.job_list(request.clone()),
|
|
153
|
+
)
|
|
154
|
+
.await
|
|
155
|
+
}
|
|
156
|
+
}
|