dsh-gitbash-shell 0.39.3 → 0.40.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/CHANGELOG.md +185 -0
- package/README.md +75 -35
- package/README_EN.md +48 -49
- package/docs/experimental-native-execution.md +224 -0
- package/docs/experimental-native-verification.md +184 -0
- package/dsh.plugin.json +1 -1
- package/package.json +4 -3
- package/src/bash-tool.js +5 -7
- package/src/client.js +75 -22
- package/src/experimental-tools.js +47 -0
- package/src/index.js +98 -26
- package/src/inline-tools.js +103 -15
- package/src/native-executor.js +283 -0
- package/src/native-output.js +347 -0
- package/src/native-tool.js +195 -0
- package/src/path-tool.js +297 -0
- package/src/session-workdir.js +12 -0
- package/src/shell.js +78 -76
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { registerExperimentalTool } from './experimental-tools.js'
|
|
2
|
+
import { createNativeExecutor } from './native-executor.js'
|
|
3
|
+
import { processJob, processSources, ringDelta } from './bash-render.js'
|
|
4
|
+
|
|
5
|
+
const typedPath = () => ({ type: 'object', additionalProperties: false, properties: {
|
|
6
|
+
kind: { type: 'string', const: 'path', required: true }, value: { type: 'string', required: true },
|
|
7
|
+
} })
|
|
8
|
+
|
|
9
|
+
/** defineTool's AUTHOR property-map, not a raw object-root JSON schema. */
|
|
10
|
+
export function nativeParameters({ background = false, promote = false } = {}) {
|
|
11
|
+
return {
|
|
12
|
+
description: { type: 'string', required: true, description: 'Short description of the intended native operation.' },
|
|
13
|
+
executable: { ...typedPath(), required: true, description: 'Absolute native .exe path. MSYS paths translate only in kind:path values. Bare commands, scripts, .cmd and .bat are unsupported.' },
|
|
14
|
+
argv: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
15
|
+
kind: { type: 'string', enum: ['literal', 'path'], required: true }, value: { type: 'string', required: true },
|
|
16
|
+
} }, description: 'Each literal is passed unchanged (including /all, /c, quotes and empty strings); each path must be absolute and is explicitly translated to native Windows spelling. No shell expansion.' },
|
|
17
|
+
cwd: { ...typedPath(), description: 'Absolute typed working directory; omitted means the agent workspace.' },
|
|
18
|
+
entrypoint: { type: 'string', enum: ['native', 'interpreter'], description: 'Default native. Known interpreters require explicit interpreter. Their own command language/quoting is not made safe by direct argv; no automatic script wrapper.' },
|
|
19
|
+
timeoutMs: { type: 'number', description: promote ? 'Foreground wait deadline, default 120000ms, capped at 600000ms; expiry promotes the managed process to a job. Not accepted with run_in_background.' : 'Kill deadline, default 120000ms, capped at 600000ms. Not accepted with run_in_background.' },
|
|
20
|
+
...(background ? { run_in_background: { type: 'boolean', description: 'Use existing managed Jobs; collect with job_output, stop with job_kill. No tool deadline. Requires an attached job controller.' } } : {}),
|
|
21
|
+
stdoutEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], description: 'Known producer stdout encoding, default utf8. raw preserves unknown/mixed bytes without claiming reliable text. No auto detection.' },
|
|
22
|
+
stderrEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], description: 'Known producer stderr encoding, independently selected; default utf8.' },
|
|
23
|
+
errors: { type: 'string', enum: ['replace', 'strict'], description: 'Default replace; strict records decoding failures separately from actual command exit. Never rerun a side-effecting command merely because output decoding failed.' },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const streamSchema = { type: 'object', additionalProperties: false, properties: {
|
|
28
|
+
text: { type: 'string', required: true }, truncated: { type: 'boolean', required: true }, spillPath: { type: 'string' },
|
|
29
|
+
} }
|
|
30
|
+
// Core's author DSL supports integer, not numeric minimum keywords; the
|
|
31
|
+
// adapter owns non-negative counters and budget validation before execution.
|
|
32
|
+
const byteCount = () => ({ type: 'integer', required: true })
|
|
33
|
+
const factBoolean = () => ({ type: 'boolean', required: true })
|
|
34
|
+
const outputErrorSchema = { type: 'object', additionalProperties: false, properties: {
|
|
35
|
+
code: { type: 'string', required: true }, message: { type: 'string', required: true },
|
|
36
|
+
} }
|
|
37
|
+
const normalizedEncoding = ['utf-8', 'gbk', 'utf-16le', 'raw']
|
|
38
|
+
const artifactSchema = kind => ({ type: 'object', additionalProperties: false, properties: {
|
|
39
|
+
kind: { type: 'string', const: kind, required: true },
|
|
40
|
+
encoding: { type: 'string', ...(kind === 'decoded' ? { const: 'utf-8' } : { enum: normalizedEncoding }), required: true },
|
|
41
|
+
path: { type: 'string' }, bytes: byteCount(), complete: factBoolean(), capReached: factBoolean(), error: outputErrorSchema,
|
|
42
|
+
} })
|
|
43
|
+
const streamFactsSchema = { type: 'object', additionalProperties: false, properties: {
|
|
44
|
+
encoding: { type: 'string', enum: normalizedEncoding, required: true },
|
|
45
|
+
errors: { type: 'string', enum: ['replace', 'strict'], required: true },
|
|
46
|
+
rawBytes: byteCount(), decodedUtf8Bytes: byteCount(), rawCursor: byteCount(), decodedCursor: byteCount(),
|
|
47
|
+
reliableText: factBoolean(), lossy: factBoolean(), complete: factBoolean(), sourceComplete: factBoolean(),
|
|
48
|
+
aborted: factBoolean(), settled: factBoolean(),
|
|
49
|
+
termination: { type: 'string', enum: ['end', 'close-before-end', 'stream-error', 'missing-pipe', 'spawn-error', 'abort-drain-timeout', 'terminate-drain-timeout', 'exit-drain-timeout', 'provider-error-drain-timeout'] },
|
|
50
|
+
decodeError: outputErrorSchema, streamError: outputErrorSchema, storageError: outputErrorSchema,
|
|
51
|
+
artifact: { type: 'object', additionalProperties: false, required: true, properties: {
|
|
52
|
+
raw: artifactSchema('raw'), decoded: artifactSchema('decoded'),
|
|
53
|
+
} },
|
|
54
|
+
retainedMaxBytes: byteCount(),
|
|
55
|
+
} }
|
|
56
|
+
// Explicit current adapter DTO, including the two executor-owned degradation
|
|
57
|
+
// branches. Optional undefined projection never admits arbitrary output fields.
|
|
58
|
+
export const NATIVE_OUTPUT_FACTS_SCHEMA = { oneOf: [
|
|
59
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
60
|
+
stdout: { ...streamFactsSchema, required: true }, stderr: { ...streamFactsSchema, required: true },
|
|
61
|
+
budget: { type: 'object', additionalProperties: false, required: true, properties: {
|
|
62
|
+
maxTotalBytes: byteCount(), reservedMemoryBytes: byteCount(), artifactBytes: byteCount(), maxArtifactBytes: byteCount(),
|
|
63
|
+
} },
|
|
64
|
+
} },
|
|
65
|
+
{ type: 'object', additionalProperties: false, properties: { unavailable: { type: 'boolean', const: true, required: true } } },
|
|
66
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
67
|
+
projectionError: { type: 'string', required: true }, reliableText: { type: 'boolean', const: false, required: true },
|
|
68
|
+
} },
|
|
69
|
+
] }
|
|
70
|
+
const sandboxSchema = { type: 'object', additionalProperties: false, properties: {
|
|
71
|
+
mode: { type: 'string', const: 'danger-full-access', required: true }, denied: { type: 'boolean', const: false, required: true },
|
|
72
|
+
} }
|
|
73
|
+
const encodingSchema = { type: 'object', additionalProperties: false, properties: {
|
|
74
|
+
stdoutEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], required: true },
|
|
75
|
+
stderrEncoding: { type: 'string', enum: ['utf8', 'gbk', 'utf16le', 'raw'], required: true },
|
|
76
|
+
errors: { type: 'string', enum: ['replace', 'strict'], required: true },
|
|
77
|
+
} }
|
|
78
|
+
export const NATIVE_OUTPUT_SCHEMA = {
|
|
79
|
+
oneOf: [
|
|
80
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
81
|
+
kind: { type: 'string', const: 'foreground', required: true }, cwd: { type: 'string', required: true },
|
|
82
|
+
status: { type: 'string', enum: ['running', 'completed', 'killed'], required: true }, exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
83
|
+
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
84
|
+
timedOut: { type: 'boolean', required: true }, aborted: { type: 'boolean', required: true }, timeoutMs: { type: 'number', required: true },
|
|
85
|
+
stdout: { ...streamSchema, required: true }, stderr: { ...streamSchema, required: true },
|
|
86
|
+
sandbox: { ...sandboxSchema, required: true }, outputFacts: { ...NATIVE_OUTPUT_FACTS_SCHEMA, required: true },
|
|
87
|
+
} },
|
|
88
|
+
{ type: 'object', additionalProperties: false, properties: {
|
|
89
|
+
kind: { type: 'string', enum: ['background', 'promoted'], required: true }, cwd: { type: 'string', required: true },
|
|
90
|
+
jobId: { type: 'string', required: true }, timeoutMs: { type: 'number' }, output: { type: 'string' },
|
|
91
|
+
encoding: { ...encodingSchema, required: true },
|
|
92
|
+
} },
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderNativeResult(value) {
|
|
97
|
+
if (value.kind !== 'foreground') return `Native command ${value.kind === 'promoted' ? 'continues after the wait deadline' : 'admitted to managed Jobs'}; job id: ${value.jobId}. Collect with job_output, stop with job_kill.\n${value.output ?? ''}\n[requested encoding: ${JSON.stringify(value.encoding)}; final producer/output facts arrive in job output]`
|
|
98
|
+
const text = value.stdout.text + (value.stderr.text ? `\n[stderr]\n${value.stderr.text}` : '')
|
|
99
|
+
return `${text}\n[exit code: ${value.exitCode ?? 'unknown'}; status: ${value.status}; signal: ${value.signal ?? 'none'}; timedOut: ${value.timedOut}; aborted: ${value.aborted}]\n[native output facts: ${JSON.stringify(value.outputFacts)}]\nOutput decoding/storage problems do not mean the command did not execute; inspect producer exit facts before considering any retry.`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One per-agent module. Host packages and the shared path translator are injected. */
|
|
103
|
+
export function createNativeToolModule(deps) {
|
|
104
|
+
if (typeof deps.toolsModule?.defineTool !== 'function') throw new Error('gitbash_native: host defineTool capability unavailable')
|
|
105
|
+
return {
|
|
106
|
+
inject: ['tools', 'subprocess', 'shellEnv'],
|
|
107
|
+
apply(ctx, config = {}) {
|
|
108
|
+
const executor = createNativeExecutor(ctx, deps)
|
|
109
|
+
const enabled = config.enableRunInBackground !== false
|
|
110
|
+
const promoteOnTimeout = config.promoteOnTimeout !== false && enabled
|
|
111
|
+
const definition = jobs => {
|
|
112
|
+
const background = enabled && jobs !== undefined
|
|
113
|
+
const promote = background && promoteOnTimeout
|
|
114
|
+
const startJob = (request, exec) => {
|
|
115
|
+
let proc
|
|
116
|
+
let finalFacts = ''
|
|
117
|
+
const sources = processSources(() => proc)
|
|
118
|
+
sources.push({ channel: 'stderr', read(fromByte) {
|
|
119
|
+
const bytes = Buffer.from(finalFacts, 'utf8')
|
|
120
|
+
return { text: bytes.subarray(fromByte).toString('utf8'), nextOffset: bytes.length, lossy: false }
|
|
121
|
+
} })
|
|
122
|
+
const id = jobs.start({ kind: 'gitbash-native', label: request.description,
|
|
123
|
+
...(exec.agent ? { owner: exec.agent.id } : {}), output: sources,
|
|
124
|
+
run: () => processJob(async signal => {
|
|
125
|
+
proc = await executor.execute(request, exec, { signal, onExpiry: 'none' })
|
|
126
|
+
return proc
|
|
127
|
+
}, async started => {
|
|
128
|
+
// done has already sealed decoding and released this operation's scope.
|
|
129
|
+
const result = await started.result()
|
|
130
|
+
finalFacts = `\n[native producer: exitCode=${result.exitCode}, signal=${result.signal}, status=${result.status}, aborted=${result.aborted}]\n[native output facts: ${JSON.stringify(result.outputFacts)}]\nOutput failure is not command non-execution; do not blindly retry.\n`
|
|
131
|
+
return { status: result.status === 'killed' ? 'killed' : 'completed', detail: `exit code: ${result.exitCode ?? 'unknown'}; output facts retained in job output` }
|
|
132
|
+
}),
|
|
133
|
+
})
|
|
134
|
+
return { id, process: () => proc }
|
|
135
|
+
}
|
|
136
|
+
const execute = async (args, exec) => {
|
|
137
|
+
// prepare resolves policy BEFORE ensure/path/env/artifact work.
|
|
138
|
+
const request = await executor.prepare(args, exec)
|
|
139
|
+
exec.signal?.throwIfAborted()
|
|
140
|
+
if (request.run_in_background && !background) throw new Error('run_in_background is unavailable or disabled; this request was not executed')
|
|
141
|
+
if (request.run_in_background) {
|
|
142
|
+
const attached = startJob(request, exec)
|
|
143
|
+
return { kind: 'background', jobId: attached.id, cwd: request.cwd, encoding: request.output }
|
|
144
|
+
}
|
|
145
|
+
if (promote) {
|
|
146
|
+
// No retry/fallback around admission: a host throwing after starting a
|
|
147
|
+
// producer must not cause a second execution of a side-effecting call.
|
|
148
|
+
const attached = startJob(request, exec)
|
|
149
|
+
const owner = exec.agent?.id
|
|
150
|
+
let view
|
|
151
|
+
try { view = await jobs.wait(attached.id, executor.waitBudget(request), owner, exec.signal) }
|
|
152
|
+
catch (error) {
|
|
153
|
+
jobs.kill(attached.id, owner, 'tool call aborted')
|
|
154
|
+
await jobs.wait(attached.id, 10000, owner)
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
if (view.status === 'running' || view.status === 'stopping') {
|
|
158
|
+
if (!attached.process()) {
|
|
159
|
+
jobs.kill(attached.id, owner, 'timed out during native preparation')
|
|
160
|
+
await jobs.wait(attached.id, 10000, owner)
|
|
161
|
+
throw new Error(`native preparation timed out; job ${attached.id} was cancelled, inspect job_output before retrying`)
|
|
162
|
+
}
|
|
163
|
+
const read = jobs.read(attached.id, owner)
|
|
164
|
+
return { kind: 'promoted', jobId: attached.id, cwd: request.cwd, timeoutMs: request.timeoutMs,
|
|
165
|
+
output: ringDelta(read.chunks), encoding: request.output }
|
|
166
|
+
}
|
|
167
|
+
jobs.remove(attached.id, owner)
|
|
168
|
+
if (!attached.process()) throw new Error(view.detail ?? 'native preparation failed before a process handle was available')
|
|
169
|
+
return { kind: 'foreground', ...await attached.process().result() }
|
|
170
|
+
}
|
|
171
|
+
const proc = await executor.execute(request, exec)
|
|
172
|
+
return { kind: 'foreground', ...await proc.result() }
|
|
173
|
+
}
|
|
174
|
+
return deps.toolsModule.defineTool({ name: 'gitbash_native',
|
|
175
|
+
description: 'Choose this tool BEFORE execution for a Windows-native .exe that needs exact literal argv (for example /all or /c without MSYS conversion) or explicitly known independent stdout/stderr encodings. Use an offered shell tool instead for pipelines, redirection, globbing, shell expansion or POSIX scripts; this is direct typed argv through the host managed executor, not shell source. Supply an absolute executable, mark literal arguments unchanged and path arguments explicitly; do not put a whole shell command into argv. Prefer a producer UTF-8 option; declare gbk/utf16le only when known, raw for unknown or mixed bytes. In PTC call this offered SDK binding from inside run_code, not as a direct model tool call. Only actual danger-full-access standing policy is supported; missing/restricted policy is rejected before execution. A policy/access denial is a stop condition, never permission for another-tool retry. Decoding/storage failure preserves producer exit: never blindly repeat side effects. No user env, implicit scripts, .cmd/.bat wrappers, path guessing or encoding auto-detection. Known interpreters require explicit entrypoint:interpreter; common CRT/Node argv is supported, arbitrary interpreter quoting is not guaranteed.',
|
|
176
|
+
parameters: nativeParameters({ background, promote }),
|
|
177
|
+
output: { schema: NATIVE_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderNativeResult(value) }], presentationMeta: (_args, value) => ({ cwd: value.cwd }) },
|
|
178
|
+
execute,
|
|
179
|
+
presentCall: args => ({ card: 'generic', title: args.description, kind: 'execute', content: [{ type: 'text', text: args.executable.value }] }),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
let currentJobs
|
|
183
|
+
const refresh = registerExperimentalTool(ctx, () => definition(currentJobs), deps.experimentalEnabled, { scope: deps.scope, log: deps.log })
|
|
184
|
+
if (!enabled) return
|
|
185
|
+
ctx.inject(['jobs'], jobCtx => {
|
|
186
|
+
currentJobs = jobCtx.jobs
|
|
187
|
+
refresh()
|
|
188
|
+
jobCtx.effect(() => () => {
|
|
189
|
+
currentJobs = undefined
|
|
190
|
+
if (ctx.fiber?.state === 2) refresh()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/path-tool.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { registerExperimentalTool } from './experimental-tools.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-gitbash-shell/path-tool — the EXPLICIT full-value MSYS→native converter
|
|
5
|
+
* (`gitbash_to_native_path`, 2026-10-13, compat-implementation task
|
|
6
|
+
* IMPL-PATH-HELPER).
|
|
7
|
+
*
|
|
8
|
+
* WHY. run_code hands a program's path STRINGS to native Node, and the legacy
|
|
9
|
+
* codePaths literal rewrite can only ever guess which literals are paths
|
|
10
|
+
* (research REPORT.md: route/search data still gets corrupted, dynamic values
|
|
11
|
+
* are out of reach by design). The explicit alternative: the CALLER decides a
|
|
12
|
+
* complete value is a path and converts it AT RUNTIME through one tool:
|
|
13
|
+
*
|
|
14
|
+
* const r = await tools.gitbash_to_native_path({ msys_path: p })
|
|
15
|
+
* await fs.readFile(r.native_path, 'utf8')
|
|
16
|
+
*
|
|
17
|
+
* Contract (mirrors the approved research fixture `fixture_to_native`):
|
|
18
|
+
* - the parameter is named `msys_path` ON PURPOSE: the automatic
|
|
19
|
+
* tools/execute path-field rewrite only touches the whitelisted
|
|
20
|
+
* `file_path`/`path`/`workdir` fields (src/index.js
|
|
21
|
+
* TRANSLATABLE_PATH_FIELDS), so the raw MSYS value reaches this tool
|
|
22
|
+
* intact and the conversion is the tool's own explicit job;
|
|
23
|
+
* - the value goes through the SHARED `translateMsysPath` + a FRESH
|
|
24
|
+
* `buildTranslateEnv(...)` per call (injected, never copied here) — drive
|
|
25
|
+
* roots, ~, $HOME-style variables, /tmp, /dev/null and the Git install
|
|
26
|
+
* mounts resolve exactly like every other translated face of the plugin;
|
|
27
|
+
* - NO filesystem I/O, NO existence check, NO permission grant, NO global
|
|
28
|
+
* env seeding: the caller's own runtime policy still governs what the
|
|
29
|
+
* converted path may touch;
|
|
30
|
+
* - already Windows-native (drive-letter/UNC) or cwd-relative values are
|
|
31
|
+
* returned unchanged — declared here, in the description;
|
|
32
|
+
* - a POSIX-looking value that NO mount maps, or whose needed mount fact is
|
|
33
|
+
* unavailable, is REJECTED with an explicit error: returning the POSIX
|
|
34
|
+
* spelling as `native_path` would be a silent lie;
|
|
35
|
+
* - empty / NUL-byte / URL-shaped values are rejected outright; Unicode,
|
|
36
|
+
* `#` and spaces inside a path are preserved byte for byte;
|
|
37
|
+
* - the tool is INDEPENDENT of the automatic dialect switches
|
|
38
|
+
* (posixPaths/codePaths/virtualMounts): whether it is registered at all
|
|
39
|
+
* (and the Windows-only gate) is the main wiring's decision — this module
|
|
40
|
+
* carries no Config and reads no dialect state.
|
|
41
|
+
*
|
|
42
|
+
* Mounting follows the bash-tool pattern: `createPathToolModule(deps)` returns
|
|
43
|
+
* a cordis module ({ inject, apply }) that the main wiring mounts inside an
|
|
44
|
+
* agent-owned scope; registration reverts through the scope's effect on
|
|
45
|
+
* disposal. There are NO top-level static `@deepseek-ai/*` imports — the host
|
|
46
|
+
* `defineTool` and the shared translator ride the deps bag (v0.24.3 lesson: a
|
|
47
|
+
* failed static peer import silently drops the whole plugin row).
|
|
48
|
+
*
|
|
49
|
+
* The SUCCESS result is `{ native_path }`, a shape the result-echo layer
|
|
50
|
+
* never rewrites (rewriteResultPaths is keyed on the official tools' names
|
|
51
|
+
* and path metadata fields, not on this tool), so the native value cannot be
|
|
52
|
+
* translated back behind the caller's back.
|
|
53
|
+
*
|
|
54
|
+
* @module dsh-gitbash-shell/path-tool
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The registered tool name. */
|
|
58
|
+
export const PATH_TOOL_NAME = 'gitbash_to_native_path'
|
|
59
|
+
|
|
60
|
+
/** Windows drive-letter absolute: `C:/x`, `C:\x` (drive-relative `C:` is NOT). */
|
|
61
|
+
const WINDOWS_ABSOLUTE = /^[A-Za-z]:[\\/]/
|
|
62
|
+
|
|
63
|
+
/** A `scheme:` prefix — `https:`, `file:`, `app:`, … (single-letter `C:` excluded). */
|
|
64
|
+
const URL_SCHEME = /^([A-Za-z][A-Za-z0-9+.\-]*):/
|
|
65
|
+
|
|
66
|
+
const NUL_BYTE = '\0'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether the value is ALREADY in a native Windows spelling (drive-letter
|
|
70
|
+
* absolute or UNC, both slash styles). Pure.
|
|
71
|
+
* @param {unknown} value
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function isWindowsNativeValue(value) {
|
|
75
|
+
return typeof value === 'string'
|
|
76
|
+
&& (WINDOWS_ABSOLUTE.test(value) || value.startsWith('\\\\') || value.startsWith('//'))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether the value LOOKS like it needs MSYS mount resolution (POSIX root,
|
|
81
|
+
* home shorthand or a leading variable). A match does NOT promise a mapping
|
|
82
|
+
* exists — only that a passthrough would be a guess. Pure.
|
|
83
|
+
* @param {unknown} value
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function looksPosixValue(value) {
|
|
87
|
+
return typeof value === 'string'
|
|
88
|
+
&& (value.startsWith('/') || value.startsWith('~') || value.startsWith('$'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One honest line about which mount facts the conversion could see. `null`
|
|
93
|
+
* means the wiring turned mounts off; `undefined` means the wiring did not
|
|
94
|
+
* expose facts at all.
|
|
95
|
+
*/
|
|
96
|
+
function factSummary(env) {
|
|
97
|
+
if (env === null) return 'mount facts unavailable (virtual mounts are off)'
|
|
98
|
+
if (!env || typeof env !== 'object') return 'mount facts not exposed by this wiring'
|
|
99
|
+
return `mount facts: home=${env.home ? 'yes' : 'no'} tmpDir=${env.tmpDir ? 'yes' : 'no'} gitRoot=${env.gitRoot ? 'yes' : 'no'}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Convert ONE caller-declared full path value. Pure: string surgery only,
|
|
104
|
+
* the translator (the shared translateMsysPath composed with a fresh mount
|
|
105
|
+
* fact bag) is injected, nothing is probed here.
|
|
106
|
+
*
|
|
107
|
+
* Rejections are explicit and non-guessing:
|
|
108
|
+
* - EMPTY — not a non-empty string;
|
|
109
|
+
* - NUL_BYTE — a NUL byte anywhere in the value;
|
|
110
|
+
* - URL — a multi-letter scheme prefix (`https:…`, `file:…`);
|
|
111
|
+
* - NO_MOUNT_MAPPING — POSIX-looking but the shared mount table returned it
|
|
112
|
+
* unchanged: either no mount maps it or a needed fact is missing. The
|
|
113
|
+
* POSIX spelling is NEVER returned as `native_path`.
|
|
114
|
+
*
|
|
115
|
+
* @param {unknown} value - the complete value the caller has decided is a path.
|
|
116
|
+
* @param {{ translate?: (value: string, env?: object|null) => unknown,
|
|
117
|
+
* env?: object|null }} [options] - the injected shared translator
|
|
118
|
+
* and this call's fresh mount-fact bag (for the rejection diagnostics).
|
|
119
|
+
* @returns {{ ok: true, native_path: string,
|
|
120
|
+
* status: 'converted'|'passthrough', kind: string }
|
|
121
|
+
* | { ok: false, code: string, message: string }}
|
|
122
|
+
*/
|
|
123
|
+
export function convertMsysToNative(value, { translate, env } = {}) {
|
|
124
|
+
// The code rides the message: the registry carries only error.message to
|
|
125
|
+
// the model/bridge, so the rejection class must survive that one face.
|
|
126
|
+
const reject = (code, body) => ({ ok: false, code, message: `${PATH_TOOL_NAME} [${code}]: ${body}` })
|
|
127
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
128
|
+
return reject('EMPTY', 'msys_path must be a non-empty string')
|
|
129
|
+
}
|
|
130
|
+
if (value.includes(NUL_BYTE)) {
|
|
131
|
+
return reject('NUL_BYTE', 'msys_path contains a NUL byte; a single filesystem path is required')
|
|
132
|
+
}
|
|
133
|
+
const scheme = URL_SCHEME.exec(value)
|
|
134
|
+
if (scheme && scheme[1].length >= 2) {
|
|
135
|
+
return reject('URL', `msys_path looks like a URL (scheme '${scheme[1]}'); explicit conversion accepts a single filesystem path only`)
|
|
136
|
+
}
|
|
137
|
+
// Already native or cwd-relative: an honest, DECLARED passthrough — the
|
|
138
|
+
// description promises this, so no mount facts are consulted at all.
|
|
139
|
+
if (isWindowsNativeValue(value)) {
|
|
140
|
+
return { ok: true, native_path: value, status: 'passthrough', kind: 'windows-native' }
|
|
141
|
+
}
|
|
142
|
+
if (!looksPosixValue(value)) {
|
|
143
|
+
return { ok: true, native_path: value, status: 'passthrough', kind: 'cwd-relative' }
|
|
144
|
+
}
|
|
145
|
+
if (typeof translate !== 'function') {
|
|
146
|
+
return reject('NO_TRANSLATOR', `no shared translator is wired; refusing to guess a native form for ${JSON.stringify(value)}`)
|
|
147
|
+
}
|
|
148
|
+
let translated
|
|
149
|
+
try {
|
|
150
|
+
translated = translate(value, env ?? null)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return reject('TRANSLATE_FAILED', `the shared translator failed for ${JSON.stringify(value)}: ${error?.message ?? error}`)
|
|
153
|
+
}
|
|
154
|
+
if (typeof translated !== 'string' || translated.length === 0) {
|
|
155
|
+
return reject('TRANSLATE_FAILED', `the shared translator returned a non-string for ${JSON.stringify(value)}`)
|
|
156
|
+
}
|
|
157
|
+
if (translated !== value) {
|
|
158
|
+
return { ok: true, native_path: translated, status: 'converted', kind: 'msys' }
|
|
159
|
+
}
|
|
160
|
+
return reject('NO_MOUNT_MAPPING', `no MSYS mount mapping for ${JSON.stringify(value)} (${factSummary(env)}). Refusing to return the POSIX spelling as a native path — resolve the mount fact or convert the value at its source.`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The model-facing contract (kept in one place for both definition shapes). */
|
|
164
|
+
export function pathToolDescription() {
|
|
165
|
+
return 'Explicitly convert one complete caller-declared path value from the MSYS/Git Bash dialect '
|
|
166
|
+
+ '(/c/-style drive roots, ~, $HOME-style variables, /tmp, /dev/null, Git installation mounts) '
|
|
167
|
+
+ 'to its native Windows form and return { native_path }. Call this ONLY when the full value is '
|
|
168
|
+
+ 'already known to be a single filesystem path — the tool never inspects surrounding data and never guesses. '
|
|
169
|
+
+ 'Pure string conversion through the plugin\'s shared mount table: no filesystem I/O, no existence check, '
|
|
170
|
+
+ 'no permission grant; your runtime policy still governs the converted path. Values that are already '
|
|
171
|
+
+ 'Windows-native (drive-letter or UNC) or cwd-relative are returned unchanged, by design. A POSIX-looking '
|
|
172
|
+
+ 'value that no mount maps — or whose needed mount fact is unavailable — is REJECTED with an explicit error '
|
|
173
|
+
+ 'instead of a misleading passthrough. Independent of the automatic dialect switches; the parameter is named '
|
|
174
|
+
+ 'msys_path so the automatic path-field rewrite leaves it untouched. '
|
|
175
|
+
+ 'Use it for runtime-built MSYS paths before direct native Node fs/path I/O: '
|
|
176
|
+
+ 'await the result and pass result.native_path, never the original MSYS string. '
|
|
177
|
+
+ 'Already-native/cwd-relative Node values or ordinary translated tool path fields need no conversion. '
|
|
178
|
+
+ 'Never pass routes, search strings, shell commands or program source. '
|
|
179
|
+
+ 'In PTC invoke this offered SDK binding from inside run_code. A file policy/access denial is a stop condition, '
|
|
180
|
+
+ 'not a reason to convert the spelling and retry through Node or another tool.'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const MSYS_PATH_PARAM_DESCRIPTION = 'The complete value to convert, as one single MSYS/Git Bash path '
|
|
184
|
+
+ '(drive root /c/, ~, $HOME-style variable, /tmp, /dev/null or a Git install mount). '
|
|
185
|
+
+ 'Pass the FULL value only; the tool performs no I/O and no permission grant.'
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build the tool definition in whichever dialect the host supports: the real
|
|
189
|
+
* `defineTool` author schema when the host module is available (parameters is
|
|
190
|
+
* a property map with `required: true` on the property; the output object
|
|
191
|
+
* declares `additionalProperties: false` explicitly and marks every property
|
|
192
|
+
* required), or an equivalent plain JSON-schema object otherwise. Both shapes
|
|
193
|
+
* share ONE execute body.
|
|
194
|
+
* @param {object} deps
|
|
195
|
+
* @param {object|undefined} [deps.toolsModule] - `@deepseek-ai/dsh-tools` (defineTool).
|
|
196
|
+
* @param {(value: string, env?: object|null) => unknown} [deps.translate] -
|
|
197
|
+
* the shared translator, already composed fresh by the wiring.
|
|
198
|
+
* @param {(value: string, env: object|null) => unknown} [deps.translateMsysPath] -
|
|
199
|
+
* alternative to `translate`: the raw shared helper.
|
|
200
|
+
* @param {() => object|null|undefined} [deps.buildEnv] - fresh mount-facts
|
|
201
|
+
* reader (buildTranslateEnv composition); called once per tool call.
|
|
202
|
+
* @param {(message: string) => void} [deps.log]
|
|
203
|
+
* @returns {object} a registry-ready definition.
|
|
204
|
+
*/
|
|
205
|
+
export function buildPathToolDefinition({ toolsModule, translate, translateMsysPath, buildEnv, experimentalEnabled, log = () => {} } = {}) {
|
|
206
|
+
const convertValue = typeof translate === 'function'
|
|
207
|
+
? (value, env) => translate(value, env)
|
|
208
|
+
: typeof translateMsysPath === 'function'
|
|
209
|
+
? (value, env) => translateMsysPath(value, env)
|
|
210
|
+
: undefined
|
|
211
|
+
if (!convertValue) {
|
|
212
|
+
throw new Error('path-tool: a shared translator is required — pass translate (fresh composed) or translateMsysPath; the tool never copies the mount table')
|
|
213
|
+
}
|
|
214
|
+
const execute = (args) => {
|
|
215
|
+
if (experimentalEnabled?.() === false) throw new Error('gitbash_to_native_path is disabled by experimentalNative. Enable Experimental native execution / fail-closed in Settings > Plugins > dsh-gitbash-shell.')
|
|
216
|
+
// Fresh facts per call: the same lazy discipline as every other
|
|
217
|
+
// translation consumer (a settings bashPath change must be visible on
|
|
218
|
+
// the next call without a re-mount).
|
|
219
|
+
const env = typeof buildEnv === 'function' ? buildEnv() : undefined
|
|
220
|
+
const outcome = convertMsysToNative(args?.msys_path, { translate: convertValue, env })
|
|
221
|
+
if (!outcome.ok) throw new Error(outcome.message)
|
|
222
|
+
return { native_path: outcome.native_path }
|
|
223
|
+
}
|
|
224
|
+
if (typeof toolsModule?.defineTool === 'function') {
|
|
225
|
+
return toolsModule.defineTool({
|
|
226
|
+
name: PATH_TOOL_NAME,
|
|
227
|
+
description: pathToolDescription(),
|
|
228
|
+
parameters: {
|
|
229
|
+
msys_path: { type: 'string', required: true, description: MSYS_PATH_PARAM_DESCRIPTION },
|
|
230
|
+
},
|
|
231
|
+
output: {
|
|
232
|
+
schema: {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: { native_path: { type: 'string', required: true } },
|
|
235
|
+
additionalProperties: false,
|
|
236
|
+
},
|
|
237
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
238
|
+
},
|
|
239
|
+
isConcurrencySafe: true,
|
|
240
|
+
execute,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
log('path-tool: @deepseek-ai/dsh-tools not resolvable; building a plain JSON-schema definition')
|
|
244
|
+
return {
|
|
245
|
+
name: PATH_TOOL_NAME,
|
|
246
|
+
description: pathToolDescription(),
|
|
247
|
+
parameters: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: { msys_path: { type: 'string', description: MSYS_PATH_PARAM_DESCRIPTION } },
|
|
250
|
+
required: ['msys_path'],
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
},
|
|
253
|
+
output: {
|
|
254
|
+
schema: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
properties: { native_path: { type: 'string' } },
|
|
257
|
+
required: ['native_path'],
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
},
|
|
260
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
261
|
+
},
|
|
262
|
+
execute,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the cordis module the main wiring mounts inside an agent-owned scope.
|
|
268
|
+
* The tool registration is owned by the mounting scope: it reverts through
|
|
269
|
+
* the scope's effect at disposal, exactly like the bash-tool module's.
|
|
270
|
+
* Windows-only availability, agent gating and any dialect/config gate are the
|
|
271
|
+
* MAIN WIRING's decisions — this module carries none.
|
|
272
|
+
*
|
|
273
|
+
* Deps (final interface):
|
|
274
|
+
* - toolsModule: `@deepseek-ai/dsh-tools` when resolvable (defineTool);
|
|
275
|
+
* absent ⇒ plain JSON-schema definition, same surface.
|
|
276
|
+
* - translate: (value) => native, the shared translateMsysPath
|
|
277
|
+
* composed fresh per call by the wiring, OR
|
|
278
|
+
* - translateMsysPath + buildEnv: the raw shared helper plus a fresh
|
|
279
|
+
* mount-facts reader `() => buildTranslateEnv(...)`;
|
|
280
|
+
* buildEnv is called once per tool call.
|
|
281
|
+
* - log: optional (message) => void.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} [deps]
|
|
284
|
+
* @returns {{ inject: string[], apply: (ctx: object) => void }}
|
|
285
|
+
*/
|
|
286
|
+
export function createPathToolModule(deps = {}) {
|
|
287
|
+
const definition = buildPathToolDefinition(deps)
|
|
288
|
+
return {
|
|
289
|
+
inject: ['tools'],
|
|
290
|
+
apply(ctx) {
|
|
291
|
+
if (typeof ctx?.tools?.register !== 'function') {
|
|
292
|
+
throw new Error('path-tool: ctx.tools.register is unavailable; cannot own the tool registration')
|
|
293
|
+
}
|
|
294
|
+
registerExperimentalTool(ctx, () => definition, deps.experimentalEnabled, { scope: deps.scope, log: deps.log })
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Use the host's current directory; retain the old policy/header seam when absent. */
|
|
2
|
+
export async function resolveSessionCwd(ctx, exec, policy) {
|
|
3
|
+
if (exec.agent === undefined) return undefined
|
|
4
|
+
exec.signal?.throwIfAborted()
|
|
5
|
+
const service = typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined
|
|
6
|
+
if (service !== undefined) {
|
|
7
|
+
if (typeof service?.ensure !== 'function') throw new TypeError('workingDirectory service does not expose ensure()')
|
|
8
|
+
// Existing service errors must not become a silent downgrade to the original cwd.
|
|
9
|
+
return service.ensure(exec.agent, exec.signal)
|
|
10
|
+
}
|
|
11
|
+
return policy?.workspaceRoot ?? exec.agent.session?.header?.cwd
|
|
12
|
+
}
|